feat(iios): context-attestation verifier — the July 12 stolen-token proof (§1)
A valid actor token is no longer enough. This adds the trust-layer core so IIOS can require a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.) before a privileged request — defeating a hacked app replaying a stolen token. - prisma: IiosClientRegistry (registered clients allowed to attest, per app) + IiosAttestationNonce (single-use replay ledger). Migration applied. - ContextAttestationVerifier + ports (ClientRegistryPort, NonceStorePort) + a dev signer. Verifies: registered+active client, signature, aud=iios, app_id matches the token AND is allowed for the client, freshness, single-use nonce. Fail-closed. - Prisma-backed stores; nonce reserve is atomic via the unique PK (P2002 = replay). - Tests (9 pass): happy path + the five rejection cases the CEO named (unknown client, wrong audience, expired, replayed nonce, app-mismatch) + forged-signature + disabled-client; a DB atomicity test (skips if engine offline). Decoupled from the request path on purpose — wiring it into the messaging guard (the three-proof gate) + demoting be-crm to an attestation forwarder is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { ClientRegistryEntry, ClientRegistryPort, NonceStorePort } 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import {
|
||||
ContextAttestationVerifier,
|
||||
signDevAttestation,
|
||||
type ClientRegistryEntry,
|
||||
type ClientRegistryPort,
|
||||
type NonceStorePort,
|
||||
} from './context-attestation';
|
||||
import { PrismaNonceStore } from './attestation-stores';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const APPSHELL_SECRET = 'appshell-dev-signing-key';
|
||||
const AUD = 'iios-core';
|
||||
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',
|
||||
clientType: 'SUPPORT_BFF',
|
||||
allowedAppIds: ['crm-web'],
|
||||
attestSecret: APPSHELL_SECRET,
|
||||
status: 'ACTIVE',
|
||||
...over,
|
||||
};
|
||||
return { find: async (id) => (id === entry.clientId ? entry : null) };
|
||||
}
|
||||
|
||||
function fakeNonces(): NonceStorePort {
|
||||
const seen = new Set<string>();
|
||||
return { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) };
|
||||
}
|
||||
|
||||
const verifier = (reg: ClientRegistryPort = fakeRegistry(), nonces: NonceStorePort = fakeNonces()) =>
|
||||
new ContextAttestationVerifier(reg, nonces, { audience: AUD });
|
||||
|
||||
/** 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 },
|
||||
secret,
|
||||
at,
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
// ── the five rejection cases the CEO named ──
|
||||
it('rejects an unknown / unregistered client (wrong client)', async () => {
|
||||
const res = await verifier().verify(attest({ clientId: 'hacker-app', nonce: 'n1' }), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'UNKNOWN_CLIENT' });
|
||||
});
|
||||
|
||||
it('rejects the wrong audience (token minted for another service)', async () => {
|
||||
const res = await verifier().verify(attest({ audience: 'some-other-service', nonce: 'n2' }), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'WRONG_AUDIENCE' });
|
||||
});
|
||||
|
||||
it('rejects an expired attestation', async () => {
|
||||
// signed 10 min ago with a 5 min TTL → already expired at NOW
|
||||
const stale = attest({ nonce: 'n3', ttlSeconds: 300 }, APPSHELL_SECRET, new Date(NOW.getTime() - 600_000));
|
||||
const res = await verifier().verify(stale, 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'EXPIRED' });
|
||||
});
|
||||
|
||||
it('rejects a replayed nonce (same attestation used twice)', async () => {
|
||||
const v = verifier();
|
||||
const token = attest({ nonce: 'n_replay' });
|
||||
const first = await v.verify(token, 'crm-web', NOW);
|
||||
const second = await v.verify(token, 'crm-web', NOW);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(second).toMatchObject({ ok: false, reason: 'REPLAY' });
|
||||
});
|
||||
|
||||
it('rejects app mismatch: token app != attestation app', async () => {
|
||||
const res = await verifier().verify(attest({ appId: 'crm-web', nonce: 'n4' }), 'some-other-app', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
|
||||
});
|
||||
|
||||
// ── extra hardening ──
|
||||
it('rejects a forged signature (signed with the wrong key)', async () => {
|
||||
const res = await verifier().verify(attest({ nonce: 'n5' }, 'attacker-key'), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'BAD_SIGNATURE' });
|
||||
});
|
||||
|
||||
it('rejects a disabled client', async () => {
|
||||
const res = await verifier(fakeRegistry({ status: 'DISABLED' })).verify(attest({ nonce: 'n6' }), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'CLIENT_DISABLED' });
|
||||
});
|
||||
|
||||
it('rejects an app the client is not allowed to attest for', async () => {
|
||||
const reg = fakeRegistry({ allowedAppIds: ['other-app'] });
|
||||
const res = await verifier(reg).verify(attest({ appId: 'crm-web', nonce: 'n7' }), 'crm-web', NOW);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'APP_MISMATCH' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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';
|
||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||
const store = new PrismaNonceStore(prisma as unknown as PrismaService);
|
||||
let dbUp = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
try { await prisma.$connect(); dbUp = true; } catch { dbUp = false; }
|
||||
});
|
||||
afterAll(async () => { if (dbUp) await prisma.$disconnect(); });
|
||||
|
||||
it('reserves a nonce once; a second reserve of the same nonce is rejected', async (ctx) => {
|
||||
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 });
|
||||
expect(first).toBe(true);
|
||||
expect(second).toBe(false);
|
||||
await prisma.iiosAttestationNonce.delete({ where: { nonce } }).catch(() => undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* The July 12 "third proof". A cryptographically valid actor token is NOT enough — a hacked
|
||||
* app can replay a stolen one. Before IIOS processes a privileged request it must also verify
|
||||
* a CONTEXT ATTESTATION: a short-lived, single-use, signed statement from an authorized parent
|
||||
* (AppShell for CRM, the adapter registry for channels, Calendar for calendar) that says
|
||||
* "this request, this client, this app, this context is genuinely mine."
|
||||
*
|
||||
* This module is the verifier + its two ports (client registry, nonce ledger) + a dev signer.
|
||||
* It is intentionally decoupled from the request path so it can be unit-tested in isolation and
|
||||
* wired into the messaging guard as a separate, gated step.
|
||||
*/
|
||||
|
||||
/** The subset of AppShell's context-attestation envelope that IIOS verifies. */
|
||||
export interface ContextAttestation {
|
||||
attestationId?: string;
|
||||
issuer: string;
|
||||
issuerType?: string; // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
|
||||
audience: string; // must equal the configured IIOS audience
|
||||
clientId: string; // must be a registered, ACTIVE client
|
||||
appId: string; // must match the actor token's app_id AND be allowed for this client
|
||||
scope?: { orgId?: string; appId?: string; tenantId?: string; buId?: string };
|
||||
nonce: string; // single-use
|
||||
iat?: number;
|
||||
exp?: number; // short-lived
|
||||
}
|
||||
|
||||
/** A registered caller allowed to attest context on behalf of one or more apps. */
|
||||
export interface ClientRegistryEntry {
|
||||
clientId: string;
|
||||
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)
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
}
|
||||
|
||||
export interface ClientRegistryPort {
|
||||
find(clientId: string): Promise<ClientRegistryEntry | null>;
|
||||
}
|
||||
|
||||
export interface NonceStorePort {
|
||||
/** Atomically reserve a nonce. Returns true if fresh, false if it was already seen (replay). */
|
||||
reserve(input: { nonce: string; clientId: string; expiresAt: Date }): Promise<boolean>;
|
||||
}
|
||||
|
||||
export type AttestationReason =
|
||||
| 'MALFORMED'
|
||||
| 'UNKNOWN_CLIENT'
|
||||
| 'CLIENT_DISABLED'
|
||||
| 'NO_KEY'
|
||||
| 'BAD_SIGNATURE'
|
||||
| 'WRONG_AUDIENCE'
|
||||
| 'APP_MISMATCH'
|
||||
| 'EXPIRED'
|
||||
| 'REPLAY';
|
||||
|
||||
export type AttestationResult =
|
||||
| { ok: true; attestation: ContextAttestation }
|
||||
| { ok: false; reason: AttestationReason; detail: string };
|
||||
|
||||
export interface AttestationConfig {
|
||||
/** The audience IIOS requires on attestations addressed to it (e.g. 'iios-core'). */
|
||||
audience: string;
|
||||
}
|
||||
|
||||
const deny = (reason: AttestationReason, detail: string): AttestationResult => ({ ok: false, reason, detail });
|
||||
|
||||
export class ContextAttestationVerifier {
|
||||
constructor(
|
||||
private readonly registry: ClientRegistryPort,
|
||||
private readonly nonces: NonceStorePort,
|
||||
private readonly config: AttestationConfig,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Verify an attestation against an already-verified actor token. Fail-closed: any doubt → deny.
|
||||
* @param attestationJwt the signed attestation the parent issued
|
||||
* @param tokenAppId the `app_id` from the verified actor token (Level-1 proof)
|
||||
* @param now current time — injected so tests are deterministic
|
||||
*/
|
||||
async verify(attestationJwt: string, tokenAppId: string, now: Date = new Date()): Promise<AttestationResult> {
|
||||
// 1. Decode WITHOUT verifying, only to learn who claims to have signed it.
|
||||
const claimed = jwt.decode(attestationJwt, { json: true }) as (ContextAttestation & jwt.JwtPayload) | null;
|
||||
if (!claimed || typeof claimed !== 'object' || !claimed.clientId || !claimed.nonce) {
|
||||
return deny('MALFORMED', 'attestation missing clientId/nonce');
|
||||
}
|
||||
|
||||
// 2. The claimed client must be registered and ACTIVE.
|
||||
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.
|
||||
let att: ContextAttestation & jwt.JwtPayload;
|
||||
try {
|
||||
att = jwt.verify(attestationJwt, client.attestSecret, { algorithms: ['HS256'], ignoreExpiration: true }) as ContextAttestation & jwt.JwtPayload;
|
||||
} catch (e) {
|
||||
return deny('BAD_SIGNATURE', (e as Error).message);
|
||||
}
|
||||
|
||||
// 4. Audience must be IIOS — a token for another service can't be replayed at IIOS.
|
||||
if (att.audience !== this.config.audience) return deny('WRONG_AUDIENCE', `audience "${att.audience}" != "${this.config.audience}"`);
|
||||
|
||||
// 5. app binding: the attestation's app must match the token's app AND be allowed for this client.
|
||||
if (att.appId !== tokenAppId) return deny('APP_MISMATCH', `attestation app "${att.appId}" != token app "${tokenAppId}"`);
|
||||
if (!client.allowedAppIds.includes(att.appId)) return deny('APP_MISMATCH', `client "${client.clientId}" not allowed for app "${att.appId}"`);
|
||||
|
||||
// 6. Freshness — attestations are short-lived.
|
||||
const expMs = (att.exp ?? 0) * 1000;
|
||||
if (!expMs || expMs <= now.getTime()) return deny('EXPIRED', 'attestation expired or missing exp');
|
||||
|
||||
// 7. Single-use — reserve the nonce. A replayed (stolen) attestation loses here. Fail-closed.
|
||||
const fresh = await this.nonces.reserve({ nonce: att.nonce, clientId: client.clientId, expiresAt: new Date(expMs) });
|
||||
if (!fresh) return deny('REPLAY', `nonce "${att.nonce}" already used`);
|
||||
|
||||
return { ok: true, attestation: att };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV/TEST helper: mint a signed attestation the way AppShell would (HS256 stand-in for its
|
||||
* signing key). Production AppShell signs asymmetrically and IIOS verifies via the client's JWKS.
|
||||
*/
|
||||
export function signDevAttestation(
|
||||
claims: Omit<ContextAttestation, 'iat' | 'exp'> & { ttlSeconds?: number },
|
||||
secret: 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 }, secret, { algorithm: 'HS256' });
|
||||
}
|
||||
Reference in New Issue
Block a user