e39caa3c80
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) <noreply@anthropic.com>
77 lines
2.7 KiB
TypeScript
77 lines
2.7 KiB
TypeScript
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,
|
|
PublicKeyResolverPort,
|
|
} from './context-attestation';
|
|
|
|
/** Client registry backed by Postgres (IiosClientRegistry). */
|
|
@Injectable()
|
|
export class PrismaClientRegistry implements ClientRegistryPort {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async find(clientId: string): Promise<ClientRegistryEntry | null> {
|
|
const r = await this.prisma.iiosClientRegistry.findUnique({ where: { clientId } });
|
|
if (!r) return null;
|
|
return {
|
|
clientId: r.clientId,
|
|
clientType: r.clientType,
|
|
allowedAppIds: r.allowedAppIds,
|
|
attestSecret: r.attestSecret ?? undefined,
|
|
jwksUri: r.jwksUri ?? undefined,
|
|
status: r.status === 'ACTIVE' ? 'ACTIVE' : 'DISABLED',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Nonce ledger backed by Postgres (IiosAttestationNonce). Reserve-if-absent is atomic via the
|
|
* unique primary key: a duplicate insert (P2002) means the nonce was already used → replay.
|
|
*/
|
|
@Injectable()
|
|
export class PrismaNonceStore implements NonceStorePort {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean> {
|
|
try {
|
|
await this.prisma.iiosAttestationNonce.create({ data: input });
|
|
return true;
|
|
} catch (e) {
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') return false; // already seen
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<string, JwksClient>();
|
|
|
|
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<string | null> {
|
|
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
|
|
}
|
|
}
|
|
}
|