diff --git a/packages/iios-service/.env.example b/packages/iios-service/.env.example index 791514e..527e6b3 100644 --- a/packages/iios-service/.env.example +++ b/packages/iios-service/.env.example @@ -49,3 +49,13 @@ IIOS_AI_BUDGET_UNITS=100000 # per-scope AI cost-unit budget (KG-12) # ── Capability providers (governed egress targets) ─────────────────────────── # Per-channel provider endpoint the CapabilityBroker calls, e.g.: # IIOS_PROVIDER_URL_EMAIL=https://provider.internal/email + +# ── Context attestation (July 12 trust layer) ── +# Audience IIOS requires on attestations addressed to it. +IIOS_ATTESTATION_AUDIENCE=iios-core +# 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). +# IIOS_ATTESTATION_DEV_SECRET=appshell-dev-signing-key +# 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. +IIOS_REQUIRE_ATTESTATION=0 diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index e12dc3d..29129e2 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from './prisma/prisma.module'; import { PlatformModule } from './platform/platform.module'; +import { AttestationModule } from './platform/attestation.module'; import { IdentityModule } from './identity/identity.module'; import { InteractionsModule } from './interactions/interactions.module'; import { IdempotencyModule } from './idempotency/idempotency.module'; @@ -27,6 +28,7 @@ import { DevController } from './dev/dev.controller'; imports: [ PrismaModule, PlatformModule, + AttestationModule, IdentityModule, InteractionsModule, IdempotencyModule, diff --git a/packages/iios-service/src/platform/attestation.module.ts b/packages/iios-service/src/platform/attestation.module.ts new file mode 100644 index 0000000..979045e --- /dev/null +++ b/packages/iios-service/src/platform/attestation.module.ts @@ -0,0 +1,44 @@ +import { Global, Module, type OnModuleInit } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { ContextAttestationVerifier } from './context-attestation'; +import { PrismaClientRegistry, PrismaNonceStore } from './attestation-stores'; +import { ContextAttestationGuard } from './context-attestation.guard'; + +/** + * Wires the context-attestation trust layer into DI and exposes the guard globally so any + * controller can enforce the three-proof gate. Also dev-seeds a registered client so locally + * minted attestations verify (prod registers clients out-of-band). + */ +@Global() +@Module({ + providers: [ + PrismaClientRegistry, + PrismaNonceStore, + { + provide: ContextAttestationVerifier, + useFactory: (reg: PrismaClientRegistry, nonces: PrismaNonceStore) => + new ContextAttestationVerifier(reg, nonces, { audience: process.env.IIOS_ATTESTATION_AUDIENCE ?? 'iios-core' }), + inject: [PrismaClientRegistry, PrismaNonceStore], + }, + ContextAttestationGuard, + ], + exports: [ContextAttestationVerifier, ContextAttestationGuard], +}) +export class AttestationModule implements OnModuleInit { + constructor(private readonly prisma: PrismaService) {} + + /** Dev seed: register the CRM support client so an attestation signed with the shared dev + * secret verifies locally. Gated by IIOS_DEV_TOKENS + IIOS_ATTESTATION_DEV_SECRET; best-effort. */ + async onModuleInit(): Promise { + if (process.env.IIOS_DEV_TOKENS !== '1') return; + const secret = process.env.IIOS_ATTESTATION_DEV_SECRET; + if (!secret) return; + await this.prisma.iiosClientRegistry + .upsert({ + where: { clientId: 'crm-support-widget' }, + create: { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: secret, status: 'ACTIVE' }, + update: { attestSecret: secret, allowedAppIds: ['crm-web'], status: 'ACTIVE' }, + }) + .catch(() => undefined); + } +} diff --git a/packages/iios-service/src/platform/context-attestation.guard.spec.ts b/packages/iios-service/src/platform/context-attestation.guard.spec.ts new file mode 100644 index 0000000..711264e --- /dev/null +++ b/packages/iios-service/src/platform/context-attestation.guard.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { ForbiddenException, type ExecutionContext } from '@nestjs/common'; +import { ContextAttestationGuard } from './context-attestation.guard'; +import { + ContextAttestationVerifier, + signDevAttestation, + type ClientRegistryPort, + type NonceStorePort, +} from './context-attestation'; +import type { SessionVerifier } from './session.verifier'; + +const SECRET = 'appshell-dev-signing-key'; + +// SessionVerifier stand-in: the token always resolves to app "crm-web". +const fakeSession = { + verify: () => ({ userId: 'agent_1', appId: 'crm-web', orgId: 'org', tenantId: 'tnt', displayName: 'A' }), +} as unknown as SessionVerifier; + +function makeGuard(): ContextAttestationGuard { + const seen = new Set(); + const registry: ClientRegistryPort = { + find: async (id) => + id === 'crm-support-widget' + ? { clientId: 'crm-support-widget', clientType: 'SUPPORT_BFF', allowedAppIds: ['crm-web'], attestSecret: SECRET, status: 'ACTIVE' } + : null, + }; + const nonces: NonceStorePort = { reserve: async ({ nonce }) => (seen.has(nonce) ? false : (seen.add(nonce), true)) }; + const verifier = new ContextAttestationVerifier(registry, nonces, { audience: 'iios-core' }); + return new ContextAttestationGuard(fakeSession, verifier); +} + +function ctxWith(headers: Record): ExecutionContext { + return { switchToHttp: () => ({ getRequest: () => ({ headers }) }) } as unknown as ExecutionContext; +} + +function att(over: Record = {}, secret = SECRET): string { + return signDevAttestation( + { issuer: 'appshell.crm', audience: 'iios-core', clientId: 'crm-support-widget', appId: 'crm-web', nonce: `n_${Math.random()}`, ...over }, + secret, + ); +} + +describe('ContextAttestationGuard (three-proof gate on the request path)', () => { + afterEach(() => { delete process.env.IIOS_REQUIRE_ATTESTATION; }); + + it('allows a request with no attestation when NOT required (dev / zero-trust)', async () => { + expect(await makeGuard().canActivate(ctxWith({}))).toBe(true); + }); + + it('rejects a request with no attestation when IIOS_REQUIRE_ATTESTATION=1', async () => { + process.env.IIOS_REQUIRE_ATTESTATION = '1'; + await expect(makeGuard().canActivate(ctxWith({}))).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('allows a valid attestation bound to the token app', async () => { + const headers = { authorization: 'Bearer tok', 'x-context-attestation': att() }; + expect(await makeGuard().canActivate(ctxWith(headers))).toBe(true); + }); + + it('rejects a forged attestation (wrong signing key) — stolen-context defense', async () => { + const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_forge' }, 'attacker-key') }; + await expect(makeGuard().canActivate(ctxWith(headers))).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects a replayed attestation (same nonce twice)', async () => { + const guard = makeGuard(); + const headers = { authorization: 'Bearer tok', 'x-context-attestation': att({ nonce: 'n_replay' }) }; + expect(await guard.canActivate(ctxWith(headers))).toBe(true); + await expect(guard.canActivate(ctxWith({ ...headers }))).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/packages/iios-service/src/platform/context-attestation.guard.ts b/packages/iios-service/src/platform/context-attestation.guard.ts new file mode 100644 index 0000000..e5a240c --- /dev/null +++ b/packages/iios-service/src/platform/context-attestation.guard.ts @@ -0,0 +1,52 @@ +import { type CanActivate, type ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import type { Request } from 'express'; +import { SessionVerifier } from './session.verifier'; +import { ContextAttestationVerifier } from './context-attestation'; + +/** + * The July 12 three-proof gate on the request path. Proof #1 (a valid actor token) is done by + * the controllers; proof #2 (workload/mTLS) is the mesh's job in prod. This guard adds proof #3: + * a signed CONTEXT ATTESTATION from an authorized parent (AppShell etc.). + * + * Behaviour (safe rollout): + * • attestation header present → verify it; the attestation's app_id must match the actor + * token's app_id. Any failure → 403. + * • attestation header absent → allowed ONLY when not required (dev / a zero-trust standalone + * caller that IIOS checks fully itself). With `IIOS_REQUIRE_ATTESTATION=1`, absence is a 403. + * + * The requirement is read per-request so it can be toggled without restarts and stays OFF by + * default — existing callers that don't send an attestation keep working until the rollout flips. + */ +@Injectable() +export class ContextAttestationGuard implements CanActivate { + constructor( + private readonly session: SessionVerifier, + private readonly attest: ContextAttestationVerifier, + ) {} + + async canActivate(ctx: ExecutionContext): Promise { + const required = process.env.IIOS_REQUIRE_ATTESTATION === '1'; + const req = ctx.switchToHttp().getRequest(); + const raw = req.headers['x-context-attestation']; + const attestation = Array.isArray(raw) ? raw[0] : raw; + + if (!attestation) { + if (required) throw new ForbiddenException('context attestation required'); + return true; // dev / zero-trust: the actor token is still enforced downstream + } + + // Bind the attestation to the token's app_id, so a stolen attestation for another app fails. + const auth = req.headers['authorization'] ?? ''; + const token = String(auth).replace(/^Bearer\s+/i, ''); + let appId: string; + try { + appId = this.session.verify(token).appId ?? ''; + } catch { + throw new ForbiddenException('invalid actor token'); + } + + const result = await this.attest.verify(attestation, appId); + if (!result.ok) throw new ForbiddenException(`context attestation rejected: ${result.reason}`); + return true; + } +} diff --git a/packages/iios-service/src/support/support.controller.ts b/packages/iios-service/src/support/support.controller.ts index 14a6164..cb4a149 100644 --- a/packages/iios-service/src/support/support.controller.ts +++ b/packages/iios-service/src/support/support.controller.ts @@ -1,8 +1,9 @@ -import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Headers, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { IiosTicketState } from '@prisma/client'; import { SupportService } from './support.service'; import { AssignmentService } from './assignment.service'; import { SessionVerifier } from '../platform/session.verifier'; +import { ContextAttestationGuard } from '../platform/context-attestation.guard'; import type { MessagePrincipal } from '../identity/actor.resolver'; import { AssignTicketDto, @@ -15,6 +16,7 @@ import { } from './support.dto'; @Controller('v1/support') +@UseGuards(ContextAttestationGuard) export class SupportController { constructor( private readonly support: SupportService, diff --git a/packages/iios-service/src/threads/threads.controller.ts b/packages/iios-service/src/threads/threads.controller.ts index 497d341..83d28f4 100644 --- a/packages/iios-service/src/threads/threads.controller.ts +++ b/packages/iios-service/src/threads/threads.controller.ts @@ -9,13 +9,16 @@ import { Param, Post, Query, + UseGuards, } from '@nestjs/common'; import { ThreadsService } from './threads.service'; import { MessageService } from '../messaging/message.service'; import { SessionVerifier } from '../platform/session.verifier'; +import { ContextAttestationGuard } from '../platform/context-attestation.guard'; import { SendMessageDto } from './send-message.dto'; @Controller('v1/threads') +@UseGuards(ContextAttestationGuard) export class ThreadsController { constructor( private readonly threads: ThreadsService,