diff --git a/apps/integration-calendar-service/src/calendar-service-context.test.ts b/apps/integration-calendar-service/src/calendar-service-context.test.ts new file mode 100644 index 000000000..8796823d4 --- /dev/null +++ b/apps/integration-calendar-service/src/calendar-service-context.test.ts @@ -0,0 +1,100 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const TEST_CONTEXT_KEY = randomBytes(32).toString('base64url'); +const NOW_SECONDS = 1_786_291_200; + +interface CalendarContextModule { + requireTrustedCalendarWorkspaceContext( + headers: Readonly<{ + workspaceId: unknown; + issuedAt: unknown; + signature: unknown; + }>, + secret: unknown, + nowSeconds?: number, + ): string; +} + +async function contextModule(): Promise { + const modulePath = './calendar-service-context'; + const module = (await import(modulePath).catch(() => ({}))) as Readonly< + Record + >; + expect(typeof module.requireTrustedCalendarWorkspaceContext).toBe('function'); + return module as unknown as CalendarContextModule; +} + +function signature(workspaceId: string, issuedAt: string): string { + return createHmac('sha256', TEST_CONTEXT_KEY) + .update(`life-os.calendar-workspace.v1\n${workspaceId}\n${issuedAt}`, 'utf8') + .digest('base64url'); +} + +describe('trusted calendar workspace context', () => { + it('accepts one fresh signed UUIDv4 workspace context', async () => { + const { requireTrustedCalendarWorkspaceContext } = await contextModule(); + const issuedAt = String(NOW_SECONDS); + + expect( + requireTrustedCalendarWorkspaceContext( + { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: signature(WORKSPACE_ID, issuedAt), + }, + TEST_CONTEXT_KEY, + NOW_SECONDS, + ), + ).toBe(WORKSPACE_ID); + }); + + it('rejects unsigned, forged, stale, future, malformed, and unavailable contexts', async () => { + const { requireTrustedCalendarWorkspaceContext } = await contextModule(); + const issuedAt = String(NOW_SECONDS); + const valid = { + workspaceId: WORKSPACE_ID, + issuedAt, + signature: signature(WORKSPACE_ID, issuedAt), + }; + const invalid = [ + { ...valid, signature: undefined }, + { + ...valid, + signature: signature( + '22222222-2222-4222-8222-222222222222', + issuedAt, + ), + }, + { + ...valid, + issuedAt: String(NOW_SECONDS - 61), + signature: signature(WORKSPACE_ID, String(NOW_SECONDS - 61)), + }, + { + ...valid, + issuedAt: String(NOW_SECONDS + 6), + signature: signature(WORKSPACE_ID, String(NOW_SECONDS + 6)), + }, + { ...valid, workspaceId: 'attacker-selected-workspace' }, + ]; + + for (const candidate of invalid) { + expect(() => + requireTrustedCalendarWorkspaceContext( + candidate, + TEST_CONTEXT_KEY, + NOW_SECONDS, + ), + ).toThrow(); + } + expect(() => + requireTrustedCalendarWorkspaceContext( + valid, + 'short-secret', + NOW_SECONDS, + ), + ).toThrow(); + }); +}); diff --git a/apps/integration-calendar-service/src/calendar-service-context.ts b/apps/integration-calendar-service/src/calendar-service-context.ts new file mode 100644 index 000000000..2b4a162d9 --- /dev/null +++ b/apps/integration-calendar-service/src/calendar-service-context.ts @@ -0,0 +1,103 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +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}$/iu; +const ISSUED_AT_PATTERN = /^\d{10}$/u; +const SIGNATURE_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const MINIMUM_SECRET_BYTES = 32; +const MAXIMUM_CONTEXT_AGE_SECONDS = 60; +const MAXIMUM_FUTURE_SKEW_SECONDS = 5; +const CONTEXT_VERSION = 'life-os.calendar-workspace.v1'; + +/** Headers accepted from the trusted LifeOS gateway boundary. */ +export interface CalendarWorkspaceContextHeaders { + readonly workspaceId: unknown; + readonly issuedAt: unknown; + readonly signature: unknown; +} + +/** Marks a missing or unusable server-side verification configuration. */ +export class CalendarContextUnavailableError extends Error { + constructor() { + super('trusted calendar context is unavailable'); + this.name = 'CalendarContextUnavailableError'; + } +} + +/** Marks an untrusted, malformed, stale, or forged calendar context. */ +export class CalendarContextInvalidError extends Error { + constructor() { + super('trusted calendar context is invalid'); + this.name = 'CalendarContextInvalidError'; + } +} + +function requireSecret(secret: unknown): string { + if ( + typeof secret !== 'string' || + Buffer.byteLength(secret, 'utf8') < MINIMUM_SECRET_BYTES + ) { + throw new CalendarContextUnavailableError(); + } + return secret; +} + +function requireIssuedAt(value: unknown, nowSeconds: number): string { + if (typeof value !== 'string' || !ISSUED_AT_PATTERN.test(value)) { + throw new CalendarContextInvalidError(); + } + const issuedAt = Number(value); + if ( + !Number.isSafeInteger(nowSeconds) || + !Number.isSafeInteger(issuedAt) || + issuedAt < nowSeconds - MAXIMUM_CONTEXT_AGE_SECONDS || + issuedAt > nowSeconds + MAXIMUM_FUTURE_SKEW_SECONDS + ) { + throw new CalendarContextInvalidError(); + } + return value; +} + +function expectedSignature( + workspaceId: string, + issuedAt: string, + secret: string, +): Buffer { + return Buffer.from( + createHmac('sha256', secret) + .update(`${CONTEXT_VERSION}\n${workspaceId}\n${issuedAt}`, 'utf8') + .digest('base64url'), + 'ascii', + ); +} + +/** + * Verifies one short-lived server-derived workspace context and returns only + * the authenticated UUIDv4 workspace identifier. + */ +export function requireTrustedCalendarWorkspaceContext( + headers: CalendarWorkspaceContextHeaders, + secret: unknown, + nowSeconds = Math.floor(Date.now() / 1000), +): string { + const safeSecret = requireSecret(secret); + if ( + typeof headers.workspaceId !== 'string' || + !UUID_V4_PATTERN.test(headers.workspaceId) || + typeof headers.signature !== 'string' || + !SIGNATURE_PATTERN.test(headers.signature) + ) { + throw new CalendarContextInvalidError(); + } + const workspaceId = headers.workspaceId.toLowerCase(); + const issuedAt = requireIssuedAt(headers.issuedAt, nowSeconds); + const providedSignature = Buffer.from(headers.signature, 'ascii'); + const canonicalSignature = expectedSignature(workspaceId, issuedAt, safeSecret); + if ( + providedSignature.length !== canonicalSignature.length || + !timingSafeEqual(providedSignature, canonicalSignature) + ) { + throw new CalendarContextInvalidError(); + } + return workspaceId; +} diff --git a/apps/integration-calendar-service/src/calendar-sync.integration.test.ts b/apps/integration-calendar-service/src/calendar-sync.integration.test.ts index 3da1964a6..1b7f339ff 100644 --- a/apps/integration-calendar-service/src/calendar-sync.integration.test.ts +++ b/apps/integration-calendar-service/src/calendar-sync.integration.test.ts @@ -1,3 +1,4 @@ +import { createHmac, randomBytes } from 'node:crypto'; import type { AddressInfo } from 'node:net'; import { NestFactory } from '@nestjs/core'; import { describe, expect, it } from 'vitest'; @@ -13,6 +14,7 @@ const WORKSPACE_ID = 'e021b411-f75e-4490-97a4-f1f6ee811849'; const OTHER_WORKSPACE_ID = '474c83ae-08af-4a63-957b-49eb2093a61d'; const BLOCK_ID = '1f06da41-cf62-4387-adad-6f53dd8ee66c'; const SYNTHETIC_CSRF_TOKEN = 'synthetic-test-csrf-token'; +const CALENDAR_CONTEXT_KEY = randomBytes(32).toString('base64url'); interface StoredCalendarResource { readonly calendarData: string; @@ -65,6 +67,21 @@ function unfoldIcalendar(value: string): string { return value.replace(/\r\n[ \t]/g, ''); } +function trustedWorkspaceHeaders(workspaceId: string): Record { + const issuedAt = String(Math.floor(Date.now() / 1000)); + const signature = createHmac('sha256', CALENDAR_CONTEXT_KEY) + .update( + `life-os.calendar-workspace.v1\n${workspaceId}\n${issuedAt}`, + 'utf8', + ) + .digest('base64url'); + return { + 'x-life-os-workspace-id': workspaceId, + 'x-life-os-context-issued-at': issuedAt, + 'x-life-os-context-signature': signature, + }; +} + async function postSync( port: number, workspaceId: string, @@ -76,13 +93,66 @@ async function postSync( 'content-type': 'application/json', 'x-csrf-token': SYNTHETIC_CSRF_TOKEN, 'x-workspace-id': workspaceId, + ...trustedWorkspaceHeaders(workspaceId), }, body: JSON.stringify(body), }); } describe('calendar synchronization HTTP boundary', () => { + it('rejects legacy client-selected workspace authority and accepts the signed workspace context', async () => { + const previousSecret = process.env.CALENDAR_GATEWAY_CONTEXT_SECRET; + process.env.CALENDAR_GATEWAY_CONTEXT_SECRET = CALENDAR_CONTEXT_KEY; + const provider = new ConflictSafeRecordingProvider(); + const app = await NestFactory.create(CalendarAppModule.register(provider), { + logger: false, + }); + await app.listen(0, '127.0.0.1'); + + try { + const address = app.getHttpServer().address() as AddressInfo; + const legacyResponse = await fetch( + `http://127.0.0.1:${address.port}/v1/calendar/sync`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-csrf-token': SYNTHETIC_CSRF_TOKEN, + 'x-workspace-id': WORKSPACE_ID, + }, + body: JSON.stringify(timeBlock()), + }, + ); + expect(legacyResponse.status).toBe(401); + expect(provider.resources.size).toBe(0); + + const trustedResponse = await fetch( + `http://127.0.0.1:${address.port}/v1/calendar/sync`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-csrf-token': SYNTHETIC_CSRF_TOKEN, + ...trustedWorkspaceHeaders(WORKSPACE_ID), + }, + body: JSON.stringify(timeBlock()), + }, + ); + expect(trustedResponse.status).toBe(200); + expect(provider.resources.size).toBe(1); + } finally { + await app.close(); + if (previousSecret === undefined) { + delete process.env.CALENDAR_GATEWAY_CONTEXT_SECRET; + } else { + process.env.CALENDAR_GATEWAY_CONTEXT_SECRET = previousSecret; + } + } + }); + it('prevents duplicates and silent overwrites while retaining tenant isolation', async () => { + const previousSecret = process.env.CALENDAR_GATEWAY_CONTEXT_SECRET; + process.env.CALENDAR_GATEWAY_CONTEXT_SECRET = CALENDAR_CONTEXT_KEY; const provider = new ConflictSafeRecordingProvider(); const app = await NestFactory.create(CalendarAppModule.register(provider), { logger: false, @@ -164,6 +234,7 @@ describe('calendar synchronization HTTP boundary', () => { headers: { 'x-csrf-token': SYNTHETIC_CSRF_TOKEN, 'x-workspace-id': WORKSPACE_ID, + ...trustedWorkspaceHeaders(WORKSPACE_ID), }, }, ); @@ -176,6 +247,11 @@ describe('calendar synchronization HTTP boundary', () => { expect(ownershipInjection.status).toBe(400); } finally { await app.close(); + if (previousSecret === undefined) { + delete process.env.CALENDAR_GATEWAY_CONTEXT_SECRET; + } else { + process.env.CALENDAR_GATEWAY_CONTEXT_SECRET = previousSecret; + } } }); }); diff --git a/apps/integration-calendar-service/src/main.ts b/apps/integration-calendar-service/src/main.ts index 3c40f2fe3..8c6a5c029 100644 --- a/apps/integration-calendar-service/src/main.ts +++ b/apps/integration-calendar-service/src/main.ts @@ -12,6 +12,11 @@ import { Post, } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { + CalendarContextInvalidError, + CalendarContextUnavailableError, + requireTrustedCalendarWorkspaceContext, +} from './calendar-service-context'; import { CaldavCalendarProvider, CalendarConflictError, @@ -57,15 +62,32 @@ export class CalendarSyncController { @Post('v1/calendar/sync') @HttpCode(200) async sync( - @Headers('x-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: unknown, ): Promise { try { - if (!workspaceId) { - throw new CalendarValidationError(); - } - return await this.calendarSyncService.sync(workspaceId, body); + const trustedWorkspaceId = requireTrustedCalendarWorkspaceContext( + { workspaceId, issuedAt, signature }, + process.env.CALENDAR_GATEWAY_CONTEXT_SECRET, + ); + return await this.calendarSyncService.sync(trustedWorkspaceId, body); } catch (error) { + if (error instanceof CalendarContextInvalidError) { + throw problem( + 401, + 'Calendar synchronization context is invalid', + 'invalid_gateway_context', + ); + } + if (error instanceof CalendarContextUnavailableError) { + throw problem( + 503, + 'Calendar synchronization context is unavailable', + 'calendar_context_unavailable', + ); + } if (error instanceof CalendarValidationError) { throw problem( 400,