diff --git a/packages/iios-service/prisma/migrations/20260713200417_add_context_attestation/migration.sql b/packages/iios-service/prisma/migrations/20260713200417_add_context_attestation/migration.sql new file mode 100644 index 0000000..bb861db --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260713200417_add_context_attestation/migration.sql @@ -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"); diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index 154896f..9b4aa91 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -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]) +} diff --git a/packages/iios-service/src/platform/attestation-stores.ts b/packages/iios-service/src/platform/attestation-stores.ts new file mode 100644 index 0000000..8978e7e --- /dev/null +++ b/packages/iios-service/src/platform/attestation-stores.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/packages/iios-service/src/platform/context-attestation.spec.ts b/packages/iios-service/src/platform/context-attestation.spec.ts new file mode 100644 index 0000000..c17e96d --- /dev/null +++ b/packages/iios-service/src/platform/context-attestation.spec.ts @@ -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 = {}): 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(); + 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 = {}, 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); + }); +}); diff --git a/packages/iios-service/src/platform/context-attestation.ts b/packages/iios-service/src/platform/context-attestation.ts new file mode 100644 index 0000000..66d7046 --- /dev/null +++ b/packages/iios-service/src/platform/context-attestation.ts @@ -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; +} + +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; +} + +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 { + // 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 & { 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' }); +}