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,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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user