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:
@@ -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<ClientRegistryEntry> = {}): 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<string, unknown> = {}, 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<string, unknown> = {}, 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);
|
||||
|
||||
Reference in New Issue
Block a user