diff --git a/apps/integration-service/src/integration-controller-authority.test.ts b/apps/integration-service/src/integration-controller-authority.test.ts index a2e68392..0ef41a45 100644 --- a/apps/integration-service/src/integration-controller-authority.test.ts +++ b/apps/integration-service/src/integration-controller-authority.test.ts @@ -1,16 +1,278 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { createHmac, randomBytes } from 'node:crypto'; +import { HttpException } from '@nestjs/common'; import { describe, expect, it } from 'vitest'; +import { + requireTrustedEventWorkspaceContext, + type IntegrationTrustedRequestBinding, +} from './main'; -const controllerSource = readFileSync(resolve(__dirname, 'main.ts'), 'utf8'); +const WORKSPACE_ID = '3b237d04-e84c-4ac4-933d-7f179865e1a0'; +const OTHER_WORKSPACE_ID = '474c83ae-08af-4a63-957b-49eb2093a61d'; +const GATEWAY_SECRET = randomBytes(32).toString('base64url'); +const NOW_SECONDS = 1_786_334_400; +const EVENT_BINDING = { + method: 'POST', + path: '/v1/events/prepare', +} as const; +const BASE64URL_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +function signContext( + workspaceId: string, + issuedAt: string, + binding: Readonly<{ method: string; path: string }> = EVENT_BINDING, +): string { + return createHmac('sha256', GATEWAY_SECRET) + .update( + `life-os.integration-event-context.v2\n${workspaceId.toLowerCase()}\n${issuedAt}\n${binding.method}\n${binding.path}`, + 'utf8', + ) + .digest('base64url'); +} + +function responseOf(error: HttpException): unknown { + return error.getResponse(); +} + +function expectContextProblem( + operation: () => unknown, + expected: { readonly status: number; readonly code: string }, +): void { + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + if (thrown === undefined) { + throw new Error('Expected trusted Integration context to be rejected'); + } + expect(thrown).toBeInstanceOf(HttpException); + expect(responseOf(thrown as HttpException)).toMatchObject(expected); +} describe('Integration event tenant authority contract', () => { - it('never accepts the legacy browser-selectable workspace header', () => { - expect(controllerSource).not.toContain("@Headers('x-workspace-id')"); - expect(controllerSource).toContain("@Headers('x-life-os-workspace-id')"); - expect(controllerSource).toContain("@Headers('x-life-os-context-issued-at')"); - expect(controllerSource).toContain("@Headers('x-life-os-context-signature')"); - expect(controllerSource).toContain('INTEGRATION_GATEWAY_CONTEXT_SECRET'); - expect(controllerSource).toContain('requireTrustedWorkspaceContext('); + it('accepts only a fresh signature bound to the exact event route', () => { + const issuedAt = String(NOW_SECONDS); + expect( + requireTrustedEventWorkspaceContext( + { + workspaceId: WORKSPACE_ID.toUpperCase(), + issuedAt, + signature: signContext(WORKSPACE_ID, issuedAt), + }, + GATEWAY_SECRET, + EVENT_BINDING, + NOW_SECONDS, + ), + ).toBe(WORKSPACE_ID); + }); + + it('rejects the legacy workspace-only signature contract', () => { + const issuedAt = String(NOW_SECONDS); + const legacySignature = createHmac('sha256', GATEWAY_SECRET) + .update(`life-os.workspace.v1\n${WORKSPACE_ID}\n${issuedAt}`, 'utf8') + .digest('base64url'); + + expectContextProblem( + () => + requireTrustedEventWorkspaceContext( + { workspaceId: WORKSPACE_ID, issuedAt, signature: legacySignature }, + GATEWAY_SECRET, + EVENT_BINDING, + NOW_SECONDS, + ), + { status: 401, code: 'invalid_gateway_context' }, + ); + }); + + it.each([ + { + name: 'different path', + binding: { method: 'POST', path: '/v1/plugins/install' }, + }, + { + name: 'different method', + binding: { method: 'GET', path: '/v1/events/prepare' }, + }, + { + name: 'query string', + binding: { method: 'POST', path: '/v1/events/prepare?dryRun=true' }, + }, + { + name: 'fragment', + binding: { method: 'POST', path: '/v1/events/prepare#replay' }, + }, + ])('rejects unsupported request binding: $name', ({ binding }) => { + const issuedAt = String(NOW_SECONDS); + expectContextProblem( + () => + requireTrustedEventWorkspaceContext( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: signContext(WORKSPACE_ID, issuedAt, binding), + }, + GATEWAY_SECRET, + binding satisfies IntegrationTrustedRequestBinding, + NOW_SECONDS, + ), + { status: 401, code: 'invalid_gateway_context' }, + ); + }); + + it('rejects a signature that was issued for another workspace', () => { + const issuedAt = String(NOW_SECONDS); + expectContextProblem( + () => + requireTrustedEventWorkspaceContext( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: signContext(OTHER_WORKSPACE_ID, issuedAt), + }, + GATEWAY_SECRET, + EVENT_BINDING, + NOW_SECONDS, + ), + { status: 401, code: 'invalid_gateway_context' }, + ); + }); + + it('rejects a non-canonical base64url alias for the same signature bytes', () => { + const issuedAt = String(NOW_SECONDS); + const canonical = signContext(WORKSPACE_ID, issuedAt); + const finalIndex = BASE64URL_ALPHABET.indexOf(canonical.at(-1) ?? ''); + expect(finalIndex).toBeGreaterThanOrEqual(0); + expect(finalIndex % 4).toBe(0); + const aliasCharacter = BASE64URL_ALPHABET[finalIndex + 1]; + expect(aliasCharacter).toBeDefined(); + const nonCanonical = `${canonical.slice(0, -1)}${aliasCharacter}`; + expect(Buffer.from(nonCanonical, 'base64url')).toEqual( + Buffer.from(canonical, 'base64url'), + ); + + expectContextProblem( + () => + requireTrustedEventWorkspaceContext( + { workspaceId: WORKSPACE_ID, issuedAt, signature: nonCanonical }, + GATEWAY_SECRET, + EVENT_BINDING, + NOW_SECONDS, + ), + { status: 401, code: 'invalid_gateway_context' }, + ); + }); + + it.each([ + { + name: 'non-string workspace header', + headers: { + workspaceId: 42, + issuedAt: String(NOW_SECONDS), + signature: signContext(WORKSPACE_ID, String(NOW_SECONDS)), + }, + }, + { + name: 'workspace header outside the UUIDv4 grammar', + headers: { + workspaceId: '3b237d04-e84c-1ac4-933d-7f179865e1a0', + issuedAt: String(NOW_SECONDS), + signature: signContext(WORKSPACE_ID, String(NOW_SECONDS)), + }, + }, + { + name: 'non-string issued-at header', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: NOW_SECONDS, + signature: signContext(WORKSPACE_ID, String(NOW_SECONDS)), + }, + }, + { + name: 'issued-at header outside the canonical integer grammar', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: `0${NOW_SECONDS}`, + signature: signContext(WORKSPACE_ID, String(NOW_SECONDS)), + }, + }, + { + name: 'non-string signature header', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: 42, + }, + }, + { + name: 'signature header outside the SHA-256 base64url grammar', + headers: { + workspaceId: WORKSPACE_ID, + issuedAt: String(NOW_SECONDS), + signature: 'A'.repeat(42), + }, + }, + ])('rejects malformed trusted headers: $name', ({ headers }) => { + expectContextProblem( + () => + requireTrustedEventWorkspaceContext( + headers, + GATEWAY_SECRET, + EVENT_BINDING, + NOW_SECONDS, + ), + { status: 401, code: 'invalid_gateway_context' }, + ); + }); + + it.each([ + { + name: 'stale timestamp', + issuedAt: String(NOW_SECONDS - 61), + nowSeconds: NOW_SECONDS, + secret: GATEWAY_SECRET, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'future timestamp', + issuedAt: String(NOW_SECONDS + 6), + nowSeconds: NOW_SECONDS, + secret: GATEWAY_SECRET, + status: 401, + code: 'invalid_gateway_context', + }, + { + name: 'short verifier secret', + issuedAt: String(NOW_SECONDS), + nowSeconds: NOW_SECONDS, + secret: 'too-short', + status: 503, + code: 'gateway_context_unavailable', + }, + { + name: 'invalid server clock', + issuedAt: String(NOW_SECONDS), + nowSeconds: -1, + secret: GATEWAY_SECRET, + status: 503, + code: 'gateway_context_unavailable', + }, + ])('fails closed for $name', ({ issuedAt, nowSeconds, secret, status, code }) => { + expectContextProblem( + () => + requireTrustedEventWorkspaceContext( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: signContext(WORKSPACE_ID, issuedAt), + }, + secret, + EVENT_BINDING, + nowSeconds, + ), + { status, code }, + ); }); }); diff --git a/apps/integration-service/src/main.ts b/apps/integration-service/src/main.ts index bc12b3d1..b705f216 100644 --- a/apps/integration-service/src/main.ts +++ b/apps/integration-service/src/main.ts @@ -33,6 +33,19 @@ interface IntegrationProblemDetails { readonly code: IntegrationProblemCode; } +/** Untrusted request identity that must match the exact protected Integration route. */ +export interface IntegrationTrustedRequestBinding { + readonly method: string; + readonly path: string; +} + +/** Untrusted gateway headers used only after cryptographic verification. */ +export interface IntegrationTrustedWorkspaceHeaders { + readonly workspaceId: unknown; + readonly issuedAt: unknown; + readonly signature: unknown; +} + const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const UNIX_SECONDS_PATTERN = /^(?:0|[1-9]\d{0,12})$/u; @@ -40,6 +53,10 @@ const BASE64URL_SHA256_PATTERN = /^[A-Za-z0-9_-]{43}$/u; const MINIMUM_GATEWAY_SECRET_BYTES = 32; const MAXIMUM_CONTEXT_AGE_SECONDS = 60; const MAXIMUM_FUTURE_SKEW_SECONDS = 5; +const EVENT_PREPARE_BINDING = Object.freeze({ + method: 'POST', + path: '/v1/events/prepare', +}); function problemException( status: number, @@ -76,37 +93,39 @@ function unavailableGatewayContext(): never { } /** - * Verifies short-lived tenant authority created only after gateway authentication. - * The legacy browser-selectable `x-workspace-id` header is intentionally ignored. + * Verifies short-lived tenant authority bound to the exact event-preparation + * method and path. Workspace-only v1 signatures and non-canonical signatures + * are rejected so this proof cannot be replayed as future Integration authority. */ -function requireTrustedWorkspaceContext( - workspaceValue: unknown, - issuedAtValue: unknown, - signatureValue: unknown, +export function requireTrustedEventWorkspaceContext( + headers: IntegrationTrustedWorkspaceHeaders, secretValue: unknown, + requestBinding: IntegrationTrustedRequestBinding, nowSeconds = Math.floor(Date.now() / 1000), ): string { if ( typeof secretValue !== 'string' || - Buffer.byteLength(secretValue, 'utf8') < MINIMUM_GATEWAY_SECRET_BYTES + Buffer.byteLength(secretValue, 'utf8') < MINIMUM_GATEWAY_SECRET_BYTES || + !Number.isSafeInteger(nowSeconds) || + nowSeconds < 0 ) { return unavailableGatewayContext(); } if ( - typeof workspaceValue !== 'string' || - typeof issuedAtValue !== 'string' || - typeof signatureValue !== 'string' || - !UUID_V4_PATTERN.test(workspaceValue) || - !UNIX_SECONDS_PATTERN.test(issuedAtValue) || - !BASE64URL_SHA256_PATTERN.test(signatureValue) || - !Number.isSafeInteger(nowSeconds) || - nowSeconds < 0 + requestBinding.method !== EVENT_PREPARE_BINDING.method || + requestBinding.path !== EVENT_PREPARE_BINDING.path || + typeof headers.workspaceId !== 'string' || + typeof headers.issuedAt !== 'string' || + typeof headers.signature !== 'string' || + !UUID_V4_PATTERN.test(headers.workspaceId) || + !UNIX_SECONDS_PATTERN.test(headers.issuedAt) || + !BASE64URL_SHA256_PATTERN.test(headers.signature) ) { return invalidGatewayContext(); } - const workspaceId = workspaceValue.toLowerCase(); - const issuedAtSeconds = Number(issuedAtValue); + const workspaceId = headers.workspaceId.toLowerCase(); + const issuedAtSeconds = Number(headers.issuedAt); if ( !Number.isSafeInteger(issuedAtSeconds) || issuedAtSeconds > nowSeconds + MAXIMUM_FUTURE_SKEW_SECONDS || @@ -115,10 +134,16 @@ function requireTrustedWorkspaceContext( return invalidGatewayContext(); } + const actual = Buffer.from(headers.signature, 'base64url'); + if (actual.toString('base64url') !== headers.signature) { + return invalidGatewayContext(); + } const expected = createHmac('sha256', secretValue) - .update(`life-os.workspace.v1\n${workspaceId}\n${issuedAtValue}`, 'utf8') + .update( + `life-os.integration-event-context.v2\n${workspaceId}\n${headers.issuedAt}\n${requestBinding.method}\n${requestBinding.path}`, + 'utf8', + ) .digest(); - const actual = Buffer.from(signatureValue, 'base64url'); if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { return invalidGatewayContext(); } @@ -159,11 +184,10 @@ export class IntegrationController { @Body() body: unknown, ): PreparedPluginEvent { try { - const trustedWorkspaceId = requireTrustedWorkspaceContext( - workspaceId, - issuedAt, - signature, + const trustedWorkspaceId = requireTrustedEventWorkspaceContext( + { workspaceId, issuedAt, signature }, process.env.INTEGRATION_GATEWAY_CONTEXT_SECRET, + EVENT_PREPARE_BINDING, ); return preparePluginEvent(trustedWorkspaceId, body); } catch (error) { diff --git a/apps/integration-service/src/plugin-contract.integration.test.ts b/apps/integration-service/src/plugin-contract.integration.test.ts index 98f14e69..a12b14b5 100644 --- a/apps/integration-service/src/plugin-contract.integration.test.ts +++ b/apps/integration-service/src/plugin-contract.integration.test.ts @@ -21,6 +21,24 @@ const GATEWAY_SECRET = [ function signedWorkspaceHeaders( workspaceId: string, issuedAt = String(Math.floor(Date.now() / 1000)), +): Record { + const normalizedWorkspaceId = workspaceId.toLowerCase(); + const signature = createHmac('sha256', GATEWAY_SECRET) + .update( + `life-os.integration-event-context.v2\n${normalizedWorkspaceId}\n${issuedAt}\nPOST\n/v1/events/prepare`, + 'utf8', + ) + .digest('base64url'); + return { + 'x-life-os-workspace-id': workspaceId, + 'x-life-os-context-issued-at': issuedAt, + 'x-life-os-context-signature': signature, + }; +} + +function legacySignedWorkspaceHeaders( + workspaceId: string, + issuedAt = String(Math.floor(Date.now() / 1000)), ): Record { const normalizedWorkspaceId = workspaceId.toLowerCase(); const signature = createHmac('sha256', GATEWAY_SECRET) @@ -153,6 +171,18 @@ describe('plugin contract HTTP boundary', () => { prepared.serializedEvent.indexOf('"version"'), ); + const legacyWorkspaceOnly = await postJson( + address.port, + '/v1/events/prepare', + eventRequest(), + undefined, + legacySignedWorkspaceHeaders(WORKSPACE_ID), + ); + expect(legacyWorkspaceOnly.status).toBe(401); + expect(await legacyWorkspaceOnly.json()).toMatchObject({ + code: 'invalid_gateway_context', + }); + const otherTenantResponse = await postJson( address.port, '/v1/events/prepare',