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:
2026-07-13 20:11:04 +05:30
parent 77dd5bac82
commit dd6a4cd4fa
5 changed files with 356 additions and 0 deletions
@@ -0,0 +1,24 @@
-- Trust plane (July 12): context attestation client registry + nonce replay ledger.
CREATE TABLE "IiosClientRegistry" (
"clientId" TEXT NOT NULL,
"clientType" TEXT NOT NULL,
"ownerService" TEXT,
"allowedAppIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"attestSecret" TEXT,
"jwksUri" TEXT,
"spiffeId" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IiosClientRegistry_pkey" PRIMARY KEY ("clientId")
);
CREATE TABLE "IiosAttestationNonce" (
"nonce" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IiosAttestationNonce_pkey" PRIMARY KEY ("nonce")
);
CREATE INDEX "IiosAttestationNonce_expiresAt_idx" ON "IiosAttestationNonce"("expiresAt");
@@ -1321,3 +1321,31 @@ model IiosDlqItem {
@@unique([consumerName, sourceId])
@@index([scopeId, status])
}
// ─── Trust plane (July 12) — context attestation ──────────────────
// Registered clients/BFFs/adapters allowed to call IIOS and attest context on
// behalf of an app. A valid actor token is NOT enough; the caller must present a
// context attestation signed by one of these registered clients.
model IiosClientRegistry {
clientId String @id
clientType String // APPSHELL | SUPPORT_BFF | ADAPTER | CALENDAR | SERVICE
ownerService String?
allowedAppIds String[] // which app_ids this client may attest for
attestSecret String? // dev: shared HS256 signing key (AppShell's key stand-in)
jwksUri String? // prod: verify the attestation signature via JWKS
spiffeId String? // prod: workload identity for mTLS proof
status String @default("ACTIVE") // ACTIVE | DISABLED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Single-use nonce ledger: once a context attestation's nonce is seen it can
// never be replayed. Reserve-if-absent (unique PK) makes the check atomic.
model IiosAttestationNonce {
nonce String @id
clientId String
expiresAt DateTime
firstSeenAt DateTime @default(now())
@@index([expiresAt])
}
@@ -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' });
}