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) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 12:56:10 +05:30
parent 85a78eb21e
commit e39caa3c80
8 changed files with 252 additions and 29 deletions
@@ -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);
}