From a0f6c883abeaf5ba6bca5c18dd8d787cd2dd2e1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:02:29 +0900 Subject: [PATCH 01/13] test(habit): require exact data-rights transport authority --- .../habit-data-rights-http-boundary.test.ts | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 apps/habit-service/src/habit-data-rights-http-boundary.test.ts diff --git a/apps/habit-service/src/habit-data-rights-http-boundary.test.ts b/apps/habit-service/src/habit-data-rights-http-boundary.test.ts new file mode 100644 index 00000000..de04fe95 --- /dev/null +++ b/apps/habit-service/src/habit-data-rights-http-boundary.test.ts @@ -0,0 +1,200 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { HttpException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { + parseTrustedHabitDataRightsRequest, + type HabitDataRightsRequestBinding, +} from './habit-data-rights-http-boundary'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; +const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; +const CONTEXT_SECRET = randomBytes(32).toString('base64url'); +const NOW_SECONDS = 1_785_806_400; +const BINDING = { + method: 'POST', + path: '/v1/internal/data-rights/contributor', +} as const; + +const EXPORT_REQUEST = { + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'export', + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, +} as const; + +function sign( + request: Readonly>, + binding: HabitDataRightsRequestBinding = BINDING, + issuedAt = String(NOW_SECONDS), +): string { + const operation = String(request.operation); + const idempotencyKey = + operation === 'erase' ? String(request.idempotencyKey) : '-'; + return createHmac('sha256', CONTEXT_SECRET) + .update( + [ + 'life-os.habit-data-rights-context.v1', + request.contractVersion, + request.workspaceId, + request.requestedByUserId, + request.requestId, + operation, + idempotencyKey, + issuedAt, + binding.method, + binding.path, + ].join('\n'), + 'utf8', + ) + .digest('base64url'); +} + +function expectHttpStatus(operation: () => unknown, status: number): void { + let thrown: unknown; + try { + operation(); + } catch (error) { + thrown = error; + } + if (thrown === undefined) { + throw new Error(`Expected HTTP ${status} rejection`); + } + expect(thrown).toBeInstanceOf(HttpException); + expect((thrown as HttpException).getStatus()).toBe(status); +} + +describe('Habit data-rights trusted HTTP boundary', () => { + it('accepts an exact short-lived service-authenticated export request', () => { + const issuedAt = String(NOW_SECONDS); + expect( + parseTrustedHabitDataRightsRequest( + EXPORT_REQUEST, + { issuedAt, signature: sign(EXPORT_REQUEST, BINDING, issuedAt) }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + ), + ).toEqual(EXPORT_REQUEST); + }); + + it('rejects replay of an export signature onto an erase request', () => { + const eraseRequest = { + ...EXPORT_REQUEST, + operation: 'erase', + idempotencyKey: IDEMPOTENCY_KEY, + } as const; + const issuedAt = String(NOW_SECONDS); + + expectHttpStatus( + () => + parseTrustedHabitDataRightsRequest( + eraseRequest, + { issuedAt, signature: sign(EXPORT_REQUEST, BINDING, issuedAt) }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + ), + 401, + ); + }); + + it('binds destructive authority to the exact idempotency key', () => { + const eraseRequest = { + ...EXPORT_REQUEST, + operation: 'erase', + idempotencyKey: IDEMPOTENCY_KEY, + } as const; + const tamperedRequest = { + ...eraseRequest, + idempotencyKey: '55555555-5555-4555-8555-555555555555', + } as const; + const issuedAt = String(NOW_SECONDS); + + expectHttpStatus( + () => + parseTrustedHabitDataRightsRequest( + tamperedRequest, + { issuedAt, signature: sign(eraseRequest, BINDING, issuedAt) }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + ), + 401, + ); + }); + + it('rejects a valid signature replayed to another method or path', () => { + const issuedAt = String(NOW_SECONDS); + const signature = sign(EXPORT_REQUEST, BINDING, issuedAt); + + for (const binding of [ + { method: 'GET', path: BINDING.path }, + { method: 'POST', path: '/v1/internal/data-rights/other' }, + ]) { + expectHttpStatus( + () => + parseTrustedHabitDataRightsRequest( + EXPORT_REQUEST, + { issuedAt, signature }, + CONTEXT_SECRET, + binding, + NOW_SECONDS, + ), + 401, + ); + } + }); + + it('rejects stale authority, malformed UUIDs, extra fields, and missing secrets', () => { + const issuedAt = String(NOW_SECONDS); + const signature = sign(EXPORT_REQUEST, BINDING, issuedAt); + + expectHttpStatus( + () => + parseTrustedHabitDataRightsRequest( + EXPORT_REQUEST, + { issuedAt: String(NOW_SECONDS - 61), signature }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + ), + 401, + ); + expectHttpStatus( + () => + parseTrustedHabitDataRightsRequest( + { ...EXPORT_REQUEST, workspaceId: 'workspace-one' }, + { issuedAt, signature }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + ), + 400, + ); + expectHttpStatus( + () => + parseTrustedHabitDataRightsRequest( + { ...EXPORT_REQUEST, unexpected: true }, + { issuedAt, signature }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + ), + 400, + ); + expectHttpStatus( + () => + parseTrustedHabitDataRightsRequest( + EXPORT_REQUEST, + { issuedAt, signature }, + undefined, + BINDING, + NOW_SECONDS, + ), + 503, + ); + }); +}); From 694054ba6616b9c136928a2cb93721267493712a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:03:17 +0900 Subject: [PATCH 02/13] feat(habit): verify trusted data-rights contributor requests --- .../src/habit-data-rights-http-boundary.ts | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 apps/habit-service/src/habit-data-rights-http-boundary.ts diff --git a/apps/habit-service/src/habit-data-rights-http-boundary.ts b/apps/habit-service/src/habit-data-rights-http-boundary.ts new file mode 100644 index 00000000..70f75ed0 --- /dev/null +++ b/apps/habit-service/src/habit-data-rights-http-boundary.ts @@ -0,0 +1,252 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { HttpException } from '@nestjs/common'; +import { + DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + type HabitDataRightsRequest, +} from './habit-data-rights'; + +/** Short-lived service-authentication headers for the internal contributor route. */ +export interface TrustedHabitDataRightsContextHeaders { + readonly issuedAt: unknown; + readonly signature: unknown; +} + +/** Server-owned HTTP identity bound into one contributor authorization digest. */ +export interface HabitDataRightsRequestBinding { + readonly method: unknown; + readonly path: unknown; +} + +interface HabitDataRightsProblemDetails { + readonly type: 'about:blank'; + readonly title: string; + readonly status: number; + readonly code: string; +} + +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; +const BASE64URL_SHA256_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const CONTRIBUTOR_PATH = '/v1/internal/data-rights/contributor'; +const MINIMUM_CONTEXT_SECRET_BYTES = 32; +const MAXIMUM_CONTEXT_AGE_SECONDS = 60; +const MAXIMUM_FUTURE_SKEW_SECONDS = 5; + +type NormalizedRequest = HabitDataRightsRequest & + Readonly<{ + workspaceId: string; + requestedByUserId: string; + requestId: string; + }>; + +function problemException( + status: number, + title: string, + code: string, +): HttpException { + const problem: HabitDataRightsProblemDetails = { + type: 'about:blank', + title, + status, + code, + }; + return new HttpException(problem, status); +} + +function invalidRequest(): never { + throw problemException( + 400, + 'Habit data-rights request is invalid', + 'invalid_data_rights_request', + ); +} + +function invalidContext(): never { + throw problemException( + 401, + 'Habit data-rights authority is invalid', + 'invalid_data_rights_context', + ); +} + +function unavailableContext(): never { + throw problemException( + 503, + 'Habit data-rights authority is unavailable', + 'data_rights_context_unavailable', + ); +} + +function requireRecord(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return invalidRequest(); + } + return value as Record; +} + +function requireExactKeys( + record: Record, + expectedKeys: readonly string[], +): void { + const expected = new Set(expectedKeys); + const actual = Object.keys(record); + if ( + actual.length !== expected.size || + actual.some((key) => !expected.has(key)) + ) { + invalidRequest(); + } +} + +function requireUuidV4(value: unknown): string { + if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { + return invalidRequest(); + } + return value.toLowerCase(); +} + +function normalizeRequest(body: unknown): NormalizedRequest { + const request = requireRecord(body); + const commonKeys = [ + 'contractVersion', + 'operation', + 'workspaceId', + 'requestedByUserId', + 'requestId', + ] as const; + if ( + request.contractVersion !== DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION || + (request.operation !== 'export' && + request.operation !== 'erase_preflight' && + request.operation !== 'erase' && + request.operation !== 'verify_erased') + ) { + return invalidRequest(); + } + + const workspaceId = requireUuidV4(request.workspaceId); + const requestedByUserId = requireUuidV4(request.requestedByUserId); + const requestId = requireUuidV4(request.requestId); + + if (request.operation === 'erase') { + requireExactKeys(request, [...commonKeys, 'idempotencyKey']); + return { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: 'erase', + workspaceId, + requestedByUserId, + requestId, + idempotencyKey: requireUuidV4(request.idempotencyKey), + }; + } + + requireExactKeys(request, commonKeys); + return { + contractVersion: DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, + operation: request.operation, + workspaceId, + requestedByUserId, + requestId, + }; +} + +function requireRequestBinding( + binding: HabitDataRightsRequestBinding, +): { readonly method: 'POST'; readonly path: typeof CONTRIBUTOR_PATH } { + if (binding.method !== 'POST' || binding.path !== CONTRIBUTOR_PATH) { + return invalidContext(); + } + return { method: 'POST', path: CONTRIBUTOR_PATH }; +} + +function requestDigest( + request: NormalizedRequest, + issuedAt: string, + binding: Readonly<{ method: 'POST'; path: typeof CONTRIBUTOR_PATH }>, + secret: string, +): Buffer { + const idempotencyKey = + request.operation === 'erase' ? request.idempotencyKey : '-'; + return createHmac('sha256', secret) + .update( + [ + 'life-os.habit-data-rights-context.v1', + request.contractVersion, + request.workspaceId, + request.requestedByUserId, + request.requestId, + request.operation, + idempotencyKey, + issuedAt, + binding.method, + binding.path, + ].join('\n'), + 'utf8', + ) + .digest(); +} + +/** + * Parses one exact contributor request and verifies short-lived service authority. + * The HMAC binds tenant, actor, request, purpose/operation, destructive replay key, + * lifetime, HTTP method, and resource so credentials cannot authorize another call. + */ +export function parseTrustedHabitDataRightsRequest( + body: unknown, + headers: TrustedHabitDataRightsContextHeaders, + secret: unknown, + requestBinding: HabitDataRightsRequestBinding, + nowSeconds = Math.floor(Date.now() / 1000), +): HabitDataRightsRequest { + const request = normalizeRequest(body); + if ( + typeof secret !== 'string' || + Buffer.byteLength(secret, 'utf8') < MINIMUM_CONTEXT_SECRET_BYTES + ) { + return unavailableContext(); + } + const binding = requireRequestBinding(requestBinding); + if ( + typeof headers.issuedAt !== 'string' || + typeof headers.signature !== 'string' || + !UNIX_SECONDS_PATTERN.test(headers.issuedAt) || + !BASE64URL_SHA256_PATTERN.test(headers.signature) || + !Number.isSafeInteger(nowSeconds) || + nowSeconds < 0 + ) { + return invalidContext(); + } + + const issuedAtSeconds = Number(headers.issuedAt); + if ( + !Number.isSafeInteger(issuedAtSeconds) || + issuedAtSeconds > nowSeconds + MAXIMUM_FUTURE_SKEW_SECONDS || + issuedAtSeconds < nowSeconds - MAXIMUM_CONTEXT_AGE_SECONDS + ) { + return invalidContext(); + } + + const expected = requestDigest(request, headers.issuedAt, binding, secret); + const actual = Buffer.from(headers.signature, 'base64url'); + if ( + actual.length !== expected.length || + actual.toString('base64url') !== headers.signature || + !timingSafeEqual(actual, expected) + ) { + return invalidContext(); + } + return request; +} + +/** Maps contributor/runtime failures to a bounded credential-free service error. */ +export function toHabitDataRightsHttpException(error: unknown): HttpException { + if (error instanceof HttpException) { + return error; + } + return problemException( + 503, + 'Habit data-rights operation is unavailable', + 'data_rights_unavailable', + ); +} From b330f714ebc0c5045b51755f8d06182a03b53d1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:04:04 +0900 Subject: [PATCH 03/13] feat(habit): expose authenticated data-rights contributor transport --- apps/habit-service/src/main.ts | 39 +++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/habit-service/src/main.ts b/apps/habit-service/src/main.ts index 9746deac..be30beb4 100644 --- a/apps/habit-service/src/main.ts +++ b/apps/habit-service/src/main.ts @@ -11,6 +11,11 @@ import { Query, } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import type { HabitDataRightsResponse } from './habit-data-rights'; +import { + parseTrustedHabitDataRightsRequest, + toHabitDataRightsHttpException, +} from './habit-data-rights-http-boundary'; import type { Habit, HabitCompletionEvent, @@ -169,8 +174,40 @@ export class HabitController { } } +/** Internal service-authenticated transport for Habit-owned data-rights work. */ +@Controller('internal/data-rights') +export class HabitDataRightsController { + constructor( + @Inject(HABIT_RUNTIME) + private readonly runtime: HabitRuntime, + ) {} + + /** Executes only the exact v1 contributor request authorized by Identity. */ + @Post('contributor') + async contribute( + @Headers('x-life-os-data-rights-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-data-rights-signature') signature: string | undefined, + @Body() body: unknown, + ): Promise { + try { + const request = parseTrustedHabitDataRightsRequest( + body, + { issuedAt, signature }, + process.env.HABIT_DATA_RIGHTS_CONTEXT_SECRET, + { + method: 'POST', + path: '/v1/internal/data-rights/contributor', + }, + ); + return await this.runtime.dataRightsContributor.handle(request); + } catch (error) { + throw toHabitDataRightsHttpException(error); + } + } +} + @Module({ - controllers: [HabitController], + controllers: [HabitController, HabitDataRightsController], providers: [ { provide: HABIT_RUNTIME, From d5ef6e49869faf45f921a858327444a6243ccfd7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:04:31 +0900 Subject: [PATCH 04/13] test(habit): enforce contributor authority before persistence --- ...t-data-rights-controller-authority.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 apps/habit-service/src/habit-data-rights-controller-authority.test.ts diff --git a/apps/habit-service/src/habit-data-rights-controller-authority.test.ts b/apps/habit-service/src/habit-data-rights-controller-authority.test.ts new file mode 100644 index 00000000..047cb940 --- /dev/null +++ b/apps/habit-service/src/habit-data-rights-controller-authority.test.ts @@ -0,0 +1,100 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { HttpException } from '@nestjs/common'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { HabitRuntime } from './habit-runtime'; +import { HabitDataRightsController } from './main'; + +const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; +const USER_ID = '22222222-2222-4222-8222-222222222222'; +const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; +const CONTEXT_SECRET = randomBytes(32).toString('base64url'); +const REQUEST = { + contractVersion: 'life-os.data-rights-contributor.v1', + operation: 'export', + workspaceId: WORKSPACE_ID, + requestedByUserId: USER_ID, + requestId: REQUEST_ID, +} as const; + +function signature(issuedAt: string): string { + return createHmac('sha256', CONTEXT_SECRET) + .update( + [ + 'life-os.habit-data-rights-context.v1', + REQUEST.contractVersion, + REQUEST.workspaceId, + REQUEST.requestedByUserId, + REQUEST.requestId, + REQUEST.operation, + '-', + issuedAt, + 'POST', + '/v1/internal/data-rights/contributor', + ].join('\n'), + 'utf8', + ) + .digest('base64url'); +} + +function controllerWith(handle: ReturnType): HabitDataRightsController { + return new HabitDataRightsController({ + dataRightsContributor: { handle }, + } as unknown as HabitRuntime); +} + +afterEach(() => { + delete process.env.HABIT_DATA_RIGHTS_CONTEXT_SECRET; + vi.restoreAllMocks(); +}); + +describe.sequential('HabitDataRightsController authority contract', () => { + it('passes only a verified exact request to the service-owned contributor', async () => { + process.env.HABIT_DATA_RIGHTS_CONTEXT_SECRET = CONTEXT_SECRET; + const issuedAt = String(Math.floor(Date.now() / 1000)); + const response = { + contractVersion: REQUEST.contractVersion, + operation: 'export', + contributor: 'habit.service', + requestId: REQUEST_ID, + schemaVersion: 'habit.data-rights.v1', + recordCount: 0, + sha256: '0'.repeat(64), + data: { habits: [], completionEvents: [] }, + } as const; + const handle = vi.fn().mockResolvedValue(response); + const controller = controllerWith(handle); + + await expect( + controller.contribute(issuedAt, signature(issuedAt), REQUEST), + ).resolves.toEqual(response); + expect(handle).toHaveBeenCalledTimes(1); + expect(handle).toHaveBeenCalledWith(REQUEST); + }); + + it('fails closed before persistence when the service signature is forged', async () => { + process.env.HABIT_DATA_RIGHTS_CONTEXT_SECRET = CONTEXT_SECRET; + const issuedAt = String(Math.floor(Date.now() / 1000)); + const handle = vi.fn(); + const controller = controllerWith(handle); + + await expect( + controller.contribute( + issuedAt, + randomBytes(32).toString('base64url'), + REQUEST, + ), + ).rejects.toMatchObject({ status: 401 } satisfies Partial); + expect(handle).not.toHaveBeenCalled(); + }); + + it('fails closed when the dedicated data-rights trust secret is absent', async () => { + const issuedAt = String(Math.floor(Date.now() / 1000)); + const handle = vi.fn(); + const controller = controllerWith(handle); + + await expect( + controller.contribute(issuedAt, signature(issuedAt), REQUEST), + ).rejects.toMatchObject({ status: 503 } satisfies Partial); + expect(handle).not.toHaveBeenCalled(); + }); +}); From 7f9e441c1a8b61c39613e8e09cd7c310c7432ec9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:05:11 +0900 Subject: [PATCH 05/13] test(habit): assert bounded data-rights transport failures --- ...t-data-rights-controller-authority.test.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/habit-service/src/habit-data-rights-controller-authority.test.ts b/apps/habit-service/src/habit-data-rights-controller-authority.test.ts index 047cb940..2d7db5cb 100644 --- a/apps/habit-service/src/habit-data-rights-controller-authority.test.ts +++ b/apps/habit-service/src/habit-data-rights-controller-authority.test.ts @@ -42,6 +42,16 @@ function controllerWith(handle: ReturnType): HabitDataRightsContro } as unknown as HabitRuntime); } +async function rejectedStatus(operation: Promise): Promise { + try { + await operation; + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + return (error as HttpException).getStatus(); + } + throw new Error('Expected Habit data-rights transport rejection'); +} + afterEach(() => { delete process.env.HABIT_DATA_RIGHTS_CONTEXT_SECRET; vi.restoreAllMocks(); @@ -77,13 +87,15 @@ describe.sequential('HabitDataRightsController authority contract', () => { const handle = vi.fn(); const controller = controllerWith(handle); - await expect( - controller.contribute( - issuedAt, - randomBytes(32).toString('base64url'), - REQUEST, + expect( + await rejectedStatus( + controller.contribute( + issuedAt, + randomBytes(32).toString('base64url'), + REQUEST, + ), ), - ).rejects.toMatchObject({ status: 401 } satisfies Partial); + ).toBe(401); expect(handle).not.toHaveBeenCalled(); }); @@ -92,9 +104,11 @@ describe.sequential('HabitDataRightsController authority contract', () => { const handle = vi.fn(); const controller = controllerWith(handle); - await expect( - controller.contribute(issuedAt, signature(issuedAt), REQUEST), - ).rejects.toMatchObject({ status: 503 } satisfies Partial); + expect( + await rejectedStatus( + controller.contribute(issuedAt, signature(issuedAt), REQUEST), + ), + ).toBe(503); expect(handle).not.toHaveBeenCalled(); }); }); From d3e6dca39695464583a442d7603d64799b238371 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:08:31 +0900 Subject: [PATCH 06/13] chore(habit): declare dedicated data-rights trust secret --- .env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.example b/.env.example index 362945b1..24cc988a 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,7 @@ PLANNING_SERVICE_ORIGIN=http://127.0.0.1:4102 PLANNING_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes HABIT_SERVICE_ORIGIN=http://127.0.0.1:4103 HABIT_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes +HABIT_DATA_RIGHTS_CONTEXT_SECRET=replace-with-distinct-at-least-32-random-bytes AI_SERVICE_ORIGIN=http://127.0.0.1:4105 AI_GATEWAY_ACTIVE_KEY_ID=gateway-2026-08-a AI_GATEWAY_ACTIVE_KEY_SECRET=replace-with-at-least-32-random-bytes From 16a51d0761760c3202c553917e4afc19875250e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:54:45 +0900 Subject: [PATCH 07/13] test(habit): expose data-rights replay and error leaks --- ...t-data-rights-controller-authority.test.ts | 91 ++++++++++++++++--- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/apps/habit-service/src/habit-data-rights-controller-authority.test.ts b/apps/habit-service/src/habit-data-rights-controller-authority.test.ts index 2d7db5cb..e83f5e72 100644 --- a/apps/habit-service/src/habit-data-rights-controller-authority.test.ts +++ b/apps/habit-service/src/habit-data-rights-controller-authority.test.ts @@ -7,6 +7,7 @@ import { HabitDataRightsController } from './main'; const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; const USER_ID = '22222222-2222-4222-8222-222222222222'; const REQUEST_ID = '33333333-3333-4333-8333-333333333333'; +const IDEMPOTENCY_KEY = '44444444-4444-4444-8444-444444444444'; const CONTEXT_SECRET = randomBytes(32).toString('base64url'); const REQUEST = { contractVersion: 'life-os.data-rights-contributor.v1', @@ -15,18 +16,25 @@ const REQUEST = { requestedByUserId: USER_ID, requestId: REQUEST_ID, } as const; +const ERASE_REQUEST = { + ...REQUEST, + operation: 'erase', + idempotencyKey: IDEMPOTENCY_KEY, +} as const; + +type SignedRequest = typeof REQUEST | typeof ERASE_REQUEST; -function signature(issuedAt: string): string { +function signature(request: SignedRequest, issuedAt: string): string { return createHmac('sha256', CONTEXT_SECRET) .update( [ 'life-os.habit-data-rights-context.v1', - REQUEST.contractVersion, - REQUEST.workspaceId, - REQUEST.requestedByUserId, - REQUEST.requestId, - REQUEST.operation, - '-', + request.contractVersion, + request.workspaceId, + request.requestedByUserId, + request.requestId, + request.operation, + request.operation === 'erase' ? request.idempotencyKey : '-', issuedAt, 'POST', '/v1/internal/data-rights/contributor', @@ -36,22 +44,30 @@ function signature(issuedAt: string): string { .digest('base64url'); } -function controllerWith(handle: ReturnType): HabitDataRightsController { +function controllerWith( + handle: ReturnType, + consume: ReturnType = vi.fn().mockResolvedValue(true), +): HabitDataRightsController { return new HabitDataRightsController({ dataRightsContributor: { handle }, + dataRightsAuthorityReplayGuard: { consume }, } as unknown as HabitRuntime); } -async function rejectedStatus(operation: Promise): Promise { +async function rejectedException(operation: Promise): Promise { try { await operation; } catch (error) { expect(error).toBeInstanceOf(HttpException); - return (error as HttpException).getStatus(); + return error as HttpException; } throw new Error('Expected Habit data-rights transport rejection'); } +async function rejectedStatus(operation: Promise): Promise { + return (await rejectedException(operation)).getStatus(); +} + afterEach(() => { delete process.env.HABIT_DATA_RIGHTS_CONTEXT_SECRET; vi.restoreAllMocks(); @@ -75,7 +91,7 @@ describe.sequential('HabitDataRightsController authority contract', () => { const controller = controllerWith(handle); await expect( - controller.contribute(issuedAt, signature(issuedAt), REQUEST), + controller.contribute(issuedAt, signature(REQUEST, issuedAt), REQUEST), ).resolves.toEqual(response); expect(handle).toHaveBeenCalledTimes(1); expect(handle).toHaveBeenCalledWith(REQUEST); @@ -106,9 +122,60 @@ describe.sequential('HabitDataRightsController authority contract', () => { expect( await rejectedStatus( - controller.contribute(issuedAt, signature(issuedAt), REQUEST), + controller.contribute(issuedAt, signature(REQUEST, issuedAt), REQUEST), ), ).toBe(503); expect(handle).not.toHaveBeenCalled(); }); + + it('atomically rejects replay of the same destructive signed authority before contribution', async () => { + process.env.HABIT_DATA_RIGHTS_CONTEXT_SECRET = CONTEXT_SECRET; + const issuedAt = String(Math.floor(Date.now() / 1000)); + const consume = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const response = { + contractVersion: ERASE_REQUEST.contractVersion, + operation: 'erase', + contributor: 'habit.service', + requestId: REQUEST_ID, + erasedRecords: 2, + receiptSha256: '1'.repeat(64), + } as const; + const handle = vi.fn().mockResolvedValue(response); + const controller = controllerWith(handle, consume); + const signed = signature(ERASE_REQUEST, issuedAt); + + await expect( + controller.contribute(issuedAt, signed, ERASE_REQUEST), + ).resolves.toEqual(response); + expect( + await rejectedStatus( + controller.contribute(issuedAt, signed, ERASE_REQUEST), + ), + ).toBe(401); + expect(consume).toHaveBeenCalledTimes(2); + expect(handle).toHaveBeenCalledTimes(1); + }); + + it('sanitizes arbitrary contributor HttpException responses to one bounded 503 problem', async () => { + process.env.HABIT_DATA_RIGHTS_CONTEXT_SECRET = CONTEXT_SECRET; + const issuedAt = String(Math.floor(Date.now() / 1000)); + const handle = vi + .fn() + .mockRejectedValue(new HttpException({ leaked: 'provider detail' }, 418)); + const controller = controllerWith(handle); + + const error = await rejectedException( + controller.contribute(issuedAt, signature(REQUEST, issuedAt), REQUEST), + ); + expect(error.getStatus()).toBe(503); + expect(error.getResponse()).toEqual({ + type: 'about:blank', + title: 'Habit data-rights operation is unavailable', + status: 503, + code: 'data_rights_unavailable', + }); + }); }); From 936727432adb06f48f35938345540428145362ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:59:22 +0900 Subject: [PATCH 08/13] fix(habit): atomically consume destructive data-rights authority --- .../0003_data_rights_authority_replay.sql | 29 ++++ ...ights-authority-replay.integration.test.ts | 101 ++++++++++++++ ...habit-data-rights-authority-replay.test.ts | 95 +++++++++++++ .../src/habit-data-rights-authority-replay.ts | 105 ++++++++++++++ .../habit-data-rights-http-boundary.test.ts | 132 ++++++++++++++---- .../src/habit-data-rights-http-boundary.ts | 75 ++++++++-- apps/habit-service/src/habit-runtime.ts | 4 + apps/habit-service/src/main.ts | 20 +-- 8 files changed, 511 insertions(+), 50 deletions(-) create mode 100644 apps/habit-service/migrations/0003_data_rights_authority_replay.sql create mode 100644 apps/habit-service/src/habit-data-rights-authority-replay.integration.test.ts create mode 100644 apps/habit-service/src/habit-data-rights-authority-replay.test.ts create mode 100644 apps/habit-service/src/habit-data-rights-authority-replay.ts diff --git a/apps/habit-service/migrations/0003_data_rights_authority_replay.sql b/apps/habit-service/migrations/0003_data_rights_authority_replay.sql new file mode 100644 index 00000000..766d28fb --- /dev/null +++ b/apps/habit-service/migrations/0003_data_rights_authority_replay.sql @@ -0,0 +1,29 @@ +BEGIN; + +CREATE TABLE habit.data_rights_authority_replay_records ( + evidence_digest text NOT NULL, + consumed_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + CONSTRAINT data_rights_authority_replay_records_primary + PRIMARY KEY (evidence_digest), + CONSTRAINT data_rights_authority_replay_records_digest_sha256 CHECK ( + evidence_digest ~ '^[0-9a-f]{64}$' + ), + CONSTRAINT data_rights_authority_replay_records_expiry_order CHECK ( + expires_at >= consumed_at + ) +); + +CREATE INDEX data_rights_authority_replay_expiry_index + ON habit.data_rights_authority_replay_records (expires_at); + +COMMENT ON TABLE habit.data_rights_authority_replay_records IS + 'Habit-owned one-time evidence for destructive data-rights HTTP authority.'; +COMMENT ON COLUMN habit.data_rights_authority_replay_records.evidence_digest IS + 'SHA-256 digest of one canonical validated HMAC proof; raw authorization evidence is never persisted.'; +COMMENT ON COLUMN habit.data_rights_authority_replay_records.consumed_at IS + 'Database-clock instant at which the winning Habit service instance consumed the destructive authority.'; +COMMENT ON COLUMN habit.data_rights_authority_replay_records.expires_at IS + 'End of the signed authority lifetime after which the replay record can be pruned.'; + +COMMIT; diff --git a/apps/habit-service/src/habit-data-rights-authority-replay.integration.test.ts b/apps/habit-service/src/habit-data-rights-authority-replay.integration.test.ts new file mode 100644 index 00000000..1dd10f72 --- /dev/null +++ b/apps/habit-service/src/habit-data-rights-authority-replay.integration.test.ts @@ -0,0 +1,101 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { PostgresHabitDataRightsAuthorityReplayGuard } from './habit-data-rights-authority-replay'; +import type { + HabitSqlClient, + HabitSqlQueryResult, +} from './postgres-habit-repository'; + +const DATABASE_URL = process.env.HABIT_DATABASE_URL; +const describeWithPostgres = DATABASE_URL ? describe : describe.skip; +let administrativePool: Pool; + +class PoolSqlClient implements HabitSqlClient { + constructor(private readonly pool: Pool) {} + + async query( + text: string, + values: readonly unknown[], + ): Promise> { + const result = await this.pool.query(text, [...values]); + return { rows: result.rows as Row[] }; + } +} + +function requireDatabaseUrl(): string { + if (!DATABASE_URL) { + throw new Error('HABIT_DATABASE_URL is required for integration tests'); + } + return DATABASE_URL; +} + +async function applyMigrations(pool: Pool): Promise { + for (const migration of [ + '0001_recurring_habit_core.sql', + '0002_data_rights_erasure.sql', + '0003_data_rights_authority_replay.sql', + ]) { + const sql = await readFile( + resolve(__dirname, '../migrations', migration), + 'utf8', + ); + await pool.query(sql); + } +} + +describeWithPostgres('Habit data-rights authority replay PostgreSQL integration', () => { + beforeAll(async () => { + administrativePool = new Pool({ + connectionString: requireDatabaseUrl(), + application_name: 'life-os-habit-data-rights-replay-test', + max: 4, + }); + }); + + beforeEach(async () => { + await administrativePool.query('DROP SCHEMA IF EXISTS habit CASCADE'); + await applyMigrations(administrativePool); + }); + + afterAll(async () => { + await administrativePool.query('DROP SCHEMA IF EXISTS habit CASCADE'); + await administrativePool.end(); + }); + + it('allows exactly one concurrent winner and never persists raw authorization evidence', async () => { + const guard = new PostgresHabitDataRightsAuthorityReplayGuard( + new PoolSqlClient(administrativePool), + ); + const rawSignature = 'sensitive-short-lived-proof'; + const evidenceDigest = 'a'.repeat(64); + const expiresAt = new Date(Date.now() + 60_000).toISOString(); + + const results = await Promise.all([ + guard.consume({ evidenceDigest, expiresAt }), + guard.consume({ evidenceDigest, expiresAt }), + ]); + expect(results.sort()).toEqual([false, true]); + + const stored = await administrativePool.query( + `SELECT evidence_digest, consumed_at, expires_at + FROM habit.data_rights_authority_replay_records`, + ); + expect(stored.rows).toHaveLength(1); + expect(stored.rows[0]?.evidence_digest).toBe(evidenceDigest); + expect(JSON.stringify(stored.rows)).not.toContain(rawSignature); + }); + + it('rejects already expired evidence using the database clock', async () => { + const guard = new PostgresHabitDataRightsAuthorityReplayGuard( + new PoolSqlClient(administrativePool), + ); + await expect( + guard.consume({ + evidenceDigest: 'b'.repeat(64), + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }), + ).resolves.toBe(false); + }); +}); diff --git a/apps/habit-service/src/habit-data-rights-authority-replay.test.ts b/apps/habit-service/src/habit-data-rights-authority-replay.test.ts new file mode 100644 index 00000000..c47a6ab0 --- /dev/null +++ b/apps/habit-service/src/habit-data-rights-authority-replay.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + HabitDataRightsAuthorityReplayError, + PostgresHabitDataRightsAuthorityReplayGuard, +} from './habit-data-rights-authority-replay'; +import type { + HabitSqlClient, + HabitSqlQueryResult, +} from './postgres-habit-repository'; + +const DIGEST = 'a'.repeat(64); +const EXPIRES_AT = '2026-08-12T00:01:00.000Z'; + +class FakeSqlClient implements HabitSqlClient { + readonly query = vi.fn(); + + async queryTyped( + text: string, + values: readonly unknown[], + ): Promise> { + return (await this.query(text, values)) as HabitSqlQueryResult; + } +} + +function clientWith(rows: readonly unknown[]): HabitSqlClient & { + query: ReturnType; +} { + const query = vi + .fn() + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows }); + return { query } as unknown as HabitSqlClient & { + query: ReturnType; + }; +} + +describe('PostgresHabitDataRightsAuthorityReplayGuard', () => { + it('uses database time and fixed parameterized SQL to atomically consume the first digest', async () => { + const client = clientWith([{ evidence_digest: DIGEST }]); + const guard = new PostgresHabitDataRightsAuthorityReplayGuard(client); + + await expect( + guard.consume({ evidenceDigest: DIGEST, expiresAt: EXPIRES_AT }), + ).resolves.toBe(true); + expect(client.query).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('WHERE expires_at < now()'), + [], + ); + expect(client.query).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('ON CONFLICT (evidence_digest) DO NOTHING'), + [DIGEST, EXPIRES_AT], + ); + expect(client.query.mock.calls[1]?.[0]).toContain( + 'WHERE $2::timestamptz >= now()', + ); + }); + + it('returns false when the digest already exists or database time says it expired', async () => { + const guard = new PostgresHabitDataRightsAuthorityReplayGuard(clientWith([])); + await expect( + guard.consume({ evidenceDigest: DIGEST, expiresAt: EXPIRES_AT }), + ).resolves.toBe(false); + }); + + it('rejects malformed caller evidence before any SQL authority is invoked', async () => { + const query = vi.fn(); + const client = { query } as unknown as HabitSqlClient; + const guard = new PostgresHabitDataRightsAuthorityReplayGuard(client); + + for (const evidence of [ + { evidenceDigest: 'not-a-digest', expiresAt: EXPIRES_AT }, + { evidenceDigest: DIGEST, expiresAt: '2026-08-12' }, + ]) { + await expect(guard.consume(evidence)).rejects.toBeInstanceOf( + HabitDataRightsAuthorityReplayError, + ); + } + expect(query).not.toHaveBeenCalled(); + }); + + it('rejects ambiguous or corrupted INSERT evidence instead of granting authority', async () => { + for (const rows of [ + [{ evidence_digest: 'b'.repeat(64) }], + [{ evidence_digest: DIGEST }, { evidence_digest: DIGEST }], + [{ evidence_digest: null }], + ]) { + const guard = new PostgresHabitDataRightsAuthorityReplayGuard(clientWith(rows)); + await expect( + guard.consume({ evidenceDigest: DIGEST, expiresAt: EXPIRES_AT }), + ).rejects.toBeInstanceOf(HabitDataRightsAuthorityReplayError); + } + }); +}); diff --git a/apps/habit-service/src/habit-data-rights-authority-replay.ts b/apps/habit-service/src/habit-data-rights-authority-replay.ts new file mode 100644 index 00000000..bcb7712a --- /dev/null +++ b/apps/habit-service/src/habit-data-rights-authority-replay.ts @@ -0,0 +1,105 @@ +import type { HabitSqlClient } from './postgres-habit-repository'; + +const SHA_256_PATTERN = /^[0-9a-f]{64}$/u; +const ISO_INSTANT_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u; + +/** One credential-free digest identifying a single destructive signed authority. */ +export interface HabitDataRightsAuthorityReplayEvidence { + readonly evidenceDigest: string; + readonly expiresAt: string; +} + +/** Habit-owned persistence authority that atomically consumes destructive request evidence once. */ +export interface HabitDataRightsAuthorityReplayGuardPort { + /** Returns true only for the first still-live durable consumption of this evidence digest. */ + consume(evidence: HabitDataRightsAuthorityReplayEvidence): Promise; +} + +interface ReplayEvidenceRow { + readonly evidence_digest: unknown; +} + +/** Bounded failure for malformed replay evidence or ambiguous persistence results. */ +export class HabitDataRightsAuthorityReplayError extends Error { + /** Creates a credential-free replay-store failure. */ + constructor() { + super('Habit data-rights replay evidence is invalid'); + this.name = 'HabitDataRightsAuthorityReplayError'; + } +} + +/** Rejects malformed replay evidence without reflecting caller-controlled values. */ +function invalid(): never { + throw new HabitDataRightsAuthorityReplayError(); +} + +/** Requires one lowercase SHA-256 digest so raw short-lived signatures never enter persistence. */ +function requireDigest(value: unknown): string { + if (typeof value !== 'string' || !SHA_256_PATTERN.test(value)) { + return invalid(); + } + return value; +} + +/** Requires a canonical UTC millisecond instant for the replay-retention deadline. */ +function requireInstant(value: unknown): string { + if (typeof value !== 'string' || !ISO_INSTANT_PATTERN.test(value)) { + return invalid(); + } + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime()) || parsed.toISOString() !== value) { + return invalid(); + } + return value; +} + +/** + * PostgreSQL compare-and-set guard for destructive Habit data-rights authority. + * + * The table primary key makes the first still-live evidence digest the sole winner + * across service replicas. Raw signatures are never stored. PostgreSQL `now()` is + * authoritative for both pruning and the insertion lifetime check, preventing an + * application-clock lag from deleting and re-accepting an already expired proof. + */ +export class PostgresHabitDataRightsAuthorityReplayGuard + implements HabitDataRightsAuthorityReplayGuardPort +{ + /** Creates the guard over the Habit service's bounded parameterized SQL client. */ + constructor(private readonly client: HabitSqlClient) {} + + /** Atomically consumes one validated digest, returning false for replay or expiry. */ + async consume( + evidence: HabitDataRightsAuthorityReplayEvidence, + ): Promise { + const evidenceDigest = requireDigest(evidence.evidenceDigest); + const expiresAt = requireInstant(evidence.expiresAt); + + await this.client.query( + `DELETE FROM habit.data_rights_authority_replay_records + WHERE expires_at < now()`, + [], + ); + const inserted = await this.client.query( + `INSERT INTO habit.data_rights_authority_replay_records ( + evidence_digest, expires_at + ) + SELECT $1, $2::timestamptz + WHERE $2::timestamptz >= now() + ON CONFLICT (evidence_digest) DO NOTHING + RETURNING evidence_digest`, + [evidenceDigest, expiresAt], + ); + + if (inserted.rows.length === 0) { + return false; + } + if ( + inserted.rows.length !== 1 || + requireDigest(inserted.rows[0]?.evidence_digest) !== evidenceDigest + ) { + return invalid(); + } + return true; + } +} diff --git a/apps/habit-service/src/habit-data-rights-http-boundary.test.ts b/apps/habit-service/src/habit-data-rights-http-boundary.test.ts index de04fe95..747e6b07 100644 --- a/apps/habit-service/src/habit-data-rights-http-boundary.test.ts +++ b/apps/habit-service/src/habit-data-rights-http-boundary.test.ts @@ -1,6 +1,6 @@ -import { createHmac, randomBytes } from 'node:crypto'; +import { createHash, createHmac, randomBytes } from 'node:crypto'; import { HttpException } from '@nestjs/common'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { parseTrustedHabitDataRightsRequest, type HabitDataRightsRequestBinding, @@ -24,6 +24,11 @@ const EXPORT_REQUEST = { requestedByUserId: USER_ID, requestId: REQUEST_ID, } as const; +const ERASE_REQUEST = { + ...EXPORT_REQUEST, + operation: 'erase', + idempotencyKey: IDEMPOTENCY_KEY, +} as const; function sign( request: Readonly>, @@ -52,10 +57,13 @@ function sign( .digest('base64url'); } -function expectHttpStatus(operation: () => unknown, status: number): void { +async function expectHttpStatus( + operation: () => Promise, + status: number, +): Promise { let thrown: unknown; try { - operation(); + await operation(); } catch (error) { thrown = error; } @@ -67,9 +75,9 @@ function expectHttpStatus(operation: () => unknown, status: number): void { } describe('Habit data-rights trusted HTTP boundary', () => { - it('accepts an exact short-lived service-authenticated export request', () => { + it('accepts an exact short-lived service-authenticated export request', async () => { const issuedAt = String(NOW_SECONDS); - expect( + await expect( parseTrustedHabitDataRightsRequest( EXPORT_REQUEST, { issuedAt, signature: sign(EXPORT_REQUEST, BINDING, issuedAt) }, @@ -77,56 +85,118 @@ describe('Habit data-rights trusted HTTP boundary', () => { BINDING, NOW_SECONDS, ), - ).toEqual(EXPORT_REQUEST); + ).resolves.toEqual(EXPORT_REQUEST); }); - it('rejects replay of an export signature onto an erase request', () => { - const eraseRequest = { - ...EXPORT_REQUEST, - operation: 'erase', - idempotencyKey: IDEMPOTENCY_KEY, - } as const; + it('atomically consumes valid destructive authority and rejects an already consumed proof', async () => { + const issuedAt = String(NOW_SECONDS - 30); + const signature = sign(ERASE_REQUEST, BINDING, issuedAt); + const consume = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const replayGuard = { consume }; + + await expect( + parseTrustedHabitDataRightsRequest( + ERASE_REQUEST, + { issuedAt, signature }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + replayGuard, + ), + ).resolves.toEqual(ERASE_REQUEST); + expect(consume).toHaveBeenCalledWith({ + evidenceDigest: createHash('sha256') + .update(signature, 'ascii') + .digest('hex'), + expiresAt: new Date((NOW_SECONDS + 30) * 1_000).toISOString(), + }); + + await expectHttpStatus( + () => + parseTrustedHabitDataRightsRequest( + ERASE_REQUEST, + { issuedAt, signature }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + replayGuard, + ), + 401, + ); + expect(consume).toHaveBeenCalledTimes(2); + }); + + it('fails closed when destructive replay persistence is absent or unavailable', async () => { const issuedAt = String(NOW_SECONDS); + const signature = sign(ERASE_REQUEST, BINDING, issuedAt); - expectHttpStatus( + await expectHttpStatus( () => parseTrustedHabitDataRightsRequest( - eraseRequest, + ERASE_REQUEST, + { issuedAt, signature }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + ), + 503, + ); + await expectHttpStatus( + () => + parseTrustedHabitDataRightsRequest( + ERASE_REQUEST, + { issuedAt, signature }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + { consume: vi.fn().mockRejectedValue(new Error('database detail')) }, + ), + 503, + ); + }); + + it('rejects replay of an export signature onto an erase request', async () => { + const issuedAt = String(NOW_SECONDS); + + await expectHttpStatus( + () => + parseTrustedHabitDataRightsRequest( + ERASE_REQUEST, { issuedAt, signature: sign(EXPORT_REQUEST, BINDING, issuedAt) }, CONTEXT_SECRET, BINDING, NOW_SECONDS, + { consume: vi.fn().mockResolvedValue(true) }, ), 401, ); }); - it('binds destructive authority to the exact idempotency key', () => { - const eraseRequest = { - ...EXPORT_REQUEST, - operation: 'erase', - idempotencyKey: IDEMPOTENCY_KEY, - } as const; + it('binds destructive authority to the exact idempotency key', async () => { const tamperedRequest = { - ...eraseRequest, + ...ERASE_REQUEST, idempotencyKey: '55555555-5555-4555-8555-555555555555', } as const; const issuedAt = String(NOW_SECONDS); - expectHttpStatus( + await expectHttpStatus( () => parseTrustedHabitDataRightsRequest( tamperedRequest, - { issuedAt, signature: sign(eraseRequest, BINDING, issuedAt) }, + { issuedAt, signature: sign(ERASE_REQUEST, BINDING, issuedAt) }, CONTEXT_SECRET, BINDING, NOW_SECONDS, + { consume: vi.fn().mockResolvedValue(true) }, ), 401, ); }); - it('rejects a valid signature replayed to another method or path', () => { + it('rejects a valid signature replayed to another method or path', async () => { const issuedAt = String(NOW_SECONDS); const signature = sign(EXPORT_REQUEST, BINDING, issuedAt); @@ -134,7 +204,7 @@ describe('Habit data-rights trusted HTTP boundary', () => { { method: 'GET', path: BINDING.path }, { method: 'POST', path: '/v1/internal/data-rights/other' }, ]) { - expectHttpStatus( + await expectHttpStatus( () => parseTrustedHabitDataRightsRequest( EXPORT_REQUEST, @@ -148,11 +218,11 @@ describe('Habit data-rights trusted HTTP boundary', () => { } }); - it('rejects stale authority, malformed UUIDs, extra fields, and missing secrets', () => { + it('rejects stale authority, malformed UUIDs, extra fields, and missing secrets', async () => { const issuedAt = String(NOW_SECONDS); const signature = sign(EXPORT_REQUEST, BINDING, issuedAt); - expectHttpStatus( + await expectHttpStatus( () => parseTrustedHabitDataRightsRequest( EXPORT_REQUEST, @@ -163,7 +233,7 @@ describe('Habit data-rights trusted HTTP boundary', () => { ), 401, ); - expectHttpStatus( + await expectHttpStatus( () => parseTrustedHabitDataRightsRequest( { ...EXPORT_REQUEST, workspaceId: 'workspace-one' }, @@ -174,7 +244,7 @@ describe('Habit data-rights trusted HTTP boundary', () => { ), 400, ); - expectHttpStatus( + await expectHttpStatus( () => parseTrustedHabitDataRightsRequest( { ...EXPORT_REQUEST, unexpected: true }, @@ -185,7 +255,7 @@ describe('Habit data-rights trusted HTTP boundary', () => { ), 400, ); - expectHttpStatus( + await expectHttpStatus( () => parseTrustedHabitDataRightsRequest( EXPORT_REQUEST, diff --git a/apps/habit-service/src/habit-data-rights-http-boundary.ts b/apps/habit-service/src/habit-data-rights-http-boundary.ts index 70f75ed0..6981a2ef 100644 --- a/apps/habit-service/src/habit-data-rights-http-boundary.ts +++ b/apps/habit-service/src/habit-data-rights-http-boundary.ts @@ -1,5 +1,6 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; import { HttpException } from '@nestjs/common'; +import type { HabitDataRightsAuthorityReplayGuardPort } from './habit-data-rights-authority-replay'; import { DATA_RIGHTS_CONTRIBUTOR_CONTRACT_VERSION, type HabitDataRightsRequest, @@ -17,6 +18,7 @@ export interface HabitDataRightsRequestBinding { readonly path: unknown; } +/** Credential-free RFC 7807-style problem shape exposed by the private contributor transport. */ interface HabitDataRightsProblemDetails { readonly type: 'about:blank'; readonly title: string; @@ -33,6 +35,7 @@ const MINIMUM_CONTEXT_SECRET_BYTES = 32; const MAXIMUM_CONTEXT_AGE_SECONDS = 60; const MAXIMUM_FUTURE_SKEW_SECONDS = 5; +/** Canonical request with UUIDv4 tenant, actor, request, and destructive replay fields normalized to lowercase. */ type NormalizedRequest = HabitDataRightsRequest & Readonly<{ workspaceId: string; @@ -40,6 +43,7 @@ type NormalizedRequest = HabitDataRightsRequest & requestId: string; }>; +/** Builds one bounded problem without reflecting untrusted request or dependency detail. */ function problemException( status: number, title: string, @@ -54,6 +58,7 @@ function problemException( return new HttpException(problem, status); } +/** Rejects malformed data-rights schema or identifier input as an HTTP 400 problem. */ function invalidRequest(): never { throw problemException( 400, @@ -62,6 +67,7 @@ function invalidRequest(): never { ); } +/** Rejects forged, replayed, stale, or route-mismatched authority as an HTTP 401 problem. */ function invalidContext(): never { throw problemException( 401, @@ -70,6 +76,7 @@ function invalidContext(): never { ); } +/** Rejects unavailable secret or replay-store authority as a credential-free HTTP 503 problem. */ function unavailableContext(): never { throw problemException( 503, @@ -78,6 +85,7 @@ function unavailableContext(): never { ); } +/** Requires a plain JSON object before any caller field can influence authority. */ function requireRecord(value: unknown): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return invalidRequest(); @@ -85,6 +93,7 @@ function requireRecord(value: unknown): Record { return value as Record; } +/** Requires an exact operation-specific field set so undeclared fields cannot alter downstream meaning. */ function requireExactKeys( record: Record, expectedKeys: readonly string[], @@ -99,6 +108,7 @@ function requireExactKeys( } } +/** Requires and canonicalizes one opaque UUIDv4 product identity. */ function requireUuidV4(value: unknown): string { if (typeof value !== 'string' || !UUID_V4_PATTERN.test(value)) { return invalidRequest(); @@ -106,6 +116,7 @@ function requireUuidV4(value: unknown): string { return value.toLowerCase(); } +/** Normalizes exactly the v1 contributor request schema and rejects all other shapes. */ function normalizeRequest(body: unknown): NormalizedRequest { const request = requireRecord(body); const commonKeys = [ @@ -151,6 +162,7 @@ function normalizeRequest(body: unknown): NormalizedRequest { }; } +/** Requires the single POST route that owns the v1 Habit contributor transport. */ function requireRequestBinding( binding: HabitDataRightsRequestBinding, ): { readonly method: 'POST'; readonly path: typeof CONTRIBUTOR_PATH } { @@ -160,6 +172,11 @@ function requireRequestBinding( return { method: 'POST', path: CONTRIBUTOR_PATH }; } +/** + * Computes the canonical HMAC over contract, tenant, actor, request, operation, + * destructive idempotency key (or `-`), issuance time, method, and exact path in + * that order. Any change to one field produces a different authority proof. + */ function requestDigest( request: NormalizedRequest, issuedAt: string, @@ -187,18 +204,40 @@ function requestDigest( .digest(); } +/** Derives a credential-free replay identity from one already-canonical validated signature. */ +function replayDigest(signature: string): string { + return createHash('sha256').update(signature, 'ascii').digest('hex'); +} + +/** Converts the signed issuance time into the exact end of the 60-second authority lifetime. */ +function replayExpiresAt(issuedAtSeconds: number): string { + const expiresAt = new Date( + (issuedAtSeconds + MAXIMUM_CONTEXT_AGE_SECONDS) * 1_000, + ); + if (!Number.isFinite(expiresAt.getTime())) { + return unavailableContext(); + } + return expiresAt.toISOString(); +} + /** - * Parses one exact contributor request and verifies short-lived service authority. - * The HMAC binds tenant, actor, request, purpose/operation, destructive replay key, - * lifetime, HTTP method, and resource so credentials cannot authorize another call. + * Parses one exact contributor request, verifies short-lived service authority, + * and atomically consumes destructive `erase` evidence before persistence can run. + * + * The HMAC binds tenant, actor, request, purpose/operation, destructive idempotency + * key, lifetime, HTTP method, and resource. Only the SHA-256 digest of a validated + * signature is persisted for replay control; the short-lived signature itself is + * never stored. Non-destructive operations remain replay-safe domain reads/checks + * and do not consume the destructive replay store. */ -export function parseTrustedHabitDataRightsRequest( +export async function parseTrustedHabitDataRightsRequest( body: unknown, headers: TrustedHabitDataRightsContextHeaders, secret: unknown, requestBinding: HabitDataRightsRequestBinding, nowSeconds = Math.floor(Date.now() / 1000), -): HabitDataRightsRequest { + replayGuard?: HabitDataRightsAuthorityReplayGuardPort, +): Promise { const request = normalizeRequest(body); if ( typeof secret !== 'string' || @@ -236,14 +275,30 @@ export function parseTrustedHabitDataRightsRequest( ) { return invalidContext(); } + + if (request.operation === 'erase') { + if (!replayGuard) { + return unavailableContext(); + } + let consumed: boolean; + try { + consumed = await replayGuard.consume({ + evidenceDigest: replayDigest(headers.signature), + expiresAt: replayExpiresAt(issuedAtSeconds), + }); + } catch { + return unavailableContext(); + } + if (!consumed) { + return invalidContext(); + } + } return request; } -/** Maps contributor/runtime failures to a bounded credential-free service error. */ +/** Maps every contributor/runtime failure to one bounded credential-free service error. */ export function toHabitDataRightsHttpException(error: unknown): HttpException { - if (error instanceof HttpException) { - return error; - } + void error; return problemException( 503, 'Habit data-rights operation is unavailable', diff --git a/apps/habit-service/src/habit-runtime.ts b/apps/habit-service/src/habit-runtime.ts index 2575728f..227f234b 100644 --- a/apps/habit-service/src/habit-runtime.ts +++ b/apps/habit-service/src/habit-runtime.ts @@ -1,5 +1,6 @@ import type { OnApplicationShutdown } from '@nestjs/common'; import { Pool, type PoolClient, type PoolConfig } from 'pg'; +import { PostgresHabitDataRightsAuthorityReplayGuard } from './habit-data-rights-authority-replay'; import { HabitDataRightsContributor, type HabitTransactionalSqlClient, @@ -204,6 +205,8 @@ export class HabitRuntime implements OnApplicationShutdown { readonly service: HabitService, /** Service-owned export/erasure participant consumed by Identity orchestration. */ readonly dataRightsContributor: HabitDataRightsContributor, + /** Durable single-consumption authority for destructive internal data-rights requests. */ + readonly dataRightsAuthorityReplayGuard: PostgresHabitDataRightsAuthorityReplayGuard, ) {} async close(): Promise { @@ -231,5 +234,6 @@ export function createHabitRuntime( pool, new HabitService(repository), new HabitDataRightsContributor(sqlClient), + new PostgresHabitDataRightsAuthorityReplayGuard(sqlClient), ); } diff --git a/apps/habit-service/src/main.ts b/apps/habit-service/src/main.ts index be30beb4..2fb7fcf0 100644 --- a/apps/habit-service/src/main.ts +++ b/apps/habit-service/src/main.ts @@ -189,16 +189,18 @@ export class HabitDataRightsController { @Headers('x-life-os-data-rights-signature') signature: string | undefined, @Body() body: unknown, ): Promise { + const request = await parseTrustedHabitDataRightsRequest( + body, + { issuedAt, signature }, + process.env.HABIT_DATA_RIGHTS_CONTEXT_SECRET, + { + method: 'POST', + path: '/v1/internal/data-rights/contributor', + }, + Math.floor(Date.now() / 1000), + this.runtime.dataRightsAuthorityReplayGuard, + ); try { - const request = parseTrustedHabitDataRightsRequest( - body, - { issuedAt, signature }, - process.env.HABIT_DATA_RIGHTS_CONTEXT_SECRET, - { - method: 'POST', - path: '/v1/internal/data-rights/contributor', - }, - ); return await this.runtime.dataRightsContributor.handle(request); } catch (error) { throw toHabitDataRightsHttpException(error); From d6d193359b0f9a500825d6caaed45e18db33ebca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 05:00:45 +0900 Subject: [PATCH 09/13] test(habit): remove unused replay test scaffold --- .../habit-data-rights-authority-replay.test.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/apps/habit-service/src/habit-data-rights-authority-replay.test.ts b/apps/habit-service/src/habit-data-rights-authority-replay.test.ts index c47a6ab0..80d8e19f 100644 --- a/apps/habit-service/src/habit-data-rights-authority-replay.test.ts +++ b/apps/habit-service/src/habit-data-rights-authority-replay.test.ts @@ -3,25 +3,11 @@ import { HabitDataRightsAuthorityReplayError, PostgresHabitDataRightsAuthorityReplayGuard, } from './habit-data-rights-authority-replay'; -import type { - HabitSqlClient, - HabitSqlQueryResult, -} from './postgres-habit-repository'; +import type { HabitSqlClient } from './postgres-habit-repository'; const DIGEST = 'a'.repeat(64); const EXPIRES_AT = '2026-08-12T00:01:00.000Z'; -class FakeSqlClient implements HabitSqlClient { - readonly query = vi.fn(); - - async queryTyped( - text: string, - values: readonly unknown[], - ): Promise> { - return (await this.query(text, values)) as HabitSqlQueryResult; - } -} - function clientWith(rows: readonly unknown[]): HabitSqlClient & { query: ReturnType; } { From 47315325e78912f84763cffa2848a67e84661ecc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:22:49 +0900 Subject: [PATCH 10/13] test(habit): derive replay digest from signed evidence --- .../habit-data-rights-authority-replay.integration.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/habit-service/src/habit-data-rights-authority-replay.integration.test.ts b/apps/habit-service/src/habit-data-rights-authority-replay.integration.test.ts index 1dd10f72..1c47bf37 100644 --- a/apps/habit-service/src/habit-data-rights-authority-replay.integration.test.ts +++ b/apps/habit-service/src/habit-data-rights-authority-replay.integration.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { Pool } from 'pg'; @@ -69,7 +70,9 @@ describeWithPostgres('Habit data-rights authority replay PostgreSQL integration' new PoolSqlClient(administrativePool), ); const rawSignature = 'sensitive-short-lived-proof'; - const evidenceDigest = 'a'.repeat(64); + const evidenceDigest = createHash('sha256') + .update(rawSignature, 'ascii') + .digest('hex'); const expiresAt = new Date(Date.now() + 60_000).toISOString(); const results = await Promise.all([ From 9833ce7219ab498047a20d52eac2978e872cff2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:23:31 +0900 Subject: [PATCH 11/13] refactor(habit): depend on replay guard port --- apps/habit-service/src/habit-runtime.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/habit-service/src/habit-runtime.ts b/apps/habit-service/src/habit-runtime.ts index 227f234b..99a3c1cd 100644 --- a/apps/habit-service/src/habit-runtime.ts +++ b/apps/habit-service/src/habit-runtime.ts @@ -1,6 +1,9 @@ import type { OnApplicationShutdown } from '@nestjs/common'; import { Pool, type PoolClient, type PoolConfig } from 'pg'; -import { PostgresHabitDataRightsAuthorityReplayGuard } from './habit-data-rights-authority-replay'; +import { + PostgresHabitDataRightsAuthorityReplayGuard, + type HabitDataRightsAuthorityReplayGuardPort, +} from './habit-data-rights-authority-replay'; import { HabitDataRightsContributor, type HabitTransactionalSqlClient, @@ -206,7 +209,7 @@ export class HabitRuntime implements OnApplicationShutdown { /** Service-owned export/erasure participant consumed by Identity orchestration. */ readonly dataRightsContributor: HabitDataRightsContributor, /** Durable single-consumption authority for destructive internal data-rights requests. */ - readonly dataRightsAuthorityReplayGuard: PostgresHabitDataRightsAuthorityReplayGuard, + readonly dataRightsAuthorityReplayGuard: HabitDataRightsAuthorityReplayGuardPort, ) {} async close(): Promise { From dfa273d034a2ecb4002521cde49027eed2c33cf9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:52:24 +0900 Subject: [PATCH 12/13] test(habit): prove replay store failures stay sanitized --- .../habit-data-rights-http-boundary.test.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/habit-service/src/habit-data-rights-http-boundary.test.ts b/apps/habit-service/src/habit-data-rights-http-boundary.test.ts index 747e6b07..fe63a411 100644 --- a/apps/habit-service/src/habit-data-rights-http-boundary.test.ts +++ b/apps/habit-service/src/habit-data-rights-http-boundary.test.ts @@ -144,17 +144,24 @@ describe('Habit data-rights trusted HTTP boundary', () => { ), 503, ); - await expectHttpStatus( - () => - parseTrustedHabitDataRightsRequest( - ERASE_REQUEST, - { issuedAt, signature }, - CONTEXT_SECRET, - BINDING, - NOW_SECONDS, - { consume: vi.fn().mockRejectedValue(new Error('database detail')) }, - ), - 503, + + let thrown: unknown; + try { + await parseTrustedHabitDataRightsRequest( + ERASE_REQUEST, + { issuedAt, signature }, + CONTEXT_SECRET, + BINDING, + NOW_SECONDS, + { consume: vi.fn().mockRejectedValue(new Error('database detail')) }, + ); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HttpException); + expect((thrown as HttpException).getStatus()).toBe(503); + expect(JSON.stringify((thrown as HttpException).getResponse())).not.toContain( + 'database detail', ); }); From 4b239c312583d288cc60e02bb263ed8b39154856 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 06:52:45 +0900 Subject: [PATCH 13/13] test(habit): cover non-canonical replay expiry instants --- .../habit-service/src/habit-data-rights-authority-replay.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/habit-service/src/habit-data-rights-authority-replay.test.ts b/apps/habit-service/src/habit-data-rights-authority-replay.test.ts index 80d8e19f..797efd5c 100644 --- a/apps/habit-service/src/habit-data-rights-authority-replay.test.ts +++ b/apps/habit-service/src/habit-data-rights-authority-replay.test.ts @@ -58,6 +58,7 @@ describe('PostgresHabitDataRightsAuthorityReplayGuard', () => { for (const evidence of [ { evidenceDigest: 'not-a-digest', expiresAt: EXPIRES_AT }, { evidenceDigest: DIGEST, expiresAt: '2026-08-12' }, + { evidenceDigest: DIGEST, expiresAt: '2026-02-30T00:00:00.000Z' }, ]) { await expect(guard.consume(evidence)).rejects.toBeInstanceOf( HabitDataRightsAuthorityReplayError,