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:
@@ -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.
|
# Audience IIOS requires on attestations addressed to it.
|
||||||
IIOS_ATTESTATION_AUDIENCE=iios-core
|
IIOS_ATTESTATION_AUDIENCE=iios-core
|
||||||
# Dev only: shared HS256 secret AppShell's stand-in signs attestations with (seeds the
|
# 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
|
# IIOS_ATTESTATION_DEV_SECRET=appshell-dev-signing-key
|
||||||
# When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403).
|
# 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.
|
# Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"jwks-rsa": "^4.1.0",
|
||||||
"prisma": "^6.2.1",
|
"prisma": "^6.2.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { JwksClient } from 'jwks-rsa';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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). */
|
/** Client registry backed by Postgres (IiosClientRegistry). */
|
||||||
@Injectable()
|
@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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Global, Module, type OnModuleInit } from '@nestjs/common';
|
import { Global, Module, type OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { ContextAttestationVerifier } from './context-attestation';
|
import { ContextAttestationVerifier } from './context-attestation';
|
||||||
import { PrismaClientRegistry, PrismaNonceStore } from './attestation-stores';
|
import { PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver } from './attestation-stores';
|
||||||
import { ContextAttestationGuard } from './context-attestation.guard';
|
import { ContextAttestationGuard } from './context-attestation.guard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,11 +14,12 @@ import { ContextAttestationGuard } from './context-attestation.guard';
|
|||||||
providers: [
|
providers: [
|
||||||
PrismaClientRegistry,
|
PrismaClientRegistry,
|
||||||
PrismaNonceStore,
|
PrismaNonceStore,
|
||||||
|
JwksPublicKeyResolver,
|
||||||
{
|
{
|
||||||
provide: ContextAttestationVerifier,
|
provide: ContextAttestationVerifier,
|
||||||
useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore) =>
|
useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore, keys: JwksPublicKeyResolver) =>
|
||||||
new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }),
|
new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }, keys),
|
||||||
inject: [PrismaClientRegistry, PrismaNonceStore],
|
inject: [PrismaClientRegistry, PrismaNonceStore, JwksPublicKeyResolver],
|
||||||
},
|
},
|
||||||
ContextAttestationGuard,
|
ContextAttestationGuard,
|
||||||
],
|
],
|
||||||
@@ -35,9 +36,9 @@ export class AttestationModule implements OnModuleInit {
|
|||||||
if (!secret) return;
|
if (!secret) return;
|
||||||
await this.prisma.iiosClientRegistry
|
await this.prisma.iiosClientRegistry
|
||||||
.upsert({
|
.upsert({
|
||||||
where: { clientId: 'crm-support-widget' },
|
where: { clientId: 'appshell-crm' },
|
||||||
create: { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' },
|
create: { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' },
|
||||||
update: { attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' },
|
update: { clientType: 'APPSHELL', attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' },
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,26 +16,38 @@ const fakeSession = {
|
|||||||
verify: () => ({ userId: 'agent_1', appId: 'crm-web', orgId: 'org', tenantId: 'tnt', displayName: 'A' }),
|
verify: () => ({ userId: 'agent_1', appId: 'crm-web', orgId: 'org', tenantId: 'tnt', displayName: 'A' }),
|
||||||
} as unknown as SessionVerifier;
|
} as unknown as SessionVerifier;
|
||||||
|
|
||||||
function makeGuard(): ContextAttestationGuard {
|
const registryFor = (): ClientRegistryPort => ({
|
||||||
const seen = new Set<string>();
|
|
||||||
const registry: ClientRegistryPort = {
|
|
||||||
find: async (id) =>
|
find: async (id) =>
|
||||||
id === 'crm-support-widget'
|
id === 'appshell-crm'
|
||||||
? { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' }
|
? { clientId: 'appshell-crm', clientType: 'APPSHELL', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' }
|
||||||
: null,
|
: null,
|
||||||
};
|
});
|
||||||
const nonces: NonceStorePort = { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) };
|
const freshNonces = (): NonceStorePort => {
|
||||||
const verifier = new ContextAttestationVerifier(registry, nonces, { audience: 'iios-core' });
|
const seen = new Set<string>();
|
||||||
|
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);
|
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<string, string>): ExecutionContext {
|
function ctxWith(headers: Record<string, string>): ExecutionContext {
|
||||||
return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext;
|
return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
function att(over: Record<string, unknown> = {}, secret = SECRET): string {
|
function att(over: Record<string, unknown> = {}, secret = SECRET): string {
|
||||||
return signDevAttestation(
|
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,
|
secret,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,4 +80,17 @@ describe('ContextAttestationGuard (three-proof gate on the request path)', () =>
|
|||||||
expect(await guard.canActivate(ctxWith(headers))).toBe(true);
|
expect(await guard.canActivate(ctxWith(headers))).toBe(true);
|
||||||
await expect(guard.canActivate(ctxWith({ ...headers }))).rejects.toBeInstanceOf(ForbiddenException);
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { generateKeyPairSync } from 'node:crypto';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
ContextAttestationVerifier,
|
ContextAttestationVerifier,
|
||||||
signDevAttestation,
|
signDevAttestation,
|
||||||
|
signDevAttestationES256,
|
||||||
type ClientRegistryEntry,
|
type ClientRegistryEntry,
|
||||||
type ClientRegistryPort,
|
type ClientRegistryPort,
|
||||||
type NonceStorePort,
|
type NonceStorePort,
|
||||||
|
type PublicKeyResolverPort,
|
||||||
} from './context-attestation';
|
} from './context-attestation';
|
||||||
import { PrismaNonceStore } from './attestation-stores';
|
import { PrismaNonceStore } from './attestation-stores';
|
||||||
import type { PrismaService } from '../prisma/prisma.service';
|
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) ─────────
|
// ─── in-memory fakes (fast, deterministic — no DB needed) ─────────
|
||||||
function fakeRegistry(over: Partial<ClientRegistryEntry> = {}): ClientRegistryPort {
|
function fakeRegistry(over: Partial<ClientRegistryEntry> = {}): ClientRegistryPort {
|
||||||
const entry: ClientRegistryEntry = {
|
const entry: ClientRegistryEntry = {
|
||||||
clientId: 'crm-support-widget',
|
clientId: 'appshell-crm',
|
||||||
clientType: 'SUPPORT_BFF',
|
clientType: 'SUPPORT_BFF',
|
||||||
allowedAppIds: ['crm-web'],
|
allowedAppIds: ['crm-web'],
|
||||||
attestSecret: APPSHELL_SECRET,
|
attestSecret: APPSHELL_SECRET,
|
||||||
@@ -38,7 +41,7 @@ const verifier = (reg: ClientRegistryPort = fakeRegistry(), nonces: NonceStorePo
|
|||||||
/** A well-formed AppShell attestation, overridable per test. */
|
/** A well-formed AppShell attestation, overridable per test. */
|
||||||
function attest(over: Record<string, unknown> = {}, secret = APPSHELL_SECRET, at = NOW): string {
|
function attest(over: Record<string, unknown> = {}, secret = APPSHELL_SECRET, at = NOW): string {
|
||||||
return signDevAttestation(
|
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,
|
secret,
|
||||||
at,
|
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 () => {
|
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);
|
const res = await verifier().verify(attest({ nonce: 'n_ok' }), 'crm-web', NOW);
|
||||||
expect(res.ok).toBe(true);
|
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 ──
|
// ── 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 ───────────────────────
|
// ─── DB-level atomicity of the nonce ledger ───────────────────────
|
||||||
describe('PrismaNonceStore (atomic replay defense)', () => {
|
describe('PrismaNonceStore (atomic replay defense)', () => {
|
||||||
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
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
|
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 nonce = `n_db_${NOW.getTime()}_${Math.floor(Math.random() * 1e9)}`;
|
||||||
const expiresAt = new Date(NOW.getTime() + 300_000);
|
const expiresAt = new Date(NOW.getTime() + 300_000);
|
||||||
const first = 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: 'crm-support-widget', expiresAt });
|
const second = await store.reserve({ nonce, clientId: 'appshell-crm', expiresAt });
|
||||||
expect(first).toBe(true);
|
expect(first).toBe(true);
|
||||||
expect(second).toBe(false);
|
expect(second).toBe(false);
|
||||||
await prisma.iiosAttestationNonce.delete({ where: { nonce } }).catch(() => undefined);
|
await prisma.iiosAttestationNonce.delete({ where: { nonce } }).catch(() => undefined);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export interface ClientRegistryEntry {
|
|||||||
clientType: string;
|
clientType: string;
|
||||||
allowedAppIds: string[];
|
allowedAppIds: string[];
|
||||||
attestSecret?: string; // dev: shared HS256 signing key (AppShell's key stand-in)
|
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';
|
status: 'ACTIVE' | 'DISABLED';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +45,16 @@ export interface NonceStorePort {
|
|||||||
reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean>;
|
reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
export type AttestationReason =
|
export type AttestationReason =
|
||||||
| 'MALFORMED'
|
| 'MALFORMED'
|
||||||
| 'UNKNOWN_CLIENT'
|
| 'UNKNOWN_CLIENT'
|
||||||
@@ -72,6 +82,8 @@ export class ContextAttestationVerifier {
|
|||||||
private readonly registry: ClientRegistryPort,
|
private readonly registry: ClientRegistryPort,
|
||||||
private readonly nonces: NonceStorePort,
|
private readonly nonces: NonceStorePort,
|
||||||
private readonly config: AttestationConfig,
|
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);
|
const client = await this.registry.find(claimed.clientId);
|
||||||
if (!client) return deny('UNKNOWN_CLIENT', `client "${claimed.clientId}" is not registered`);
|
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.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).
|
// 3. Verify the signature with the client's key. A client verifies EITHER asymmetrically via
|
||||||
// ignoreExpiration so we can return a precise EXPIRED reason (step 6) rather than BAD_SIGNATURE.
|
// 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;
|
let att: ContextAttestation & jwt.JwtPayload;
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
return deny('BAD_SIGNATURE', (e as Error).message);
|
return deny('BAD_SIGNATURE', (e as Error).message);
|
||||||
}
|
}
|
||||||
@@ -119,6 +134,29 @@ export class ContextAttestationVerifier {
|
|||||||
|
|
||||||
return { ok: true, attestation: att };
|
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);
|
const iat = Math.floor(now.getTime() / 1000);
|
||||||
return jwt.sign({ ...rest, iat, exp: iat + ttlSeconds }, secret, { algorithm: 'HS256' });
|
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<ContextAttestation, 'iat' | 'exp'> & { 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 });
|
||||||
|
}
|
||||||
|
|||||||
Generated
+41
@@ -370,6 +370,9 @@ importers:
|
|||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.3
|
specifier: ^9.0.3
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
|
jwks-rsa:
|
||||||
|
specifier: ^4.1.0
|
||||||
|
version: 4.1.0
|
||||||
prisma:
|
prisma:
|
||||||
specifier: ^6.2.1
|
specifier: ^6.2.1
|
||||||
version: 6.19.3(typescript@5.9.3)
|
version: 6.19.3(typescript@5.9.3)
|
||||||
@@ -2297,6 +2300,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@6.2.3:
|
||||||
|
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||||
|
|
||||||
joycon@3.1.1:
|
joycon@3.1.1:
|
||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2343,6 +2349,10 @@ packages:
|
|||||||
jwa@2.0.1:
|
jwa@2.0.1:
|
||||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
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:
|
jws@4.0.1:
|
||||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
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==}
|
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
limiter@1.1.5:
|
||||||
|
resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
|
||||||
|
|
||||||
lines-and-columns@1.2.4:
|
lines-and-columns@1.2.4:
|
||||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||||
|
|
||||||
@@ -2368,6 +2381,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==}
|
resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==}
|
||||||
engines: {node: '>=6.11.5'}
|
engines: {node: '>=6.11.5'}
|
||||||
|
|
||||||
|
lodash.clonedeep@4.5.0:
|
||||||
|
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
||||||
|
|
||||||
lodash.includes@4.3.0:
|
lodash.includes@4.3.0:
|
||||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
@@ -2406,6 +2422,9 @@ packages:
|
|||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||||
|
|
||||||
|
lru-memoizer@3.0.0:
|
||||||
|
resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||||
|
|
||||||
@@ -5066,6 +5085,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
|
jose@6.2.3: {}
|
||||||
|
|
||||||
joycon@3.1.1: {}
|
joycon@3.1.1: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
@@ -5113,6 +5134,17 @@ snapshots:
|
|||||||
ecdsa-sig-formatter: 1.0.11
|
ecdsa-sig-formatter: 1.0.11
|
||||||
safe-buffer: 5.2.1
|
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:
|
jws@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
jwa: 2.0.1
|
jwa: 2.0.1
|
||||||
@@ -5122,6 +5154,8 @@ snapshots:
|
|||||||
|
|
||||||
lilconfig@3.1.3: {}
|
lilconfig@3.1.3: {}
|
||||||
|
|
||||||
|
limiter@1.1.5: {}
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
|
|
||||||
load-esm@1.0.3: {}
|
load-esm@1.0.3: {}
|
||||||
@@ -5130,6 +5164,8 @@ snapshots:
|
|||||||
|
|
||||||
loader-runner@4.3.2: {}
|
loader-runner@4.3.2: {}
|
||||||
|
|
||||||
|
lodash.clonedeep@4.5.0: {}
|
||||||
|
|
||||||
lodash.includes@4.3.0: {}
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3: {}
|
lodash.isboolean@3.0.3: {}
|
||||||
@@ -5159,6 +5195,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist: 3.1.1
|
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:
|
magic-string@0.30.17:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|||||||
Reference in New Issue
Block a user