From e39caa3c80f8e6e32c2d6d032b991c60a9ff50c3 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 15 Jul 2026 12:56:10 +0530 Subject: [PATCH] feat: verify context attestations via ES256/JWKS + rename client to appshell-crm Proof #3 now supports the production asymmetric path, not just the dev shared secret. - context-attestation.ts: PublicKeyResolverPort seam; verify() picks ES256 (JWKS by kid) when the client has a jwksUri, else HS256 (dev). signDevAttestationES256 helper. - attestation-stores.ts: JwksPublicKeyResolver (jwks-rsa, one cached client per URI, refetch on rotation, fail-closed to NO_KEY). - attestation.module.ts: inject the resolver into the verifier; dev-seed the client as clientType APPSHELL (it is the CRM browser-flow parent per the July-12 notes). - rename the registered client crm-support-widget -> appshell-crm (the name reflects the attesting parent, not a widget). - specs: ES256 (valid via JWKS, wrong key -> BAD_SIGNATURE, unknown kid -> NO_KEY, no resolver -> NO_KEY) + two stolen-token gate cases (wrong app binding -> APP_MISMATCH, wrong audience -> WRONG_AUDIENCE). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/iios-service/.env.example | 2 +- packages/iios-service/package.json | 1 + .../src/platform/attestation-stores.ts | 36 ++++++++- .../src/platform/attestation.module.ts | 15 ++-- .../context-attestation.guard.spec.ts | 45 ++++++++--- .../src/platform/context-attestation.spec.ts | 77 +++++++++++++++++-- .../src/platform/context-attestation.ts | 64 +++++++++++++-- pnpm-lock.yaml | 41 ++++++++++ 8 files changed, 252 insertions(+), 29 deletions(-) diff --git a/packages/iios-service/.env.example b/packages/iios-service/.env.example index 527e6b3..9053606 100644 --- a/packages/iios-service/.env.example +++ b/packages/iios-service/.env.example @@ -54,7 +54,7 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12) # Audience IIOS requires on attestations addressed to it. IIOS_ATTESTATION_AUDIENCE=iios-core # Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the -# crm-support-widget client into the registry when IIOS_DEV_TOKENS=1). +# appshell-crm client into the registry when IIOS_DEV_TOKENS=1). # IIOS_ATTESTATION_DEV_SECRET=appshell-dev-signing-key # When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403). # Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless. diff --git a/packages/iios-service/package.json b/packages/iios-service/package.json index 9705868..30298be 100644 --- a/packages/iios-service/package.json +++ b/packages/iios-service/package.json @@ -26,6 +26,7 @@ "dotenv": "^16.4.7", "ioredis": "^5.11.1", "jsonwebtoken": "^9.0.3", + "jwks-rsa": "^4.1.0", "prisma": "^6.2.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", diff --git a/packages/iios-service/src/platform/attestation-stores.ts b/packages/iios-service/src/platform/attestation-stores.ts index 8978e7e..8bf4a6c 100644 --- a/packages/iios-service/src/platform/attestation-stores.ts +++ b/packages/iios-service/src/platform/attestation-stores.ts @@ -1,7 +1,13 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import { JwksClient } from 'jwks-rsa'; import { PrismaService } from '../prisma/prisma.service'; -import type { ClientRegistryEntry, ClientRegistryPort, NonceStorePort } from './context-attestation'; +import type { + ClientRegistryEntry, + ClientRegistryPort, + NonceStorePort, + PublicKeyResolverPort, +} from './context-attestation'; /** Client registry backed by Postgres (IiosClientRegistry). */ @Injectable() @@ -40,3 +46,31 @@ export class PrismaNonceStore implements NonceStorePort { } } } + +/** + * Resolves a client's public signing key from its JWKS (prod ES256 path). Keeps ONE JwksClient + * per jwksUri — the client caches keys and refetches on a cache miss (so key rotation is picked up + * without a restart). Returns null on any failure so the verifier fails closed with NO_KEY. + */ +@Injectable() +export class JwksPublicKeyResolver implements PublicKeyResolverPort { + private readonly clients = new Map(); + + private clientFor(jwksUri: string): JwksClient { + let c = this.clients.get(jwksUri); + if (!c) { + c = new JwksClient({ jwksUri, cache: true, cacheMaxEntries: 8, cacheMaxAge: 10 * 60_000, rateLimit: true, jwksRequestsPerMinute: 12 }); + this.clients.set(jwksUri, c); + } + return c; + } + + async resolve(jwksUri: string, kid: string): Promise { + try { + const key = await this.clientFor(jwksUri).getSigningKey(kid); + return key.getPublicKey(); // PEM (SPKI) — works for EC (ES256) and RSA keys + } catch { + return null; // unknown kid / unreachable JWKS / malformed key → fail closed + } + } +} diff --git a/packages/iios-service/src/platform/attestation.module.ts b/packages/iios-service/src/platform/attestation.module.ts index 979045e..0c0acf3 100644 --- a/packages/iios-service/src/platform/attestation.module.ts +++ b/packages/iios-service/src/platform/attestation.module.ts @@ -1,7 +1,7 @@ import { Global, Module, type OnModuleInit } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { ContextAttestationVerifier } from './context-attestation'; -import { PrismaClientRegistry, PrismaNonceStore } from './attestation-stores'; +import { PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver } from './attestation-stores'; import { ContextAttestationGuard } from './context-attestation.guard'; /** @@ -14,11 +14,12 @@ import { ContextAttestationGuard } from './context-attestation.guard'; providers: [ PrismaClientRegistry, PrismaNonceStore, + JwksPublicKeyResolver, { provide: ContextAttestationVerifier, - useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore) => - new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }), - inject: [PrismaClientRegistry, PrismaNonceStore], + useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore, keys: JwksPublicKeyResolver) => + new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }, keys), + inject: [PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver], }, ContextAttestationGuard, ], @@ -35,9 +36,9 @@ export class AttestationModule implements OnModuleInit { if (!secret) return; await this.prisma.iiosClientRegistry .upsert({ - where: { clientId: 'crm-support-widget' }, - create: { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' }, - update: { attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' }, + where: { clientId: 'appshell-crm' }, + create: { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' }, + update: { clientType: 'APPSHELL', attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' }, }) .catch(() => undefined); } diff --git a/packages/iios-service/src/platform/context-attestation.guard.spec.ts b/packages/iios-service/src/platform/context-attestation.guard.spec.ts index 711264e..5993e73 100644 --- a/packages/iios-service/src/platform/context-attestation.guard.spec.ts +++ b/packages/iios-service/src/platform/context-attestation.guard.spec.ts @@ -16,26 +16,38 @@ const fakeSession = { verify: () => ({ userId: 'agent_1', appId: 'crm-web', orgId: 'org', tenantId: 'tnt', displayName: 'A' }), } as unknown as SessionVerifier; -function makeGuard(): ContextAttestationGuard { +const registryFor = (): ClientRegistryPort => ({ + find: async (id) => + id === 'appshell-crm' + ? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' } + : null, +}); +const freshNonces = (): NonceStorePort => { const seen = new Set(); - const registry: ClientRegistryPort = { - find: async (id) => - id === 'crm-support-widget' - ? { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' } - : null, - }; - const nonces: NonceStorePort = { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) }; - const verifier = new ContextAttestationVerifier(registry, nonces, { audience: 'iios-core' }); + return { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) }; +}; + +function makeGuard(): ContextAttestationGuard { + const verifier = new ContextAttestationVerifier(registryFor(), freshNonces(), { audience: 'iios-core' }); return new ContextAttestationGuard(fakeSession, verifier); } +// A guard whose actor token resolves to `tokenApp` — used to test attestation↔token app binding. +function makeGuardForApp(tokenApp: string): ContextAttestationGuard { + const session = { + verify: () => ({ userId: 'agent_1', appId: tokenApp, orgId: 'org', tenantId: 'tnt', displayName: 'A' }), + } as unknown as SessionVerifier; + const verifier = new ContextAttestationVerifier(registryFor(), freshNonces(), { audience: 'iios-core' }); + return new ContextAttestationGuard(session, verifier); +} + function ctxWith(headers: Record): ExecutionContext { return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext; } function att(over: Record = {}, secret = SECRET): string { return signDevAttestation( - { issuer: 'appshell.crm', audience: 'iios-core', clientId: 'crm-support-widget', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over }, + { issuer: 'appshell.crm', audience: 'iios-core', clientId: 'appshell-crm', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over }, secret, ); } @@ -68,4 +80,17 @@ describe('ContextAttestationGuard (three-proof gate on the request path)', () => expect(await guard.canActivate(ctxWith(headers))).toBe(true); await expect(guard.canActivate(ctxWith({ ...headers }))).rejects.toBeInstanceOf(ForbiddenException); }); + + // ── doc rejection matrix: "valid token + wrong client/context → reject" ── + it('rejects a valid attestation whose app binding != the actor token app (stolen context reused by another app)', async () => { + // Attestation is validly signed for app crm-web, but the presented actor token is for a different app. + const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_appmix' }) }; + await expect(makeGuardForApp('other-app').canActivate(ctxWith(headers))).rejects.toThrow(/APP_MISMATCH/); + }); + + // ── doc rejection matrix: "valid token + wrong audience → reject before business logic" ── + it('rejects an attestation minted for another service (audience != iios-core) replayed at IIOS', async () => { + const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_aud', audience: 'some-other-service' }) }; + await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toThrow(/WRONG_AUDIENCE/); + }); }); diff --git a/packages/iios-service/src/platform/context-attestation.spec.ts b/packages/iios-service/src/platform/context-attestation.spec.ts index c17e96d..79ecfa2 100644 --- a/packages/iios-service/src/platform/context-attestation.spec.ts +++ b/packages/iios-service/src/platform/context-attestation.spec.ts @@ -1,11 +1,14 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { generateKeyPairSync } from 'node:crypto'; import { PrismaClient } from '@prisma/client'; import { ContextAttestationVerifier, signDevAttestation, + signDevAttestationES256, type ClientRegistryEntry, type ClientRegistryPort, type NonceStorePort, + type PublicKeyResolverPort, } from './context-attestation'; import { PrismaNonceStore } from './attestation-stores'; import type { PrismaService } from '../prisma/prisma.service'; @@ -17,7 +20,7 @@ const NOW = new Date('2026-07-13T12:00:00Z'); // ─── in-memory fakes (fast, deterministic — no DB needed) ───────── function fakeRegistry(over: Partial = {}): ClientRegistryPort { const entry: ClientRegistryEntry = { - clientId: 'crm-support-widget', + clientId: 'appshell-crm', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: APPSHELL_SECRET, @@ -38,7 +41,7 @@ const verifier = (reg: ClientRegistryPort = fakeRegistry(), nonces: NonceStorePo /** A well-formed AppShell attestation, overridable per test. */ function attest(over: Record = {}, secret = APPSHELL_SECRET, at = NOW): string { return signDevAttestation( - { issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'crm-support-widget', appId: 'crm-web', nonce: 'n_default', ...over }, + { issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', appId: 'crm-web', nonce: 'n_default', ...over }, secret, at, ); @@ -48,7 +51,7 @@ describe('ContextAttestationVerifier (July 12 trust proof)', () => { it('accepts a valid attestation from a registered client for the matching app', async () => { const res = await verifier().verify(attest({ nonce: 'n_ok' }), 'crm-web', NOW); expect(res.ok).toBe(true); - if (res.ok) expect(res.attestation.clientId).toBe('crm-support-widget'); + if (res.ok) expect(res.attestation.clientId).toBe('appshell-crm'); }); // ── the five rejection cases the CEO named ── @@ -101,6 +104,70 @@ describe('ContextAttestationVerifier (July 12 trust proof)', () => { }); }); +// ─── prod asymmetric path: ES256 verified via the client's JWKS ─── +describe('ContextAttestationVerifier — JWKS / ES256 (production signing path)', () => { + const JWKS = 'https://appshell.example/.well-known/jwks.json'; + const KID = 'appshell-key-1'; + + // A real EC P-256 keypair (what AppShell would hold; only the public half is published as JWKS). + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + const pubPem = publicKey.export({ type: 'spki', format: 'pem' }) as string; + const { privateKey: otherPriv } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const otherPrivPem = otherPriv.export({ type: 'pkcs8', format: 'pem' }) as string; + + // Registry entry that verifies via JWKS (no shared secret) + a resolver that maps KID → pubkey. + const jwksRegistry: ClientRegistryPort = { + find: async (id) => + id === 'appshell-crm' + ? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], jwksUri: JWKS, status: 'ACTIVE' } + : null, + }; + const resolver: PublicKeyResolverPort = { resolve: async (_uri, kid) => (kid === KID ? pubPem : null) }; + const v = () => new ContextAttestationVerifier(jwksRegistry, fakeNonces(), { audience: AUD }, resolver); + // No 4th arg → no key resolver wired at all (distinct from passing undefined, which hits the default). + const vNoResolver = () => new ContextAttestationVerifier(jwksRegistry, fakeNonces(), { audience: AUD }); + + function es256(over: Record = {}, priv = privPem, kid = KID): string { + return signDevAttestationES256( + { issuer: 'appshell.crm', issuerType: 'APPSHELL', audience: AUD, clientId: 'appshell-crm', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over }, + priv, + kid, + NOW, + ); + } + + it('accepts an ES256 attestation whose signature verifies against the JWKS', async () => { + const res = await v().verify(es256({ nonce: 'es_ok' }), 'crm-web', NOW); + expect(res.ok).toBe(true); + if (res.ok) expect(res.attestation.clientId).toBe('appshell-crm'); + }); + + it('rejects an ES256 attestation signed with a DIFFERENT private key (forged)', async () => { + const res = await v().verify(es256({ nonce: 'es_forged' }, otherPrivPem), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'BAD_SIGNATURE' }); + }); + + it('rejects when the JWT header carries an unknown kid (no matching JWKS key)', async () => { + const res = await v().verify(es256({ nonce: 'es_kid' }, privPem, 'rotated-away-kid'), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'NO_KEY' }); + }); + + it('rejects when a JWKS client is configured but no key resolver is wired', async () => { + const res = await vNoResolver().verify(es256({ nonce: 'es_nores' }), 'crm-web', NOW); + expect(res).toMatchObject({ ok: false, reason: 'NO_KEY' }); + }); + + it('still enforces audience / app / replay on the ES256 path', async () => { + const vv = v(); + expect(await vv.verify(es256({ nonce: 'es_aud', audience: 'other' }), 'crm-web', NOW)).toMatchObject({ ok: false, reason: 'WRONG_AUDIENCE' }); + expect(await vv.verify(es256({ nonce: 'es_app' }), 'some-other-app', NOW)).toMatchObject({ ok: false, reason: 'APP_MISMATCH' }); + const tok = es256({ nonce: 'es_replay' }); + expect((await vv.verify(tok, 'crm-web', NOW)).ok).toBe(true); + expect(await vv.verify(tok, 'crm-web', NOW)).toMatchObject({ ok: false, reason: 'REPLAY' }); + }); +}); + // ─── DB-level atomicity of the nonce ledger ─────────────────────── describe('PrismaNonceStore (atomic replay defense)', () => { const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; @@ -117,8 +184,8 @@ describe('PrismaNonceStore (atomic replay defense)', () => { if (!dbUp) return ctx.skip(); // DB unreachable in this environment — the in-memory REPLAY test covers the logic const nonce = `n_db_${NOW.getTime()}_${Math.floor(Math.random() * 1e9)}`; const expiresAt = new Date(NOW.getTime() + 300_000); - const first = await store.reserve({ nonce, clientId: 'crm-support-widget', expiresAt }); - const second = await store.reserve({ nonce, clientId: 'crm-support-widget', expiresAt }); + const first = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt }); + const second = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt }); expect(first).toBe(true); expect(second).toBe(false); await prisma.iiosAttestationNonce.delete({ where: { nonce } }).catch(() => undefined); diff --git a/packages/iios-service/src/platform/context-attestation.ts b/packages/iios-service/src/platform/context-attestation.ts index 66d7046..1395721 100644 --- a/packages/iios-service/src/platform/context-attestation.ts +++ b/packages/iios-service/src/platform/context-attestation.ts @@ -32,7 +32,7 @@ export interface ClientRegistryEntry { clientType: string; allowedAppIds: string[]; attestSecret?: string; // dev: shared HS256 signing key (AppShell's key stand-in) - jwksUri?: string; // prod: asymmetric verification (seam — not used in dev) + jwksUri?: string; // prod: verify the attestation signature asymmetrically (ES256) via this JWKS status: 'ACTIVE' | 'DISABLED'; } @@ -45,6 +45,16 @@ export interface NonceStorePort { reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise; } +/** + * Resolves the PUBLIC key for a JWKS uri + key id, for the prod asymmetric (ES256) path. + * A port so the verifier stays network-free and unit-testable — the real impl fetches + + * caches the client's JWKS; tests inject a fake that returns a known key. + */ +export interface PublicKeyResolverPort { + /** Return the signing key (PEM) for `kid` at `jwksUri`, or null if it can't be resolved. */ + resolve(jwksUri: string, kid: string): Promise; +} + export type AttestationReason = | 'MALFORMED' | 'UNKNOWN_CLIENT' @@ -72,6 +82,8 @@ export class ContextAttestationVerifier { private readonly registry: ClientRegistryPort, private readonly nonces: NonceStorePort, private readonly config: AttestationConfig, + /** Optional — required only for clients that verify via a JWKS (prod ES256). */ + private readonly keys?: PublicKeyResolverPort, ) {} /** @@ -91,13 +103,16 @@ export class ContextAttestationVerifier { const client = await this.registry.find(claimed.clientId); if (!client) return deny('UNKNOWN_CLIENT', `client "${claimed.clientId}" is not registered`); if (client.status !== 'ACTIVE') return deny('CLIENT_DISABLED', `client "${claimed.clientId}" is ${client.status}`); - if (!client.attestSecret) return deny('NO_KEY', `no verification key configured for "${claimed.clientId}"`); - // 3. Verify the signature with the client's key (dev HS256; JWKS is the prod seam). - // ignoreExpiration so we can return a precise EXPIRED reason (step 6) rather than BAD_SIGNATURE. + // 3. Verify the signature with the client's key. A client verifies EITHER asymmetrically via + // its published JWKS (prod: AppShell signs ES256 with a private key) OR with a shared HS256 + // secret (dev stand-in). ignoreExpiration so we can return a precise EXPIRED (step 6) rather + // than BAD_SIGNATURE. + const key = await this.resolveVerificationKey(attestationJwt, client); + if (!key.ok) return deny(key.reason, key.detail); let att: ContextAttestation & jwt.JwtPayload; try { - att = jwt.verify(attestationJwt, client.attestSecret, { algorithms: ['HS256'], ignoreExpiration: true }) as ContextAttestation & jwt.JwtPayload; + att = jwt.verify(attestationJwt, key.pem, { algorithms: [key.alg], ignoreExpiration: true }) as ContextAttestation & jwt.JwtPayload; } catch (e) { return deny('BAD_SIGNATURE', (e as Error).message); } @@ -119,6 +134,29 @@ export class ContextAttestationVerifier { return { ok: true, attestation: att }; } + + /** + * Pick the algorithm + verification key for a client. JWKS (ES256) wins when configured: + * read the `kid` from the JWT header and resolve the public key from the client's JWKS. + * Otherwise fall back to the shared HS256 dev secret. Either way returns a precise NO_KEY + * reason when no usable key can be obtained (fail-closed). + */ + private async resolveVerificationKey( + attestationJwt: string, + client: ClientRegistryEntry, + ): Promise<{ ok: true; pem: string; alg: 'ES256' | 'HS256' } | { ok: false; reason: AttestationReason; detail: string }> { + if (client.jwksUri) { + if (!this.keys) return { ok: false, reason: 'NO_KEY', detail: `client "${client.clientId}" uses JWKS but no key resolver is wired` }; + const decoded = jwt.decode(attestationJwt, { complete: true }); + const kid = decoded && typeof decoded === 'object' ? (decoded.header?.kid as string | undefined) : undefined; + if (!kid) return { ok: false, reason: 'NO_KEY', detail: 'attestation header missing kid (required for JWKS)' }; + const pem = await this.keys.resolve(client.jwksUri, kid).catch(() => null); + if (!pem) return { ok: false, reason: 'NO_KEY', detail: `no JWKS key for kid "${kid}"` }; + return { ok: true, pem, alg: 'ES256' }; + } + if (client.attestSecret) return { ok: true, pem: client.attestSecret, alg: 'HS256' }; + return { ok: false, reason: 'NO_KEY', detail: `no verification key configured for "${client.clientId}"` }; + } } /** @@ -134,3 +172,19 @@ export function signDevAttestation( const iat = Math.floor(now.getTime() / 1000); return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, secret, { algorithm: 'HS256' }); } + +/** + * DEV/TEST helper: mint an attestation signed ASYMMETRICALLY (ES256), the way production AppShell + * does. `privateKeyPem` is a PKCS#8 EC private key; `kid` is stamped into the JWT header so the + * verifier can pick the matching public key from the client's JWKS. + */ +export function signDevAttestationES256( + claims: Omit & { ttlSeconds?: number }, + privateKeyPem: string, + kid: string, + now: Date = new Date(), +): string { + const { ttlSeconds = 300, ...rest } = claims; + const iat = Math.floor(now.getTime() / 1000); + return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, privateKeyPem, { algorithm: 'ES256', keyid: kid }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b58d8bd..5159988 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,6 +370,9 @@ importers: jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + jwks-rsa: + specifier: ^4.1.0 + version: 4.1.0 prisma: specifier: ^6.2.1 version: 6.19.3(typescript@5.9.3) @@ -2297,6 +2300,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -2343,6 +2349,10 @@ packages: jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jwks-rsa@4.1.0: + resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0} + jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} @@ -2353,6 +2363,9 @@ packages: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2368,6 +2381,9 @@ packages: resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} engines: {node: '>=6.11.5'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -2406,6 +2422,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-memoizer@3.0.0: + resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -5066,6 +5085,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -5113,6 +5134,17 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 + jwks-rsa@4.1.0: + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 6.2.3 + limiter: 1.1.5 + lru-cache: 11.5.1 + lru-memoizer: 3.0.0 + transitivePeerDependencies: + - supports-color + jws@4.0.1: dependencies: jwa: 2.0.1 @@ -5122,6 +5154,8 @@ snapshots: lilconfig@3.1.3: {} + limiter@1.1.5: {} + lines-and-columns@1.2.4: {} load-esm@1.0.3: {} @@ -5130,6 +5164,8 @@ snapshots: loader-runner@4.3.2: {} + lodash.clonedeep@4.5.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -5159,6 +5195,11 @@ snapshots: dependencies: yallist: 3.1.1 + lru-memoizer@3.0.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 11.5.1 + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5