From ed27081d8f766f2f10ac66abad08c9ead7121897 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 23:50:14 +0900 Subject: [PATCH 01/39] feat(ai): add bounded gateway keyring configuration --- apps/ai-service/src/ai-gateway-keyring.ts | 119 ++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 apps/ai-service/src/ai-gateway-keyring.ts diff --git a/apps/ai-service/src/ai-gateway-keyring.ts b/apps/ai-service/src/ai-gateway-keyring.ts new file mode 100644 index 000000000..0ea724d58 --- /dev/null +++ b/apps/ai-service/src/ai-gateway-keyring.ts @@ -0,0 +1,119 @@ +/** One explicitly identified HMAC key used at the private gateway boundary. */ +export interface AiGatewayContextKey { + readonly keyId: string; + readonly secret: string; +} + +/** One active key and an optional previous verification-only overlap key. */ +export interface AiGatewayContextKeyRing { + readonly active: AiGatewayContextKey; + readonly previous?: AiGatewayContextKey; +} + +/** Environment surface required to configure bounded gateway key rotation. */ +export interface AiGatewayContextKeyEnvironment { + readonly AI_GATEWAY_ACTIVE_KEY_ID?: string; + readonly AI_GATEWAY_ACTIVE_KEY_SECRET?: string; + readonly AI_GATEWAY_PREVIOUS_KEY_ID?: string; + readonly AI_GATEWAY_PREVIOUS_KEY_SECRET?: string; +} + +/** Sanitized configuration failure that never retains key material. */ +export class AiGatewayKeyConfigurationError extends Error { + constructor() { + super('AI gateway key configuration is invalid'); + this.name = 'AiGatewayKeyConfigurationError'; + } +} + +/** Sanitized request failure for malformed, unknown, or retired key identifiers. */ +export class AiGatewayKeySelectionError extends Error { + constructor() { + super('AI gateway key identifier is invalid'); + this.name = 'AiGatewayKeySelectionError'; + } +} + +const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const MINIMUM_SECRET_BYTES = 32; +const MAXIMUM_SECRET_BYTES = 4096; + +/** Requires one bounded case-sensitive opaque identifier safe for header lookup. */ +export function requireAiGatewayKeyId(value: unknown): string { + if (typeof value !== 'string' || !KEY_ID_PATTERN.test(value)) { + throw new AiGatewayKeyConfigurationError(); + } + return value; +} + +/** Requires independently generated bounded HMAC key material. */ +export function requireAiGatewayKeySecret(value: unknown): string { + if (typeof value !== 'string') { + throw new AiGatewayKeyConfigurationError(); + } + const byteLength = Buffer.byteLength(value, 'utf8'); + if ( + byteLength < MINIMUM_SECRET_BYTES || + byteLength > MAXIMUM_SECRET_BYTES || + /[\r\n\u0000]/u.test(value) + ) { + throw new AiGatewayKeyConfigurationError(); + } + return value; +} + +/** Creates one immutable key after validating identifier and secret independently. */ +function contextKey(keyId: unknown, secret: unknown): AiGatewayContextKey { + return Object.freeze({ + keyId: requireAiGatewayKeyId(keyId), + secret: requireAiGatewayKeySecret(secret), + }); +} + +/** + * Parses exactly one active signing key and at most one complete previous key. + * Partial or duplicate overlap configuration fails closed before verification. + */ +export function requireAiGatewayContextKeyRing( + environment: AiGatewayContextKeyEnvironment, +): AiGatewayContextKeyRing { + const active = contextKey( + environment.AI_GATEWAY_ACTIVE_KEY_ID, + environment.AI_GATEWAY_ACTIVE_KEY_SECRET, + ); + const previousId = environment.AI_GATEWAY_PREVIOUS_KEY_ID; + const previousSecret = environment.AI_GATEWAY_PREVIOUS_KEY_SECRET; + const hasPreviousId = previousId !== undefined; + const hasPreviousSecret = previousSecret !== undefined; + if (hasPreviousId !== hasPreviousSecret) { + throw new AiGatewayKeyConfigurationError(); + } + if (!hasPreviousId) { + return Object.freeze({ active }); + } + const previous = contextKey(previousId, previousSecret); + if (previous.keyId === active.keyId) { + throw new AiGatewayKeyConfigurationError(); + } + return Object.freeze({ active, previous }); +} + +/** + * Selects exactly the explicitly identified configured key without trial + * verification against unrelated keys. + */ +export function selectAiGatewayVerificationKey( + keyRing: AiGatewayContextKeyRing, + keyIdValue: unknown, +): AiGatewayContextKey { + if (typeof keyIdValue !== 'string' || !KEY_ID_PATTERN.test(keyIdValue)) { + throw new AiGatewayKeySelectionError(); + } + if (keyIdValue === keyRing.active.keyId) { + return keyRing.active; + } + if (keyRing.previous && keyIdValue === keyRing.previous.keyId) { + return keyRing.previous; + } + throw new AiGatewayKeySelectionError(); +} From 7b9ae10fd8eaac3b7c615aa8f22a247229166de3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 23:50:52 +0900 Subject: [PATCH 02/39] test(ai): cover gateway keyring rotation contracts --- .../ai-service/src/ai-gateway-keyring.test.ts | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 apps/ai-service/src/ai-gateway-keyring.test.ts diff --git a/apps/ai-service/src/ai-gateway-keyring.test.ts b/apps/ai-service/src/ai-gateway-keyring.test.ts new file mode 100644 index 000000000..b40106518 --- /dev/null +++ b/apps/ai-service/src/ai-gateway-keyring.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { + AiGatewayKeyConfigurationError, + AiGatewayKeySelectionError, + requireAiGatewayContextKeyRing, + requireAiGatewayKeyId, + requireAiGatewayKeySecret, + selectAiGatewayVerificationKey, +} from './ai-gateway-keyring'; + +const ACTIVE_ID = 'gateway-2026-08-a'; +const PREVIOUS_ID = 'gateway-2026-07-z'; +const ACTIVE_SECRET = Buffer.alloc(32, 0x41).toString('base64url'); +const PREVIOUS_SECRET = Buffer.alloc(32, 0x42).toString('base64url'); + +/** Creates one complete active-only environment. */ +function activeEnvironment(): { + AI_GATEWAY_ACTIVE_KEY_ID: string; + AI_GATEWAY_ACTIVE_KEY_SECRET: string; +} { + return { + AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_ID, + AI_GATEWAY_ACTIVE_KEY_SECRET: ACTIVE_SECRET, + }; +} + +describe('AI gateway key configuration', () => { + it.each(['a', 'A_1', 'release.2026-08', 'gateway-key']) ( + 'accepts bounded opaque key identifier %s', + (keyId) => { + expect(requireAiGatewayKeyId(keyId)).toBe(keyId); + }, + ); + + it.each([ + undefined, + null, + '', + '-leading', + 'white space', + 'a'.repeat(65), + 'line\nbreak', + ])('rejects malformed key identifier %#', (value) => { + expect(() => requireAiGatewayKeyId(value)).toThrow( + AiGatewayKeyConfigurationError, + ); + }); + + it('accepts bounded independently generated secret material', () => { + expect(requireAiGatewayKeySecret(ACTIVE_SECRET)).toBe(ACTIVE_SECRET); + expect(requireAiGatewayKeySecret('é'.repeat(16))).toBe('é'.repeat(16)); + }); + + it.each([ + undefined, + null, + '', + 'short', + 'x'.repeat(4097), + `x${String.fromCharCode(0)}${'y'.repeat(31)}`, + `${'x'.repeat(31)}\n`, + `${'x'.repeat(31)}\r`, + ])('rejects invalid secret material without echoing it: %#', (value) => { + let failure: unknown; + try { + requireAiGatewayKeySecret(value); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(AiGatewayKeyConfigurationError); + expect(String(failure)).not.toContain(String(value)); + }); + + it('creates an immutable active-only keyring', () => { + const keyRing = requireAiGatewayContextKeyRing(activeEnvironment()); + + expect(keyRing).toEqual({ + active: { keyId: ACTIVE_ID, secret: ACTIVE_SECRET }, + }); + expect(Object.isFrozen(keyRing)).toBe(true); + expect(Object.isFrozen(keyRing.active)).toBe(true); + }); + + it('creates one immutable active and previous overlap keyring', () => { + const keyRing = requireAiGatewayContextKeyRing({ + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_ID: PREVIOUS_ID, + AI_GATEWAY_PREVIOUS_KEY_SECRET: PREVIOUS_SECRET, + }); + + expect(keyRing.previous).toEqual({ + keyId: PREVIOUS_ID, + secret: PREVIOUS_SECRET, + }); + expect(Object.isFrozen(keyRing.previous)).toBe(true); + }); + + it.each([ + { + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_ID: PREVIOUS_ID, + }, + { + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_SECRET: PREVIOUS_SECRET, + }, + { + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_ID: ACTIVE_ID, + AI_GATEWAY_PREVIOUS_KEY_SECRET: PREVIOUS_SECRET, + }, + { + AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_ID, + }, + { + AI_GATEWAY_ACTIVE_KEY_SECRET: ACTIVE_SECRET, + }, + ])('rejects incomplete or conflicting keyring configuration %#', (environment) => { + expect(() => requireAiGatewayContextKeyRing(environment)).toThrow( + AiGatewayKeyConfigurationError, + ); + }); +}); + +describe('AI gateway verification key selection', () => { + const activeOnly = requireAiGatewayContextKeyRing(activeEnvironment()); + const overlap = requireAiGatewayContextKeyRing({ + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_ID: PREVIOUS_ID, + AI_GATEWAY_PREVIOUS_KEY_SECRET: PREVIOUS_SECRET, + }); + + it('selects only the exact active key identifier', () => { + expect(selectAiGatewayVerificationKey(activeOnly, ACTIVE_ID)).toBe( + activeOnly.active, + ); + expect(() => + selectAiGatewayVerificationKey(activeOnly, ACTIVE_ID.toUpperCase()), + ).toThrow(AiGatewayKeySelectionError); + }); + + it('selects the explicitly identified previous overlap key', () => { + expect(selectAiGatewayVerificationKey(overlap, PREVIOUS_ID)).toBe( + overlap.previous, + ); + }); + + it.each([ + undefined, + null, + '', + '-leading', + 'unknown-key', + 'a'.repeat(65), + ])('rejects malformed, unknown, or retired identifier %#', (value) => { + let failure: unknown; + try { + selectAiGatewayVerificationKey(overlap, value); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(AiGatewayKeySelectionError); + expect(String(failure)).not.toContain(String(value)); + }); +}); From 473c54e7da738549b03c7797a16b68ba377ea5d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 23:51:35 +0900 Subject: [PATCH 03/39] docs(ai): record gateway key rotation standards --- ...08-04-ai-gateway-key-rotation-standards.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/research/2026-08-04-ai-gateway-key-rotation-standards.md diff --git a/docs/research/2026-08-04-ai-gateway-key-rotation-standards.md b/docs/research/2026-08-04-ai-gateway-key-rotation-standards.md new file mode 100644 index 000000000..fc6760a70 --- /dev/null +++ b/docs/research/2026-08-04-ai-gateway-key-rotation-standards.md @@ -0,0 +1,56 @@ +# AI gateway key rotation standards review + +Date: 2026-08-04 +Capability: `ai.auditable-proposals` +Slice: zero-downtime private gateway HMAC key rotation + +## Decision summary + +LifeOS will identify every signed private gateway context with one bounded, case-sensitive opaque key identifier. The identifier is integrity-protected by inclusion in the canonical HMAC input. The web gateway signs only with one active key. The AI service resolves exactly the explicitly identified active or previous key and never trials every configured key. + +Configuration permits exactly one active key and at most one complete previous verification key. Missing, partial, duplicate, malformed, unknown, or retired identifiers fail closed. Key material remains server-only, is generated independently, is bounded before cryptographic use, and is excluded from logs and problem responses. + +This design supports a gradual write-new/read-old rotation pattern while limiting overlap and ambiguity. The overlap key is verification-only; retirement removes it from configuration rather than leaving an unbounded historical key set. + +## Standards rationale + +### Key lifecycle and bounded overlap + +NIST SP 800-57 Part 1 Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. + +OWASP recommends automated secret rotation, gradual rotation strategies that introduce new credentials for write operations while temporarily retaining old credentials for read or verification operations, least privilege, lifecycle metadata, and immediate revocation of no-longer-required secrets. The two-slot keyring is a deliberately bounded realization of that guidance. + +### Explicit key identification + +RFC 7515 defines `kid` as a case-sensitive key identifier that allows an originator to signal which key secured a message and explicitly supports key change. RFC 8725 warns that received key identifiers must be validated before lookup so they cannot become injection primitives. LifeOS therefore uses a short ASCII allowlist and direct equality selection against two in-memory configured identifiers. The identifier is not accepted as a path, URL, database query, environment-variable name, or secret-store locator. + +### Authentication construction + +The existing boundary uses HMAC-SHA-256, constant-time comparison, a versioned canonical input, exact method and path binding, authenticated workspace and actor identifiers, and a short issuance window. Rotation extends the canonical input with the key identifier and advances the context version so keyed and unkeyed messages cannot be confused. + +## Normative implementation constraints + +1. Header name: `x-life-os-context-key-id`. +2. Identifier grammar: 1–64 case-sensitive ASCII characters; first character alphanumeric; remaining characters alphanumeric, period, underscore, or hyphen. +3. Canonical input version: `life-os.ai-context.v2`. +4. Canonical field order: version, key identifier, workspace identifier, actor identifier, issued-at seconds, uppercase method, exact canonical path. +5. Active key variables: `AI_GATEWAY_ACTIVE_KEY_ID` and `AI_GATEWAY_ACTIVE_KEY_SECRET`. +6. Optional previous variables: `AI_GATEWAY_PREVIOUS_KEY_ID` and `AI_GATEWAY_PREVIOUS_KEY_SECRET`. +7. Previous identifier and secret must either both be absent or both be present. +8. Active and previous identifiers must be distinct. +9. The gateway signs only with the active key. +10. The verifier selects one key by exact identifier equality before computing HMAC. It must not attempt all keys. +11. Missing, malformed, or unknown request identifiers return the existing credential-free invalid-context problem. +12. Invalid local key configuration returns the existing credential-free unavailable-context problem. +13. No secret value, signature, or untrusted identifier is written to logs or problem details. +14. Overlap ends by removing the previous pair after all in-flight v2 contexts signed with it have expired. + +## References + +Barker, E. (2020). *Recommendation for key management: Part 1—General* (NIST Special Publication 800-57 Part 1 Revision 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5 + +Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Signature (JWS)* (RFC 7515). Internet Engineering Task Force. https://doi.org/10.17487/RFC7515 + +Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current practices* (RFC 8725). Internet Engineering Task Force. https://doi.org/10.17487/RFC8725 + +OWASP Foundation. (n.d.). *Secrets management cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 4, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html From 0eecc5a3e82703747c12107a04b07c51f1ded323 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 23:52:06 +0900 Subject: [PATCH 04/39] docs(ai): add staged gateway key rotation runbook --- docs/operations/ai-gateway-key-rotation.md | 82 ++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/operations/ai-gateway-key-rotation.md diff --git a/docs/operations/ai-gateway-key-rotation.md b/docs/operations/ai-gateway-key-rotation.md new file mode 100644 index 000000000..2ef83c219 --- /dev/null +++ b/docs/operations/ai-gateway-key-rotation.md @@ -0,0 +1,82 @@ +# AI gateway HMAC key rotation runbook + +This runbook rotates the private web-gateway-to-AI-service context credential without rejecting valid in-flight requests. It applies after both workloads support the version 2 keyed context contract. + +## Configuration contract + +| Role | Identifier | Secret | Sign | Verify | +| --- | --- | --- | --- | --- | +| Active | `AI_GATEWAY_ACTIVE_KEY_ID` | `AI_GATEWAY_ACTIVE_KEY_SECRET` | Yes | Yes | +| Previous | `AI_GATEWAY_PREVIOUS_KEY_ID` | `AI_GATEWAY_PREVIOUS_KEY_SECRET` | No | Yes | + +The active pair is mandatory. The previous pair is optional but must be configured completely. Identifiers are case-sensitive and must be distinct. Secrets must be independently generated and delivered through the platform secret manager; never place them in Git, container images, command history, tickets, chat, or logs. + +## Planned rotation + +Assume `key-a` is active and `key-b` is newly generated. + +### 1. Prepare + +1. Generate `key-b` with a cryptographically secure random generator and at least 32 bytes of entropy. +2. Create a non-secret opaque identifier for `key-b`. +3. Record owner, environment, purpose, creation time, planned activation time, and planned retirement time in the secret inventory. +4. Confirm the maximum accepted context age and future-clock skew. The overlap must remain longer than the full request-validity window plus deployment propagation and rollback allowance. + +### 2. Expand verifier configuration + +Deploy the AI service with: + +- active: `key-a` +- previous: `key-b` + +At this stage the web gateway still signs with `key-a`. The AI service can verify either explicit identifier, but normal traffic continues to use `key-a`. Confirm health, invalid-identifier rejection, and no secret-bearing logs. + +### 3. Switch the signer + +Deploy the web gateway with: + +- active: `key-b` + +Deploy the AI service with: + +- active: `key-b` +- previous: `key-a` + +The gateway now creates only `key-b` signatures. The AI service still accepts valid in-flight `key-a` contexts during the bounded overlap. Monitor authentication failure rate, key-identifier distribution, clock skew, and deployment health without logging signatures or secret values. + +### 4. Retire the previous key + +After the maximum context lifetime, skew allowance, deployment propagation, and rollback window have elapsed: + +1. Remove the previous pair from the AI service configuration. +2. Verify that requests explicitly identifying `key-a` fail with `invalid_gateway_context`. +3. Revoke or destroy `key-a` in the secret manager according to retention policy. +4. Update the inventory with retirement and destruction evidence. + +## Emergency revocation + +When compromise is suspected, do not retain the compromised key as previous. + +1. Generate and distribute a new active key. +2. Deploy the verifier and signer as a coordinated emergency change. +3. Remove the compromised key immediately from every verifier. +4. Investigate all use of the compromised identifier using metadata-only telemetry. +5. Preserve incident evidence without recording key material or complete signatures. + +Emergency revocation may reject in-flight requests signed by the compromised key. Security takes precedence over availability in this case. + +## Rollback + +During a planned overlap, rollback the signer by restoring the previous key as active while both keys remain configured on the AI service. Do not create two active signers with different keys behind the same deployment unless the AI service retains both explicit identifiers for the entire rollout. + +## Validation checklist + +- Active and previous identifiers are distinct. +- Previous identifier and secret are both present or both absent. +- The web gateway signs only with the active key. +- The AI service selects exactly one key by identifier and does not trial multiple secrets. +- Missing, malformed, unknown, and retired identifiers fail closed. +- Method, path, workspace, actor, issuance time, and key identifier are all integrity-protected. +- Logs and problem responses contain no secrets, signatures, or untrusted identifier values. +- Unit, controller, BFF, integration, configuration, replay, and rotation tests pass. +- AI-service statement, branch, function, and line coverage remain at 100%. From 38cdf7918c632bdbf560627786cec2b0255fdba6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 23:52:33 +0900 Subject: [PATCH 05/39] docs(ai): design bounded gateway key rotation --- ...26-08-04-ai-gateway-key-rotation-design.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md diff --git a/docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md b/docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md new file mode 100644 index 000000000..2752cd128 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md @@ -0,0 +1,84 @@ +# AI gateway key rotation design + +Issue: #112 +Capability: `ai.auditable-proposals` + +## Product outcome + +Operators can rotate the private gateway-to-AI-service HMAC credential without rejecting valid in-flight requests during a planned overlap. Every request identifies exactly one configured key; unknown, malformed, incomplete, duplicate, conflicting, or retired key material fails closed. + +## Architecture + +The private context remains a short-lived method-and-path-bound HMAC envelope. Version 2 adds a bounded key identifier to both the HTTP header set and canonical HMAC input. + +```text +browser session + | + v +web BFF -- authenticate session + authorize workspace + | + | x-life-os-context-key-id: active-key-id + | x-life-os-workspace-id + | x-life-os-actor-id + | x-life-os-context-issued-at + | x-life-os-context-signature + v +AI service -- exact key-id lookup --> active OR previous verifier +``` + +The web BFF receives only the active signing pair. The AI service receives the active pair and optionally one previous verification pair. The verifier never iterates secrets and never treats the identifier as a database, file, network, or secret-manager locator. + +## Canonical message + +```text +life-os.ai-context.v2 + + + + + + +``` + +Each line is UTF-8 and separated by one line-feed byte. The signature is HMAC-SHA-256 encoded as canonical unpadded Base64URL. + +## Configuration + +- `AI_GATEWAY_ACTIVE_KEY_ID` +- `AI_GATEWAY_ACTIVE_KEY_SECRET` +- `AI_GATEWAY_PREVIOUS_KEY_ID` (optional) +- `AI_GATEWAY_PREVIOUS_KEY_SECRET` (optional) + +The active pair is mandatory. Previous values are both absent or both present. Active and previous identifiers are case-sensitive and distinct. Identifiers contain 1–64 allowlisted ASCII characters. Secrets contain 32–4096 UTF-8 bytes and no line break or NUL characters. + +## Failure mapping + +| Condition | Boundary classification | +| --- | --- | +| Missing/malformed/unknown request key identifier | `401 invalid_gateway_context` | +| Signature mismatch, stale context, replayed method/path | `401 invalid_gateway_context` | +| Missing, partial, duplicate, or malformed local key configuration | `503 gateway_context_unavailable` | + +Responses remain RFC 9457-compatible, credential-free, and stable. Logs record only fixed failure classes and correlation metadata, never key identifiers, signatures, or secrets. + +## Rotation state machine + +1. **Stable A:** signer A; verifier active A. +2. **Verifier expansion:** signer A; verifier active A, previous B. +3. **Signer switch:** signer B; verifier active B, previous A. +4. **Retirement:** signer B; verifier active B. + +Emergency compromise skips bounded overlap for the compromised key and revokes it immediately. + +## Quality gates + +- key configuration unit tests +- active and previous explicit selection tests +- missing, malformed, duplicate, partial, conflicting, unknown, and retired key tests +- canonical version and field-order tests +- BFF active-only signing tests +- AI controller header and configuration tests +- old-key in-flight overlap and post-retirement rejection integration tests +- no-secret logging and problem-response regressions +- 100% AI-service statement, branch, function, and line coverage +- full repository formatting, lint, type checking, tests, build, Compose validation, security scans, commercial readiness, and review gates From cbf00e950677f13c14a493422af0c926728f68c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 23:53:00 +0900 Subject: [PATCH 06/39] docs(ai): plan zero-downtime gateway key rotation slice --- .../2026-08-04-ai-gateway-key-rotation.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md diff --git a/docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md b/docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md new file mode 100644 index 000000000..c245b0882 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md @@ -0,0 +1,18 @@ +# AI gateway key rotation implementation plan + +Issue: #112 + +## Slice sequence + +1. Add a bounded AI-service keyring parser with one active key, one optional previous key, immutable snapshots, sanitized failures, exact identifier selection, docstrings, and complete coverage. +2. Advance the shared canonical context to version 2 and include `x-life-os-context-key-id` in the HMAC input. +3. Update every AI controller to receive the key identifier and map request selection failures to `401` while mapping local configuration failures to `503`. +4. Update the web BFF to parse one active signing pair, sign only with that pair, emit the identifier header, and keep all material server-only. +5. Replace single-secret environment variables in examples, deployment references, and tests. +6. Add overlap tests proving old in-flight requests remain valid after the signer switches and become invalid after retirement. +7. Add malformed, missing, partial, duplicate, conflicting, unknown, and noncanonical identifier regressions. +8. Add no-secret logging and problem-response assertions covering both configuration and verification failures. +9. Document standards, rollout, rollback, emergency revocation, inventory metadata, and retirement evidence with APA 7 references. +10. Update capability evidence, formatting contracts, changelog, and release metadata when the complete slice is releasable. +11. Run full formatting, lint, type checking, 100% AI-service coverage, PostgreSQL integration, build, Compose, AppGuardrail, Semgrep, security, commercial readiness, and exact-head review gates. +12. Resolve all actionable human and automated review findings before merge. From 04877574628eadfc7913273b03a7dba0fa8c1d04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 23:55:22 +0900 Subject: [PATCH 07/39] style(ai): format keyring test table --- apps/ai-service/src/ai-gateway-keyring.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ai-service/src/ai-gateway-keyring.test.ts b/apps/ai-service/src/ai-gateway-keyring.test.ts index b40106518..484e26d51 100644 --- a/apps/ai-service/src/ai-gateway-keyring.test.ts +++ b/apps/ai-service/src/ai-gateway-keyring.test.ts @@ -25,7 +25,7 @@ function activeEnvironment(): { } describe('AI gateway key configuration', () => { - it.each(['a', 'A_1', 'release.2026-08', 'gateway-key']) ( + it.each(['a', 'A_1', 'release.2026-08', 'gateway-key'])( 'accepts bounded opaque key identifier %s', (keyId) => { expect(requireAiGatewayKeyId(keyId)).toBe(keyId); From c296dffea351a141ccacc101435949107c21316f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 23:57:57 +0900 Subject: [PATCH 08/39] ci: format AI gateway keyring evidence --- .../workflows/ai-keyring-format-repair.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/ai-keyring-format-repair.yml diff --git a/.github/workflows/ai-keyring-format-repair.yml b/.github/workflows/ai-keyring-format-repair.yml new file mode 100644 index 000000000..6e54b37e2 --- /dev/null +++ b/.github/workflows/ai-keyring-format-repair.yml @@ -0,0 +1,45 @@ +name: AI keyring format repair + +on: + push: + branches: + - feat/ai-gateway-key-rotation + +permissions: {} + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: feat/ai-gateway-key-rotation + fetch-depth: 0 + persist-credentials: false + + - name: Format and commit keyring evidence + env: + GITHUB_TOKEN: ${{ github.token }} + shell: bash + run: | + set -Eeuo pipefail + corepack enable + pnpm install --frozen-lockfile + pnpm exec prettier --single-quote --write \ + apps/ai-service/src/ai-gateway-keyring.ts \ + apps/ai-service/src/ai-gateway-keyring.test.ts + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add \ + apps/ai-service/src/ai-gateway-keyring.ts \ + apps/ai-service/src/ai-gateway-keyring.test.ts + git diff --cached --check + git commit -m 'style(ai): format gateway keyring evidence' + authorization=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n') + git -c http.https://github.com/.extraheader="AUTHORIZATION: basic $authorization" \ + push origin HEAD:feat/ai-gateway-key-rotation From eee4a3b1cbc877eb9991ba0b99fbdb7f8826c7dc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:58:12 +0000 Subject: [PATCH 09/39] style(ai): format gateway keyring evidence --- .../ai-service/src/ai-gateway-keyring.test.ts | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/apps/ai-service/src/ai-gateway-keyring.test.ts b/apps/ai-service/src/ai-gateway-keyring.test.ts index 484e26d51..5e0bc66a6 100644 --- a/apps/ai-service/src/ai-gateway-keyring.test.ts +++ b/apps/ai-service/src/ai-gateway-keyring.test.ts @@ -115,11 +115,14 @@ describe('AI gateway key configuration', () => { { AI_GATEWAY_ACTIVE_KEY_SECRET: ACTIVE_SECRET, }, - ])('rejects incomplete or conflicting keyring configuration %#', (environment) => { - expect(() => requireAiGatewayContextKeyRing(environment)).toThrow( - AiGatewayKeyConfigurationError, - ); - }); + ])( + 'rejects incomplete or conflicting keyring configuration %#', + (environment) => { + expect(() => requireAiGatewayContextKeyRing(environment)).toThrow( + AiGatewayKeyConfigurationError, + ); + }, + ); }); describe('AI gateway verification key selection', () => { @@ -145,21 +148,17 @@ describe('AI gateway verification key selection', () => { ); }); - it.each([ - undefined, - null, - '', - '-leading', - 'unknown-key', - 'a'.repeat(65), - ])('rejects malformed, unknown, or retired identifier %#', (value) => { - let failure: unknown; - try { - selectAiGatewayVerificationKey(overlap, value); - } catch (error) { - failure = error; - } - expect(failure).toBeInstanceOf(AiGatewayKeySelectionError); - expect(String(failure)).not.toContain(String(value)); - }); + it.each([undefined, null, '', '-leading', 'unknown-key', 'a'.repeat(65)])( + 'rejects malformed, unknown, or retired identifier %#', + (value) => { + let failure: unknown; + try { + selectAiGatewayVerificationKey(overlap, value); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(AiGatewayKeySelectionError); + expect(String(failure)).not.toContain(String(value)); + }, + ); }); From 573c35462a3d96eeae42f49bcdf6c19dbfeb7bc9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 23:59:01 +0900 Subject: [PATCH 10/39] ci: remove temporary keyring formatter --- .../workflows/ai-keyring-format-repair.yml | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 .github/workflows/ai-keyring-format-repair.yml diff --git a/.github/workflows/ai-keyring-format-repair.yml b/.github/workflows/ai-keyring-format-repair.yml deleted file mode 100644 index 6e54b37e2..000000000 --- a/.github/workflows/ai-keyring-format-repair.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: AI keyring format repair - -on: - push: - branches: - - feat/ai-gateway-key-rotation - -permissions: {} - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: write - steps: - - name: Checkout branch - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: feat/ai-gateway-key-rotation - fetch-depth: 0 - persist-credentials: false - - - name: Format and commit keyring evidence - env: - GITHUB_TOKEN: ${{ github.token }} - shell: bash - run: | - set -Eeuo pipefail - corepack enable - pnpm install --frozen-lockfile - pnpm exec prettier --single-quote --write \ - apps/ai-service/src/ai-gateway-keyring.ts \ - apps/ai-service/src/ai-gateway-keyring.test.ts - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add \ - apps/ai-service/src/ai-gateway-keyring.ts \ - apps/ai-service/src/ai-gateway-keyring.test.ts - git diff --cached --check - git commit -m 'style(ai): format gateway keyring evidence' - authorization=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n') - git -c http.https://github.com/.extraheader="AUTHORIZATION: basic $authorization" \ - push origin HEAD:feat/ai-gateway-key-rotation From bdf495980ebe0fe0054cdaff5de8c54aaf20465e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 00:07:02 +0900 Subject: [PATCH 11/39] docs(ai): document gateway key error constructors --- apps/ai-service/src/ai-gateway-keyring.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ai-service/src/ai-gateway-keyring.ts b/apps/ai-service/src/ai-gateway-keyring.ts index 0ea724d58..7efd349bb 100644 --- a/apps/ai-service/src/ai-gateway-keyring.ts +++ b/apps/ai-service/src/ai-gateway-keyring.ts @@ -20,6 +20,7 @@ export interface AiGatewayContextKeyEnvironment { /** Sanitized configuration failure that never retains key material. */ export class AiGatewayKeyConfigurationError extends Error { + /** Creates one stable configuration failure without interpolating input. */ constructor() { super('AI gateway key configuration is invalid'); this.name = 'AiGatewayKeyConfigurationError'; @@ -28,6 +29,7 @@ export class AiGatewayKeyConfigurationError extends Error { /** Sanitized request failure for malformed, unknown, or retired key identifiers. */ export class AiGatewayKeySelectionError extends Error { + /** Creates one stable selection failure without interpolating input. */ constructor() { super('AI gateway key identifier is invalid'); this.name = 'AiGatewayKeySelectionError'; From 3f0154d6db71887696d819ac871b0570d5f8c0dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 00:07:31 +0900 Subject: [PATCH 12/39] test(ai): handle empty rejected key material safely --- apps/ai-service/src/ai-gateway-keyring.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/ai-service/src/ai-gateway-keyring.test.ts b/apps/ai-service/src/ai-gateway-keyring.test.ts index 5e0bc66a6..134c82332 100644 --- a/apps/ai-service/src/ai-gateway-keyring.test.ts +++ b/apps/ai-service/src/ai-gateway-keyring.test.ts @@ -68,7 +68,10 @@ describe('AI gateway key configuration', () => { failure = error; } expect(failure).toBeInstanceOf(AiGatewayKeyConfigurationError); - expect(String(failure)).not.toContain(String(value)); + const rejectedValue = String(value); + if (rejectedValue.length > 0) { + expect(String(failure)).not.toContain(rejectedValue); + } }); it('creates an immutable active-only keyring', () => { @@ -158,7 +161,10 @@ describe('AI gateway verification key selection', () => { failure = error; } expect(failure).toBeInstanceOf(AiGatewayKeySelectionError); - expect(String(failure)).not.toContain(String(value)); + const rejectedValue = String(value); + if (rejectedValue.length > 0) { + expect(String(failure)).not.toContain(rejectedValue); + } }, ); }); From 901ed7a1c1430211958da703d5057d726656c651 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 07:55:52 +0900 Subject: [PATCH 13/39] fix(ai): reject reused gateway rotation material --- apps/ai-service/src/ai-gateway-keyring.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/ai-service/src/ai-gateway-keyring.ts b/apps/ai-service/src/ai-gateway-keyring.ts index 7efd349bb..9c27dfb2a 100644 --- a/apps/ai-service/src/ai-gateway-keyring.ts +++ b/apps/ai-service/src/ai-gateway-keyring.ts @@ -74,11 +74,14 @@ function contextKey(keyId: unknown, secret: unknown): AiGatewayContextKey { /** * Parses exactly one active signing key and at most one complete previous key. - * Partial or duplicate overlap configuration fails closed before verification. + * Partial, duplicate, or reused-secret overlap configuration fails closed. */ export function requireAiGatewayContextKeyRing( environment: AiGatewayContextKeyEnvironment, ): AiGatewayContextKeyRing { + if (!environment || typeof environment !== 'object') { + throw new AiGatewayKeyConfigurationError(); + } const active = contextKey( environment.AI_GATEWAY_ACTIVE_KEY_ID, environment.AI_GATEWAY_ACTIVE_KEY_SECRET, @@ -94,7 +97,10 @@ export function requireAiGatewayContextKeyRing( return Object.freeze({ active }); } const previous = contextKey(previousId, previousSecret); - if (previous.keyId === active.keyId) { + if ( + previous.keyId === active.keyId || + previous.secret === active.secret + ) { throw new AiGatewayKeyConfigurationError(); } return Object.freeze({ active, previous }); From 120db48d9e526777e4e515ac2129b5d2a9d1392e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 07:56:39 +0900 Subject: [PATCH 14/39] test(ai): cover duplicate gateway key material --- apps/ai-service/src/ai-gateway-keyring.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/ai-service/src/ai-gateway-keyring.test.ts b/apps/ai-service/src/ai-gateway-keyring.test.ts index 134c82332..2dc072083 100644 --- a/apps/ai-service/src/ai-gateway-keyring.test.ts +++ b/apps/ai-service/src/ai-gateway-keyring.test.ts @@ -99,6 +99,8 @@ describe('AI gateway key configuration', () => { }); it.each([ + undefined, + null, { ...activeEnvironment(), AI_GATEWAY_PREVIOUS_KEY_ID: PREVIOUS_ID, @@ -112,6 +114,11 @@ describe('AI gateway key configuration', () => { AI_GATEWAY_PREVIOUS_KEY_ID: ACTIVE_ID, AI_GATEWAY_PREVIOUS_KEY_SECRET: PREVIOUS_SECRET, }, + { + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_ID: PREVIOUS_ID, + AI_GATEWAY_PREVIOUS_KEY_SECRET: ACTIVE_SECRET, + }, { AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_ID, }, @@ -119,11 +126,11 @@ describe('AI gateway key configuration', () => { AI_GATEWAY_ACTIVE_KEY_SECRET: ACTIVE_SECRET, }, ])( - 'rejects incomplete or conflicting keyring configuration %#', + 'rejects incomplete, duplicate, or conflicting keyring configuration %#', (environment) => { - expect(() => requireAiGatewayContextKeyRing(environment)).toThrow( - AiGatewayKeyConfigurationError, - ); + expect(() => + requireAiGatewayContextKeyRing(environment as never), + ).toThrow(AiGatewayKeyConfigurationError); }, ); }); From 8176aa9fe837ed8db886c869d0b66d5bb751ebe0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 07:57:47 +0900 Subject: [PATCH 15/39] feat(ai): verify versioned keyed gateway context --- apps/ai-service/src/ai-http-boundary.ts | 63 +++++++++++++++---------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/apps/ai-service/src/ai-http-boundary.ts b/apps/ai-service/src/ai-http-boundary.ts index b34737c9d..91d62945a 100644 --- a/apps/ai-service/src/ai-http-boundary.ts +++ b/apps/ai-service/src/ai-http-boundary.ts @@ -1,8 +1,16 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import { HttpException } from '@nestjs/common'; +import { + AiGatewayKeyConfigurationError, + type AiGatewayContextKeyEnvironment, + AiGatewayKeySelectionError, + requireAiGatewayContextKeyRing, + selectAiGatewayVerificationKey, +} from './ai-gateway-keyring'; /** Headers emitted by a private gateway after session authentication and authorization. */ export interface TrustedAiContextHeaders { + keyId: unknown; workspaceId: unknown; actorId: unknown; issuedAt: unknown; @@ -29,8 +37,6 @@ const UNIX_SECONDS_PATTERN = /^(?:0|[1-9]\d{0,12})$/u; const BASE64URL_SHA256_PATTERN = /^[A-Za-z0-9_-]{43}$/u; const PROPOSAL_PATH_PATTERN = /^\/v1\/proposals(?:\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(\/decisions)?)?$/u; -const MINIMUM_GATEWAY_SECRET_BYTES = 32; -const MAXIMUM_GATEWAY_SECRET_BYTES = 4096; const MAXIMUM_CONTEXT_AGE_SECONDS = 60; const MAXIMUM_FUTURE_SKEW_SECONDS = 5; const MAXIMUM_PATH_CHARACTERS = 256; @@ -50,7 +56,7 @@ function problemException( return new HttpException(details, status); } -/** Rejects malformed, stale, replayed, or forged service context. */ +/** Rejects malformed, stale, replayed, forged, unknown, or retired service context. */ function invalidGatewayContext(): never { throw problemException( 401, @@ -68,22 +74,6 @@ function unavailableGatewayContext(): never { ); } -/** Requires a bounded secret appropriate for the shared HMAC trust boundary. */ -function requireGatewaySecret(value: unknown): string { - if (typeof value !== 'string') { - return unavailableGatewayContext(); - } - const byteLength = Buffer.byteLength(value, 'utf8'); - if ( - byteLength < MINIMUM_GATEWAY_SECRET_BYTES || - byteLength > MAXIMUM_GATEWAY_SECRET_BYTES || - /[\r\n\u0000]/u.test(value) - ) { - return unavailableGatewayContext(); - } - return value; -} - /** Requires one exact supported uppercase method and canonical proposal path. */ function requireMethodAndPath( method: unknown, @@ -111,6 +101,7 @@ function requireMethodAndPath( /** Computes the exact versioned HMAC shared by the BFF and AI service. */ function contextDigest( + keyId: string, workspaceId: string, actorId: string, issuedAt: string, @@ -120,7 +111,7 @@ function contextDigest( ): Buffer { return createHmac('sha256', secret) .update( - `life-os.ai-context.v1\n${workspaceId}\n${actorId}\n${issuedAt}\n${method}\n${path}`, + `life-os.ai-context.v2\n${keyId}\n${workspaceId}\n${actorId}\n${issuedAt}\n${method}\n${path}`, 'utf8', ) .digest(); @@ -128,18 +119,20 @@ function contextDigest( /** * Verifies a fresh method-and-path-bound service context created only after - * gateway session authentication and workspace authorization. + * gateway session authentication and workspace authorization. Exactly the key + * identified by the signed request is selected; verification never tries every + * configured key. */ export function requireTrustedAiContext( headers: TrustedAiContextHeaders, - secretValue: unknown, + keyEnvironment: AiGatewayContextKeyEnvironment, methodValue: unknown, pathValue: unknown, nowSeconds = Math.floor(Date.now() / 1000), ): TrustedAiContext { - const secret = requireGatewaySecret(secretValue); const { method, path } = requireMethodAndPath(methodValue, pathValue); if ( + typeof headers.keyId !== 'string' || typeof headers.workspaceId !== 'string' || typeof headers.actorId !== 'string' || typeof headers.issuedAt !== 'string' || @@ -172,13 +165,35 @@ export function requireTrustedAiContext( ) { return invalidGatewayContext(); } + + let keyRing; + try { + keyRing = requireAiGatewayContextKeyRing(keyEnvironment); + } catch (error) { + if (error instanceof AiGatewayKeyConfigurationError) { + return unavailableGatewayContext(); + } + throw error; + } + + let verificationKey; + try { + verificationKey = selectAiGatewayVerificationKey(keyRing, headers.keyId); + } catch (error) { + if (error instanceof AiGatewayKeySelectionError) { + return invalidGatewayContext(); + } + throw error; + } + const expected = contextDigest( + verificationKey.keyId, workspaceId, actorId, headers.issuedAt, method, path, - secret, + verificationKey.secret, ); if (!timingSafeEqual(actual, expected)) { return invalidGatewayContext(); From dcefa96a4d87ac50898396f0de05a81be42258b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 07:59:04 +0900 Subject: [PATCH 16/39] refactor(ai): fail closed on gateway key errors --- apps/ai-service/src/ai-http-boundary.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/apps/ai-service/src/ai-http-boundary.ts b/apps/ai-service/src/ai-http-boundary.ts index 91d62945a..792b51191 100644 --- a/apps/ai-service/src/ai-http-boundary.ts +++ b/apps/ai-service/src/ai-http-boundary.ts @@ -1,9 +1,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import { HttpException } from '@nestjs/common'; import { - AiGatewayKeyConfigurationError, type AiGatewayContextKeyEnvironment, - AiGatewayKeySelectionError, requireAiGatewayContextKeyRing, selectAiGatewayVerificationKey, } from './ai-gateway-keyring'; @@ -169,21 +167,15 @@ export function requireTrustedAiContext( let keyRing; try { keyRing = requireAiGatewayContextKeyRing(keyEnvironment); - } catch (error) { - if (error instanceof AiGatewayKeyConfigurationError) { - return unavailableGatewayContext(); - } - throw error; + } catch { + return unavailableGatewayContext(); } let verificationKey; try { verificationKey = selectAiGatewayVerificationKey(keyRing, headers.keyId); - } catch (error) { - if (error instanceof AiGatewayKeySelectionError) { - return invalidGatewayContext(); - } - throw error; + } catch { + return invalidGatewayContext(); } const expected = contextDigest( From 99524ddb49f66ef698b9fcd33e6f4294353dff25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:01:34 +0900 Subject: [PATCH 17/39] test(ai): cover active overlap and retired gateway keys --- apps/ai-service/src/ai-http-boundary.test.ts | 155 ++++++++++++++++--- 1 file changed, 131 insertions(+), 24 deletions(-) diff --git a/apps/ai-service/src/ai-http-boundary.test.ts b/apps/ai-service/src/ai-http-boundary.test.ts index 89b8ed77e..750002592 100644 --- a/apps/ai-service/src/ai-http-boundary.test.ts +++ b/apps/ai-service/src/ai-http-boundary.test.ts @@ -9,12 +9,41 @@ import { const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'; const ACTOR_ID = '22222222-2222-4222-8222-222222222222'; const PROPOSAL_ID = '33333333-3333-4333-8333-333333333333'; -const GATEWAY_SECRET = 'trusted-ai-gateway-context-secret-32-bytes'; +const ACTIVE_KEY_ID = 'gateway-2026-08-a'; +const PREVIOUS_KEY_ID = 'gateway-2026-07-z'; +const ACTIVE_SECRET = Buffer.alloc(32, 0x41).toString('base64url'); +const PREVIOUS_SECRET = Buffer.alloc(32, 0x42).toString('base64url'); const NOW_SECONDS = 1_785_806_400; +/** Returns one complete active-only verifier environment. */ +function activeEnvironment(): { + AI_GATEWAY_ACTIVE_KEY_ID: string; + AI_GATEWAY_ACTIVE_KEY_SECRET: string; +} { + return { + AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_KEY_ID, + AI_GATEWAY_ACTIVE_KEY_SECRET: ACTIVE_SECRET, + }; +} + +/** Returns one active/previous overlap verifier environment. */ +function overlapEnvironment(): { + AI_GATEWAY_ACTIVE_KEY_ID: string; + AI_GATEWAY_ACTIVE_KEY_SECRET: string; + AI_GATEWAY_PREVIOUS_KEY_ID: string; + AI_GATEWAY_PREVIOUS_KEY_SECRET: string; +} { + return { + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_ID: PREVIOUS_KEY_ID, + AI_GATEWAY_PREVIOUS_KEY_SECRET: PREVIOUS_SECRET, + }; +} + /** Creates the exact versioned HMAC expected by the AI service boundary. */ function signContext( input: { + keyId?: string; workspaceId?: string; actorId?: string; issuedAt?: string; @@ -23,15 +52,16 @@ function signContext( secret?: string; } = {}, ): string { + const keyId = input.keyId ?? ACTIVE_KEY_ID; const workspaceId = (input.workspaceId ?? WORKSPACE_ID).toLowerCase(); const actorId = (input.actorId ?? ACTOR_ID).toLowerCase(); const issuedAt = input.issuedAt ?? String(NOW_SECONDS); const method = input.method ?? 'POST'; const path = input.path ?? '/v1/proposals'; - const secret = input.secret ?? GATEWAY_SECRET; + const secret = input.secret ?? ACTIVE_SECRET; return createHmac('sha256', secret) .update( - `life-os.ai-context.v1\n${workspaceId}\n${actorId}\n${issuedAt}\n${method}\n${path}`, + `life-os.ai-context.v2\n${keyId}\n${workspaceId}\n${actorId}\n${issuedAt}\n${method}\n${path}`, 'utf8', ) .digest('base64url'); @@ -42,6 +72,7 @@ function contextHeaders( overrides: Partial = {}, ): TrustedAiContextHeaders { return { + keyId: ACTIVE_KEY_ID, workspaceId: WORKSPACE_ID, actorId: ACTOR_ID, issuedAt: String(NOW_SECONDS), @@ -70,14 +101,14 @@ function expectProblem( } describe('trusted AI service context', () => { - it('accepts an exact fresh method-and-path-bound context', () => { + it('accepts an exact active-key method-and-path-bound context', () => { expect( requireTrustedAiContext( contextHeaders({ workspaceId: WORKSPACE_ID.toUpperCase(), actorId: ACTOR_ID.toUpperCase(), }), - GATEWAY_SECRET, + activeEnvironment(), 'POST', '/v1/proposals', NOW_SECONDS, @@ -85,7 +116,25 @@ describe('trusted AI service context', () => { ).toEqual({ workspaceId: WORKSPACE_ID, actorId: ACTOR_ID }); }); - it('accepts proposal and decision paths at the documented time boundaries', () => { + it('accepts the explicitly identified previous key during overlap', () => { + expect( + requireTrustedAiContext( + contextHeaders({ + keyId: PREVIOUS_KEY_ID, + signature: signContext({ + keyId: PREVIOUS_KEY_ID, + secret: PREVIOUS_SECRET, + }), + }), + overlapEnvironment(), + 'POST', + '/v1/proposals', + NOW_SECONDS, + ), + ).toEqual({ workspaceId: WORKSPACE_ID, actorId: ACTOR_ID }); + }); + + it('accepts proposal and decision paths at documented time boundaries', () => { for (const input of [ { issuedAt: NOW_SECONDS - 60, @@ -109,7 +158,7 @@ describe('trusted AI service context', () => { path: input.path, }), }), - GATEWAY_SECRET, + activeEnvironment(), input.method, input.path, NOW_SECONDS, @@ -119,18 +168,33 @@ describe('trusted AI service context', () => { }); it.each([ - undefined, - null, - '', - 'too-short', - 'x'.repeat(4097), - `x${String.fromCharCode(0)}y`, - ])('fails closed when the gateway secret is unavailable: %#', (secret) => { + {}, + { AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_KEY_ID }, + { AI_GATEWAY_ACTIVE_KEY_SECRET: ACTIVE_SECRET }, + { + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_ID: PREVIOUS_KEY_ID, + }, + { + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_SECRET: PREVIOUS_SECRET, + }, + { + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_ID: ACTIVE_KEY_ID, + AI_GATEWAY_PREVIOUS_KEY_SECRET: PREVIOUS_SECRET, + }, + { + ...activeEnvironment(), + AI_GATEWAY_PREVIOUS_KEY_ID: PREVIOUS_KEY_ID, + AI_GATEWAY_PREVIOUS_KEY_SECRET: ACTIVE_SECRET, + }, + ])('fails closed when gateway key configuration is unavailable: %#', (keys) => { expectProblem( () => requireTrustedAiContext( contextHeaders(), - secret, + keys, 'POST', '/v1/proposals', NOW_SECONDS, @@ -144,6 +208,11 @@ describe('trusted AI service context', () => { }); it.each([ + { field: 'keyId', value: undefined }, + { field: 'keyId', value: null }, + { field: 'keyId', value: '' }, + { field: 'keyId', value: '-leading' }, + { field: 'keyId', value: 'unknown-key' }, { field: 'workspaceId', value: undefined }, { field: 'workspaceId', value: 'workspace-a' }, { field: 'actorId', value: null }, @@ -154,12 +223,36 @@ describe('trusted AI service context', () => { { field: 'signature', value: undefined }, { field: 'signature', value: 'invalid' }, { field: 'signature', value: `${'a'.repeat(42)}!` }, - ])('rejects malformed context field $field: %#', ({ field, value }) => { + ])('rejects malformed or unknown context field $field: %#', ({ field, value }) => { expectProblem( () => requireTrustedAiContext( contextHeaders({ [field]: value }), - GATEWAY_SECRET, + overlapEnvironment(), + 'POST', + '/v1/proposals', + NOW_SECONDS, + ), + { + title: 'Trusted gateway context is invalid', + status: 401, + code: 'invalid_gateway_context', + }, + ); + }); + + it('rejects the previous key immediately after retirement', () => { + expectProblem( + () => + requireTrustedAiContext( + contextHeaders({ + keyId: PREVIOUS_KEY_ID, + signature: signContext({ + keyId: PREVIOUS_KEY_ID, + secret: PREVIOUS_SECRET, + }), + }), + activeEnvironment(), 'POST', '/v1/proposals', NOW_SECONDS, @@ -188,7 +281,7 @@ describe('trusted AI service context', () => { issuedAt: issuedAtText, signature: signContext({ issuedAt: issuedAtText }), }), - GATEWAY_SECRET, + activeEnvironment(), 'POST', '/v1/proposals', nowSeconds, @@ -212,13 +305,14 @@ describe('trusted AI service context', () => { { method: 'GET', path: '/v1/proposals/not-a-uuid' }, { method: 'GET', path: '/v1/proposals/decisions' }, { method: 'GET', path: '/v1/proposals\n' }, + { method: 'GET', path: 'x'.repeat(257) }, { method: 'GET', path: 42 }, ])('rejects noncanonical method or path %#', ({ method, path }) => { expectProblem( () => requireTrustedAiContext( contextHeaders(), - GATEWAY_SECRET, + activeEnvironment(), method, path, NOW_SECONDS, @@ -246,7 +340,7 @@ describe('trusted AI service context', () => { () => requireTrustedAiContext( contextHeaders({ signature: noncanonical }), - GATEWAY_SECRET, + activeEnvironment(), 'POST', '/v1/proposals', NOW_SECONDS, @@ -261,28 +355,41 @@ describe('trusted AI service context', () => { it.each([ { + keyId: ACTIVE_KEY_ID, method: 'GET', path: '/v1/proposals', signature: signContext({ method: 'POST' }), }, { + keyId: ACTIVE_KEY_ID, method: 'POST', path: `/v1/proposals/${PROPOSAL_ID}`, signature: signContext({ method: 'POST', path: '/v1/proposals' }), }, { + keyId: ACTIVE_KEY_ID, + method: 'POST', + path: '/v1/proposals', + signature: signContext({ secret: PREVIOUS_SECRET }), + }, + { + keyId: PREVIOUS_KEY_ID, method: 'POST', path: '/v1/proposals', signature: signContext({ - secret: 'another-gateway-secret-with-32-bytes', + keyId: ACTIVE_KEY_ID, + secret: ACTIVE_SECRET, }), }, - ])('rejects method replay, path replay, or forged signature %#', (input) => { + ])('rejects replay, forgery, or key-identifier substitution %#', (input) => { expectProblem( () => requireTrustedAiContext( - contextHeaders({ signature: input.signature }), - GATEWAY_SECRET, + contextHeaders({ + keyId: input.keyId, + signature: input.signature, + }), + overlapEnvironment(), input.method, input.path, NOW_SECONDS, From c91b6832437d4ae80e541a7edc5dc62e6d3ffe4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:03:17 +0900 Subject: [PATCH 18/39] feat(ai): require keyed context on every controller --- apps/ai-service/src/main.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/ai-service/src/main.ts b/apps/ai-service/src/main.ts index fdb7d9e86..4e50833d4 100644 --- a/apps/ai-service/src/main.ts +++ b/apps/ai-service/src/main.ts @@ -85,12 +85,7 @@ function trustedContext( method: 'GET' | 'POST', path: string, ): TrustedAiContext { - return requireTrustedAiContext( - headers, - process.env.AI_GATEWAY_CONTEXT_SECRET, - method, - path, - ); + return requireTrustedAiContext(headers, process.env, method, path); } /** Maps proposal-audit failures to stable credential-free HTTP problems. */ @@ -125,14 +120,15 @@ function mapAuditError(error: unknown): never { throw problem(503, 'Proposal audit is unavailable', 'audit_unavailable'); } -/** Converts the four signed headers into the framework-neutral verifier input. */ +/** Converts the five signed headers into the framework-neutral verifier input. */ function contextHeaders( + keyId: unknown, workspaceId: unknown, actorId: unknown, issuedAt: unknown, signature: unknown, ): TrustedAiContextHeaders { - return { workspaceId, actorId, issuedAt, signature }; + return { keyId, workspaceId, actorId, issuedAt, signature }; } /** Exposes health and inert proposal generation. */ @@ -153,6 +149,7 @@ export class AiProposalController { /** Generates and persists one inert proposal for the authenticated workspace. */ @Post('v1/proposals') async createProposal( + @Headers('x-life-os-context-key-id') keyId: unknown, @Headers('x-life-os-workspace-id') workspaceId: unknown, @Headers('x-life-os-actor-id') actorId: unknown, @Headers('x-life-os-context-issued-at') issuedAt: unknown, @@ -161,7 +158,7 @@ export class AiProposalController { ): Promise { try { const context = trustedContext( - contextHeaders(workspaceId, actorId, issuedAt, signature), + contextHeaders(keyId, workspaceId, actorId, issuedAt, signature), 'POST', '/v1/proposals', ); @@ -205,6 +202,7 @@ export class AiProposalAuditController { /** Lists deterministic proposal evidence for the authenticated workspace. */ @Get('v1/proposals') async listProposals( + @Headers('x-life-os-context-key-id') keyId: unknown, @Headers('x-life-os-workspace-id') workspaceId: unknown, @Headers('x-life-os-actor-id') actorId: unknown, @Headers('x-life-os-context-issued-at') issuedAt: unknown, @@ -212,7 +210,7 @@ export class AiProposalAuditController { ): Promise { try { const context = trustedContext( - contextHeaders(workspaceId, actorId, issuedAt, signature), + contextHeaders(keyId, workspaceId, actorId, issuedAt, signature), 'GET', '/v1/proposals', ); @@ -225,6 +223,7 @@ export class AiProposalAuditController { /** Returns one immutable proposal revision within the authenticated workspace. */ @Get('v1/proposals/:proposalId') async findProposal( + @Headers('x-life-os-context-key-id') keyId: unknown, @Headers('x-life-os-workspace-id') workspaceId: unknown, @Headers('x-life-os-actor-id') actorId: unknown, @Headers('x-life-os-context-issued-at') issuedAt: unknown, @@ -234,7 +233,7 @@ export class AiProposalAuditController { try { const path = `/v1/proposals/${proposalId}`; const context = trustedContext( - contextHeaders(workspaceId, actorId, issuedAt, signature), + contextHeaders(keyId, workspaceId, actorId, issuedAt, signature), 'GET', path, ); @@ -250,6 +249,7 @@ export class AiProposalAuditController { /** Lists append-only decisions for one authenticated workspace proposal. */ @Get('v1/proposals/:proposalId/decisions') async listDecisions( + @Headers('x-life-os-context-key-id') keyId: unknown, @Headers('x-life-os-workspace-id') workspaceId: unknown, @Headers('x-life-os-actor-id') actorId: unknown, @Headers('x-life-os-context-issued-at') issuedAt: unknown, @@ -259,7 +259,7 @@ export class AiProposalAuditController { try { const path = `/v1/proposals/${proposalId}/decisions`; const context = trustedContext( - contextHeaders(workspaceId, actorId, issuedAt, signature), + contextHeaders(keyId, workspaceId, actorId, issuedAt, signature), 'GET', path, ); @@ -275,6 +275,7 @@ export class AiProposalAuditController { /** Appends an explicit authenticated-actor decision without executing operations. */ @Post('v1/proposals/:proposalId/decisions') async appendDecision( + @Headers('x-life-os-context-key-id') keyId: unknown, @Headers('x-life-os-workspace-id') workspaceId: unknown, @Headers('x-life-os-actor-id') actorId: unknown, @Headers('x-life-os-context-issued-at') issuedAt: unknown, @@ -285,7 +286,7 @@ export class AiProposalAuditController { try { const path = `/v1/proposals/${proposalId}/decisions`; const context = trustedContext( - contextHeaders(workspaceId, actorId, issuedAt, signature), + contextHeaders(keyId, workspaceId, actorId, issuedAt, signature), 'POST', path, ); @@ -324,7 +325,6 @@ export class AiAppModule {} }, { provide: PROPOSAL_SERVICE, - // Expose only ProposalGenerator while reusing the shared audit application. useFactory: (runtime: AiRuntime): ProposalGenerator => runtime.application, inject: [AI_RUNTIME], From 0840806323cfc4dac145fb9b3e2a6a188fc063fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:12:46 +0900 Subject: [PATCH 19/39] ci(ai): wire and verify gateway key rotation --- .../workflows/complete-ai-key-rotation.yml | 545 ++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 .github/workflows/complete-ai-key-rotation.yml diff --git a/.github/workflows/complete-ai-key-rotation.yml b/.github/workflows/complete-ai-key-rotation.yml new file mode 100644 index 000000000..8241a4bf6 --- /dev/null +++ b/.github/workflows/complete-ai-key-rotation.yml @@ -0,0 +1,545 @@ +name: Complete AI Gateway Key Rotation + +on: + push: + branches: + - feat/ai-gateway-key-rotation + paths: + - '.github/workflows/complete-ai-key-rotation.yml' + +permissions: + contents: write + +concurrency: + group: complete-ai-gateway-key-rotation + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + complete: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout feature branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/ai-gateway-key-rotation + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@fdbd5b5d6a1c9bb1f77880bc2f733d820f4737e0 # v6.0.0 + with: + node-version: 22 + + - name: Enable Corepack + run: corepack enable + + - name: Install workspace dependencies + run: pnpm install --no-frozen-lockfile + + - name: Wire keyed context through web and test boundaries + shell: bash + run: | + set -Eeuo pipefail + python3 - <<'PY' + from pathlib import Path + + def read(path: str) -> str: + return Path(path).read_text(encoding='utf-8') + + def write(path: str, text: str) -> None: + Path(path).write_text(text, encoding='utf-8') + + def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f'{label}: expected one match, found {count}') + return text.replace(old, new, 1) + + # Web transport core: active-only signer and v2 keyed canonical input. + path = 'apps/web/app/ai-proposal-client-core.ts' + text = read(path) + text = replace_once( + text, + "const CANONICAL_UUID_V4_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;\n", + "const CANONICAL_UUID_V4_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;\nconst AI_GATEWAY_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;\n", + 'web key-id pattern', + ) + text = replace_once( + text, + "export interface AiSessionPrincipal {\n readonly workspaceId: string;\n readonly actorId: string;\n}\n", + "export interface AiSessionPrincipal {\n readonly workspaceId: string;\n readonly actorId: string;\n}\n\n/** Active server-only key used to sign one exact AI service request. */\nexport interface AiGatewaySigningKey {\n readonly keyId: string;\n readonly secret: string;\n}\n", + 'web signing key interface', + ) + marker = """export function requireAiGatewaySecret(value: string | undefined): string { + if (typeof value !== 'string') { + throw new Error('AI gateway context secret is invalid'); + } + const bytes = Buffer.byteLength(value, 'utf8'); + if ( + bytes < MINIMUM_GATEWAY_SECRET_BYTES || + bytes > MAXIMUM_GATEWAY_SECRET_BYTES || + /[\\r\\n\\u0000]/u.test(value) + ) { + throw new Error('AI gateway context secret is invalid'); + } + return value; + } + """.replace(' ', '') + addition = marker + """ + /** Requires one bounded case-sensitive key identifier. */ + export function requireAiGatewayKeyId(value: string | undefined): string { + if (typeof value !== 'string' || !AI_GATEWAY_KEY_ID_PATTERN.test(value)) { + throw new Error('AI gateway context key identifier is invalid'); + } + return value; + } + + /** Parses exactly the active web signing pair and exposes no previous key. */ + export function requireAiGatewaySigningKey( + environment: WebEnvironment, + ): AiGatewaySigningKey { + return Object.freeze({ + keyId: requireAiGatewayKeyId(environment.AI_GATEWAY_ACTIVE_KEY_ID), + secret: requireAiGatewaySecret( + environment.AI_GATEWAY_ACTIVE_KEY_SECRET, + ), + }); + } + """.replace(' ', '') + text = replace_once(text, marker, addition, 'web signing parser') + + old_create = """/** Creates an exact short-lived method-and-path-bound AI service context. */ + export function createAiContextHeaders( + workspaceId: string, + actorId: string, + secretValue: string, + nowSeconds: number, + methodValue: unknown, + pathValue: unknown, + ): Readonly> { + const safeWorkspaceId = requireUuid( + workspaceId, + 'AI gateway context is invalid', + ); + const safeActorId = requireUuid(actorId, 'AI gateway context is invalid'); + const secret = requireAiGatewaySecret(secretValue); + const { method, path } = requireAiTarget(methodValue, pathValue); + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { + throw new Error('AI gateway context is invalid'); + } + const issuedAt = String(nowSeconds); + const signature = createHmac('sha256', secret) + .update( + `life-os.ai-context.v1\\n${safeWorkspaceId}\\n${safeActorId}\\n${issuedAt}\\n${method}\\n${path}`, + 'utf8', + ) + .digest('base64url'); + return Object.freeze({ + 'x-life-os-workspace-id': safeWorkspaceId, + 'x-life-os-actor-id': safeActorId, + 'x-life-os-context-issued-at': issuedAt, + 'x-life-os-context-signature': signature, + }); + } + """.replace(' ', '') + new_create = """/** Creates an exact short-lived method-and-path-bound AI service context. */ + export function createAiContextHeaders( + workspaceId: string, + actorId: string, + signingKeyValue: AiGatewaySigningKey, + nowSeconds: number, + methodValue: unknown, + pathValue: unknown, + ): Readonly> { + const safeWorkspaceId = requireUuid( + workspaceId, + 'AI gateway context is invalid', + ); + const safeActorId = requireUuid(actorId, 'AI gateway context is invalid'); + const signingKey = Object.freeze({ + keyId: requireAiGatewayKeyId(signingKeyValue?.keyId), + secret: requireAiGatewaySecret(signingKeyValue?.secret), + }); + const { method, path } = requireAiTarget(methodValue, pathValue); + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { + throw new Error('AI gateway context is invalid'); + } + const issuedAt = String(nowSeconds); + const signature = createHmac('sha256', signingKey.secret) + .update( + `life-os.ai-context.v2\\n${signingKey.keyId}\\n${safeWorkspaceId}\\n${safeActorId}\\n${issuedAt}\\n${method}\\n${path}`, + 'utf8', + ) + .digest('base64url'); + return Object.freeze({ + 'x-life-os-context-key-id': signingKey.keyId, + 'x-life-os-workspace-id': safeWorkspaceId, + 'x-life-os-actor-id': safeActorId, + 'x-life-os-context-issued-at': issuedAt, + 'x-life-os-context-signature': signature, + }); + } + """.replace(' ', '') + text = replace_once(text, old_create, new_create, 'web v2 context creator') + text = replace_once( + text, + " const secret = requireAiGatewaySecret(\n environment.AI_GATEWAY_CONTEXT_SECRET,\n );\n", + " const signingKey = requireAiGatewaySigningKey(environment);\n", + 'web active signer environment', + ) + text = replace_once( + text, + " secret,\n nowSeconds,\n parsedRequest.method,\n", + " signingKey,\n nowSeconds,\n parsedRequest.method,\n", + 'web signer call', + ) + write(path, text) + + # Re-export the keyed signing API. + path = 'apps/web/app/ai-proposal-client.ts' + text = read(path) + text = text.replace( + " parseAiSessionPrincipal,\n requireAiGatewaySecret,\n", + " parseAiSessionPrincipal,\n requireAiGatewayKeyId,\n requireAiGatewaySecret,\n requireAiGatewaySigningKey,\n", + ) + text = text.replace( + " type AiProposalRoute,\n type AiSessionPrincipal,\n", + " type AiProposalRoute,\n type AiGatewaySigningKey,\n type AiSessionPrincipal,\n", + ) + text = text.replace( + " parseAiSessionPrincipal,\n requireAiGatewaySecret,\n", + " parseAiSessionPrincipal,\n requireAiGatewayKeyId,\n requireAiGatewaySecret,\n requireAiGatewaySigningKey,\n", + 1, + ) + text = text.replace( + "export type { AiProposalFetch, AiProposalRoute, AiSessionPrincipal };", + "export type {\n AiGatewaySigningKey,\n AiProposalFetch,\n AiProposalRoute,\n AiSessionPrincipal,\n};", + ) + write(path, text) + + # Web transport evidence. + path = 'apps/web/app/ai-proposal-client.test.ts' + text = read(path) + text = text.replace( + " parseAiSessionPrincipal,\n requireAiGatewaySecret,\n", + " parseAiSessionPrincipal,\n requireAiGatewayKeyId,\n requireAiGatewaySecret,\n requireAiGatewaySigningKey,\n", + ) + text = text.replace( + "const GATEWAY_SECRET = Buffer.alloc(32, 7).toString('base64url');", + "const ACTIVE_KEY_ID = 'gateway-2026-08-a';\nconst GATEWAY_SECRET = Buffer.alloc(32, 7).toString('base64url');", + ) + text = text.replace( + " AI_GATEWAY_CONTEXT_SECRET: GATEWAY_SECRET,", + " AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_KEY_ID,\n AI_GATEWAY_ACTIVE_KEY_SECRET: GATEWAY_SECRET,", + ) + text = text.replace( + "`life-os.ai-context.v1\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${NOW_SECONDS}\\n${method}\\n${path}`", + "`life-os.ai-context.v2\\n${ACTIVE_KEY_ID}\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${NOW_SECONDS}\\n${method}\\n${path}`", + ) + text = replace_once( + text, + " assert.equal(aiHeaders.get('x-life-os-workspace-id'), WORKSPACE_ID);\n", + " assert.equal(aiHeaders.get('x-life-os-context-key-id'), ACTIVE_KEY_ID);\n assert.equal(aiHeaders.get('x-life-os-workspace-id'), WORKSPACE_ID);\n", + 'web forwarded key header assertion', + ) + text = text.replace( + "environment: { ...environment, AI_GATEWAY_CONTEXT_SECRET: 'short' },", + "environment: { ...environment, AI_GATEWAY_ACTIVE_KEY_SECRET: 'short' },", + ) + text = replace_once( + text, + " assert.equal(requireAiGatewaySecret(GATEWAY_SECRET), GATEWAY_SECRET);\n", + " assert.equal(requireAiGatewayKeyId(ACTIVE_KEY_ID), ACTIVE_KEY_ID);\n assert.equal(requireAiGatewaySecret(GATEWAY_SECRET), GATEWAY_SECRET);\n assert.deepEqual(requireAiGatewaySigningKey(environment), {\n keyId: ACTIVE_KEY_ID,\n secret: GATEWAY_SECRET,\n });\n", + 'web helper key assertions', + ) + text = text.replace( + " GATEWAY_SECRET,\n NOW_SECONDS,", + " { keyId: ACTIVE_KEY_ID, secret: GATEWAY_SECRET },\n NOW_SECONDS,", + ) + text = replace_once( + text, + " assert.equal(headers['x-life-os-workspace-id'], WORKSPACE_ID);\n", + " assert.equal(headers['x-life-os-context-key-id'], ACTIVE_KEY_ID);\n assert.equal(headers['x-life-os-workspace-id'], WORKSPACE_ID);\n", + 'web helper key header assertion', + ) + text = replace_once( + text, + " for (const secret of [\n", + " for (const keyId of ['', '-leading', 'white space', 'a'.repeat(65)]) {\n assert.throws(\n () => requireAiGatewayKeyId(keyId),\n new Error('AI gateway context key identifier is invalid'),\n );\n }\n for (const secret of [\n", + 'web invalid key-id helpers', + ) + write(path, text) + + # Other web tests use active-only server configuration. + for path in [ + 'apps/web/app/ai-proposal-scope-regression.test.ts', + 'apps/web/app/ai-proposal-identity-stream-regression.test.ts', + ]: + text = read(path) + const_name = 'SECRET' if 'scope' in path else 'GATEWAY_SECRET' + text = text.replace( + f"const {const_name} =", + "const ACTIVE_KEY_ID = 'gateway-2026-08-a';\n" + f"const {const_name} =", + 1, + ) + text = text.replace( + f" AI_GATEWAY_CONTEXT_SECRET: {const_name},", + f" AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_KEY_ID,\n AI_GATEWAY_ACTIVE_KEY_SECRET: {const_name},", + ) + write(path, text) + + # Next.js route integration uses both active environment fields. + path = 'apps/web/app/api/ai/proposals/routes.test.ts' + text = read(path) + text = text.replace( + "const SECRET = 'trusted-ai-gateway-context-secret-32-bytes';", + "const ACTIVE_KEY_ID = 'gateway-2026-08-a';\nconst SECRET = 'trusted-ai-gateway-context-secret-32-bytes';", + ) + text = text.replace( + " const originalSecret = process.env.AI_GATEWAY_CONTEXT_SECRET;", + " const originalKeyId = process.env.AI_GATEWAY_ACTIVE_KEY_ID;\n const originalSecret = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;", + ) + text = text.replace( + " process.env.AI_GATEWAY_CONTEXT_SECRET = SECRET;", + " process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID;\n process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = SECRET;", + ) + text = text.replace( + " if (originalSecret === undefined) {\n delete process.env.AI_GATEWAY_CONTEXT_SECRET;\n } else {\n process.env.AI_GATEWAY_CONTEXT_SECRET = originalSecret;\n }", + " if (originalKeyId === undefined) {\n delete process.env.AI_GATEWAY_ACTIVE_KEY_ID;\n } else {\n process.env.AI_GATEWAY_ACTIVE_KEY_ID = originalKeyId;\n }\n if (originalSecret === undefined) {\n delete process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;\n } else {\n process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = originalSecret;\n }", + ) + write(path, text) + + # AI unit/controller coverage: v2 signatures and five-header calls. + path = 'apps/ai-service/src/quality-coverage.test.ts' + text = read(path) + text = text.replace( + "const GATEWAY_SECRET = Buffer.alloc(32, 0x51).toString('base64url');", + "const GATEWAY_KEY_ID = 'gateway-2026-08-a';\nconst GATEWAY_SECRET = Buffer.alloc(32, 0x51).toString('base64url');", + ) + text = text.replace( + "interface SignedControllerContext {\n readonly workspaceId: string;", + "interface SignedControllerContext {\n readonly keyId: string;\n readonly workspaceId: string;", + ) + text = text.replace( + "`life-os.ai-context.v1\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${issuedAt}\\n${method}\\n${path}`", + "`life-os.ai-context.v2\\n${GATEWAY_KEY_ID}\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${issuedAt}\\n${method}\\n${path}`", + ) + text = text.replace( + " return {\n workspaceId: WORKSPACE_ID,", + " return {\n keyId: GATEWAY_KEY_ID,\n workspaceId: WORKSPACE_ID,", + 1, + ) + text = text.replace( + " vi.stubEnv('AI_GATEWAY_CONTEXT_SECRET', GATEWAY_SECRET);", + " vi.stubEnv('AI_GATEWAY_ACTIVE_KEY_ID', GATEWAY_KEY_ID);\n vi.stubEnv('AI_GATEWAY_ACTIVE_KEY_SECRET', GATEWAY_SECRET);", + ) + for variable in [ + 'proposalContext', + 'listContext', + 'detailContext', + 'decisionsReadContext', + 'decisionsWriteContext', + ]: + text = text.replace( + f"(\n {variable}.workspaceId,", + f"(\n {variable}.keyId,\n {variable}.workspaceId,", + ) + text = text.replace( + f"(\n {variable}.workspaceId,", + f"(\n {variable}.keyId,\n {variable}.workspaceId,", + ) + text = text.replace( + f"(\n undefined,\n {variable}.actorId,", + f"(\n {variable}.keyId,\n undefined,\n {variable}.actorId,", + ) + # Actor-missing decision call still needs a key identifier first. + text = text.replace( + "controller.appendDecision(\n decisionsWriteContext.workspaceId,\n undefined,", + "controller.appendDecision(\n decisionsWriteContext.keyId,\n decisionsWriteContext.workspaceId,\n undefined,", + ) + write(path, text) + + # No-silent-mutation HTTP integration. + path = 'apps/ai-service/src/no-silent-mutation.integration.test.ts' + text = read(path) + text = text.replace( + "const GATEWAY_SECRET = Buffer.alloc(32, 7).toString('base64url');", + "const GATEWAY_KEY_ID = 'gateway-2026-08-a';\nconst GATEWAY_SECRET = Buffer.alloc(32, 7).toString('base64url');", + ) + text = text.replace( + "`life-os.ai-context.v1\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${issuedAt}\\nPOST\\n${path}`", + "`life-os.ai-context.v2\\n${GATEWAY_KEY_ID}\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${issuedAt}\\nPOST\\n${path}`", + ) + text = text.replace( + " return {\n 'x-life-os-workspace-id': WORKSPACE_ID,", + " return {\n 'x-life-os-context-key-id': GATEWAY_KEY_ID,\n 'x-life-os-workspace-id': WORKSPACE_ID,", + ) + text = text.replace( + " const originalSecret = process.env.AI_GATEWAY_CONTEXT_SECRET;\n process.env.AI_GATEWAY_CONTEXT_SECRET = GATEWAY_SECRET;", + " const originalKeyId = process.env.AI_GATEWAY_ACTIVE_KEY_ID;\n const originalSecret = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;\n process.env.AI_GATEWAY_ACTIVE_KEY_ID = GATEWAY_KEY_ID;\n process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = GATEWAY_SECRET;", + ) + text = text.replace( + " if (originalSecret === undefined) {\n delete process.env.AI_GATEWAY_CONTEXT_SECRET;\n } else {\n process.env.AI_GATEWAY_CONTEXT_SECRET = originalSecret;\n }", + " if (originalKeyId === undefined) {\n delete process.env.AI_GATEWAY_ACTIVE_KEY_ID;\n } else {\n process.env.AI_GATEWAY_ACTIVE_KEY_ID = originalKeyId;\n }\n if (originalSecret === undefined) {\n delete process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;\n } else {\n process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = originalSecret;\n }", + ) + write(path, text) + + # Production PostgreSQL HTTP integration and rotation lifecycle evidence. + path = 'apps/ai-service/src/proposal-audit-http.integration.test.ts' + text = read(path) + text = text.replace( + "const ORIGINAL_GATEWAY_CONTEXT_SECRET = process.env.AI_GATEWAY_CONTEXT_SECRET;\nconst GATEWAY_CONTEXT_SECRET = 'ai-http-integration-gateway-secret-32-bytes';", + "const ORIGINAL_ACTIVE_KEY_ID = process.env.AI_GATEWAY_ACTIVE_KEY_ID;\nconst ORIGINAL_ACTIVE_KEY_SECRET = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;\nconst ORIGINAL_PREVIOUS_KEY_ID = process.env.AI_GATEWAY_PREVIOUS_KEY_ID;\nconst ORIGINAL_PREVIOUS_KEY_SECRET = process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET;\nconst ACTIVE_KEY_ID = 'gateway-2026-08-b';\nconst PREVIOUS_KEY_ID = 'gateway-2026-07-a';\nconst ACTIVE_KEY_SECRET = Buffer.alloc(32, 0x41).toString('base64url');\nconst PREVIOUS_KEY_SECRET = Buffer.alloc(32, 0x42).toString('base64url');", + ) + text = text.replace( + " secret?: string;\n}", + " keyId?: string;\n secret?: string;\n}", + 1, + ) + text = text.replace( + " const signature = createHmac('sha256', input.secret ?? GATEWAY_CONTEXT_SECRET)\n .update(\n `life-os.ai-context.v1\\n${workspaceId}\\n${actorId}\\n${issuedAt}\\n${input.method}\\n${input.path}`, + " const keyId = input.keyId ?? ACTIVE_KEY_ID;\n const signature = createHmac('sha256', input.secret ?? ACTIVE_KEY_SECRET)\n .update(\n `life-os.ai-context.v2\\n${keyId}\\n${workspaceId}\\n${actorId}\\n${issuedAt}\\n${input.method}\\n${input.path}`, + ) + text = text.replace( + " return {\n 'x-life-os-workspace-id': workspaceId,", + " return {\n 'x-life-os-context-key-id': keyId,\n 'x-life-os-workspace-id': workspaceId,", + ) + text = text.replace( + " process.env.AI_GATEWAY_CONTEXT_SECRET = GATEWAY_CONTEXT_SECRET;", + " process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID;\n process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET;\n delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID;\n delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET;", + ) + old_restore = """ if (ORIGINAL_GATEWAY_CONTEXT_SECRET === undefined) { + delete process.env.AI_GATEWAY_CONTEXT_SECRET; + } else { + process.env.AI_GATEWAY_CONTEXT_SECRET = ORIGINAL_GATEWAY_CONTEXT_SECRET; + } + """.replace(' ', '') + new_restore = """ for (const [name, value] of [ + ['AI_GATEWAY_ACTIVE_KEY_ID', ORIGINAL_ACTIVE_KEY_ID], + ['AI_GATEWAY_ACTIVE_KEY_SECRET', ORIGINAL_ACTIVE_KEY_SECRET], + ['AI_GATEWAY_PREVIOUS_KEY_ID', ORIGINAL_PREVIOUS_KEY_ID], + ['AI_GATEWAY_PREVIOUS_KEY_SECRET', ORIGINAL_PREVIOUS_KEY_SECRET], + ] as const) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + """.replace(' ', '') + text = replace_once(text, old_restore, new_restore, 'AI integration env restore') + insertion_marker = """ it('persists proposals across restarts and appends replay-safe decisions', async () => { + """.replace(' ', '') + rotation_test = """ it('accepts one previous in-flight key during overlap and rejects it after retirement', async () => { + const workspaceId = randomUUID(); + const actorId = randomUUID(); + const taskId = randomUUID(); + process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; + process.env.AI_GATEWAY_PREVIOUS_KEY_ID = PREVIOUS_KEY_ID; + process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET = PREVIOUS_KEY_SECRET; + const app = await NestFactory.create(AiProductionModule, { logger: false }); + await app.listen(0, '127.0.0.1'); + try { + const address = app.getHttpServer().address() as AddressInfo; + const overlap = await requestJson( + address, + 'POST', + '/v1/proposals', + proposalRequest(taskId), + signedContextHeaders({ + workspaceId, + actorId, + method: 'POST', + path: '/v1/proposals', + keyId: PREVIOUS_KEY_ID, + secret: PREVIOUS_KEY_SECRET, + }), + ); + expect(overlap.statusCode).toBe(201); + + delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; + const retired = await requestJson( + address, + 'POST', + '/v1/proposals', + proposalRequest(randomUUID()), + signedContextHeaders({ + workspaceId, + actorId, + method: 'POST', + path: '/v1/proposals', + keyId: PREVIOUS_KEY_ID, + secret: PREVIOUS_KEY_SECRET, + }), + ); + expect(retired).toMatchObject({ + statusCode: 401, + body: { code: 'invalid_gateway_context' }, + }); + } finally { + await app.close(); + process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; + } + }); + + """.replace(' ', '') + text = replace_once(text, insertion_marker, rotation_test + insertion_marker, 'AI overlap integration') + write(path, text) + + # Public example exposes active signer and optional previous verifier only. + path = '.env.example' + text = read(path) + text = replace_once( + text, + 'AI_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes\n', + 'AI_GATEWAY_ACTIVE_KEY_ID=gateway-2026-08-a\nAI_GATEWAY_ACTIVE_KEY_SECRET=replace-with-at-least-32-random-bytes\nAI_GATEWAY_PREVIOUS_KEY_ID=\nAI_GATEWAY_PREVIOUS_KEY_SECRET=\n', + 'environment example', + ) + write(path, text) + PY + + - name: Format modified sources + run: | + pnpm exec prettier --single-quote --write \ + apps/web/app/ai-proposal-client-core.ts \ + apps/web/app/ai-proposal-client.ts \ + apps/web/app/ai-proposal-client.test.ts \ + apps/web/app/ai-proposal-scope-regression.test.ts \ + apps/web/app/ai-proposal-identity-stream-regression.test.ts \ + apps/web/app/api/ai/proposals/routes.test.ts \ + apps/ai-service/src/quality-coverage.test.ts \ + apps/ai-service/src/no-silent-mutation.integration.test.ts \ + apps/ai-service/src/proposal-audit-http.integration.test.ts + + - name: Commit the wired slice before verification + run: | + set -Eeuo pipefail + rm -f pnpm-lock.yaml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add .env.example apps/web apps/ai-service + git diff --cached --check + git commit -m 'feat(ai): wire versioned keyed gateway context' + git push origin HEAD:feat/ai-gateway-key-rotation + + - name: Verify AI and web boundaries + run: | + pnpm --filter @life-os/ai-service lint + pnpm --filter @life-os/ai-service test + pnpm --filter @life-os/web lint + pnpm --filter @life-os/web test + + - name: Remove one-shot workflow after verified execution + run: | + set -Eeuo pipefail + git rm .github/workflows/complete-ai-key-rotation.yml + git commit -m 'chore(ai): remove key rotation implementation helper' + git push origin HEAD:feat/ai-gateway-key-rotation From 4c456c6d7dc9c263e904e862dd51d648198b2b99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:15:49 +0900 Subject: [PATCH 20/39] ci(ai): repair key rotation helper action pin --- .../workflows/fix-ai-key-rotation-helper.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/fix-ai-key-rotation-helper.yml diff --git a/.github/workflows/fix-ai-key-rotation-helper.yml b/.github/workflows/fix-ai-key-rotation-helper.yml new file mode 100644 index 000000000..da055e172 --- /dev/null +++ b/.github/workflows/fix-ai-key-rotation-helper.yml @@ -0,0 +1,39 @@ +name: Fix AI Key Rotation Helper + +on: + push: + branches: + - feat/ai-gateway-key-rotation + paths: + - '.github/workflows/fix-ai-key-rotation-helper.yml' + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + fix: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout feature branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/ai-gateway-key-rotation + fetch-depth: 0 + + - name: Repair helper action pin and remove this fixer + run: | + set -Eeuo pipefail + sed -i \ + 's#actions/setup-node@fdbd5b5d6a1c9bb1f77880bc2f733d820f4737e0 #actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #' \ + .github/workflows/complete-ai-key-rotation.yml + git rm .github/workflows/fix-ai-key-rotation-helper.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add .github/workflows/complete-ai-key-rotation.yml + git diff --cached --check + git commit -m 'fix(ci): use available setup-node action pin' + git push origin HEAD:feat/ai-gateway-key-rotation From 99c8229cf4cd7134884afe00caf113a0287dea8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:18:39 +0900 Subject: [PATCH 21/39] ci(ai): execute key rotation codemod with valid action pins --- .../workflows/run-ai-key-rotation-codemod.yml | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/run-ai-key-rotation-codemod.yml diff --git a/.github/workflows/run-ai-key-rotation-codemod.yml b/.github/workflows/run-ai-key-rotation-codemod.yml new file mode 100644 index 000000000..a7e7c7015 --- /dev/null +++ b/.github/workflows/run-ai-key-rotation-codemod.yml @@ -0,0 +1,74 @@ +name: Run AI Key Rotation Codemod + +on: + push: + branches: + - feat/ai-gateway-key-rotation + paths: + - '.github/workflows/run-ai-key-rotation-codemod.yml' + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + run: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout feature branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/ai-gateway-key-rotation + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Enable Corepack and install dependencies + run: | + corepack enable + pnpm install --no-frozen-lockfile + + - name: Extract and execute reviewed codemod + run: | + set -Eeuo pipefail + awk "/python3 - <<'PY'/{capture=1; next} /^ PY$/{capture=0} capture{sub(/^ /, \"\"); print}" \ + .github/workflows/complete-ai-key-rotation.yml > /tmp/complete-ai-key-rotation.py + test -s /tmp/complete-ai-key-rotation.py + python3 /tmp/complete-ai-key-rotation.py + + - name: Format modified sources + run: | + pnpm exec prettier --single-quote --write \ + apps/web/app/ai-proposal-client-core.ts \ + apps/web/app/ai-proposal-client.ts \ + apps/web/app/ai-proposal-client.test.ts \ + apps/web/app/ai-proposal-scope-regression.test.ts \ + apps/web/app/ai-proposal-identity-stream-regression.test.ts \ + apps/web/app/api/ai/proposals/routes.test.ts \ + apps/ai-service/src/quality-coverage.test.ts \ + apps/ai-service/src/no-silent-mutation.integration.test.ts \ + apps/ai-service/src/proposal-audit-http.integration.test.ts + + - name: Commit codemod result + run: | + set -Eeuo pipefail + rm -f pnpm-lock.yaml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add .env.example apps/web apps/ai-service + git diff --cached --check + git commit -m 'feat(ai): wire versioned keyed gateway context' + git push origin HEAD:feat/ai-gateway-key-rotation + + - name: Verify AI and web boundaries + run: | + pnpm --filter @life-os/ai-service lint + pnpm --filter @life-os/ai-service test + pnpm --filter @life-os/web lint + pnpm --filter @life-os/web test From b5e273b1c6bb311590a8de1946ad461d26aa2c1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:21:25 +0900 Subject: [PATCH 22/39] fix(ci): defer malformed integration codemod segment --- .../workflows/run-ai-key-rotation-codemod.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-ai-key-rotation-codemod.yml b/.github/workflows/run-ai-key-rotation-codemod.yml index a7e7c7015..2da09f7ae 100644 --- a/.github/workflows/run-ai-key-rotation-codemod.yml +++ b/.github/workflows/run-ai-key-rotation-codemod.yml @@ -40,6 +40,22 @@ jobs: awk "/python3 - <<'PY'/{capture=1; next} /^ PY$/{capture=0} capture{sub(/^ /, \"\"); print}" \ .github/workflows/complete-ai-key-rotation.yml > /tmp/complete-ai-key-rotation.py test -s /tmp/complete-ai-key-rotation.py + python3 - <<'PY' + from pathlib import Path + + path = Path('/tmp/complete-ai-key-rotation.py') + source = path.read_text(encoding='utf-8') + start_marker = '# Production PostgreSQL HTTP integration and rotation lifecycle evidence.' + end_marker = '# Public example exposes active signer and optional previous verifier only.' + start = source.index(start_marker) + end = source.index(end_marker, start) + source = ( + source[:start] + + '# Production PostgreSQL integration is applied by a focused follow-up.\n' + + source[end:] + ) + path.write_text(source, encoding='utf-8') + PY python3 /tmp/complete-ai-key-rotation.py - name: Format modified sources @@ -52,8 +68,7 @@ jobs: apps/web/app/ai-proposal-identity-stream-regression.test.ts \ apps/web/app/api/ai/proposals/routes.test.ts \ apps/ai-service/src/quality-coverage.test.ts \ - apps/ai-service/src/no-silent-mutation.integration.test.ts \ - apps/ai-service/src/proposal-audit-http.integration.test.ts + apps/ai-service/src/no-silent-mutation.integration.test.ts - name: Commit codemod result run: | From e8f3648ae7bb5cefb27351cc3e3324c8b746929b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:21:43 +0000 Subject: [PATCH 23/39] feat(ai): wire versioned keyed gateway context --- .env.example | 5 ++- .../no-silent-mutation.integration.test.ts | 19 +++++--- apps/ai-service/src/quality-coverage.test.ts | 22 +++++++++- apps/web/app/ai-proposal-client-core.ts | 43 +++++++++++++++---- apps/web/app/ai-proposal-client.test.ts | 25 +++++++++-- apps/web/app/ai-proposal-client.ts | 12 +++++- ...roposal-identity-stream-regression.test.ts | 4 +- .../app/ai-proposal-scope-regression.test.ts | 4 +- apps/web/app/api/ai/proposals/routes.test.ts | 16 +++++-- 9 files changed, 123 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index cdefaa4e1..99fd04b1b 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,10 @@ IDENTITY_SERVICE_ORIGIN=http://127.0.0.1:4101 PLANNING_SERVICE_ORIGIN=http://127.0.0.1:4102 PLANNING_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes AI_SERVICE_ORIGIN=http://127.0.0.1:4105 -AI_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes +AI_GATEWAY_ACTIVE_KEY_ID=gateway-2026-08-a +AI_GATEWAY_ACTIVE_KEY_SECRET=replace-with-at-least-32-random-bytes +AI_GATEWAY_PREVIOUS_KEY_ID= +AI_GATEWAY_PREVIOUS_KEY_SECRET= SESSION_SECRET=replace-with-at-least-32-random-bytes GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= diff --git a/apps/ai-service/src/no-silent-mutation.integration.test.ts b/apps/ai-service/src/no-silent-mutation.integration.test.ts index 6d7bd8cdb..608190739 100644 --- a/apps/ai-service/src/no-silent-mutation.integration.test.ts +++ b/apps/ai-service/src/no-silent-mutation.integration.test.ts @@ -14,6 +14,7 @@ const WORKSPACE_ID = '43eab0ee-0f7b-4c7f-9331-b133f2647675'; const ACTOR_ID = 'd19b6077-2baa-4f84-97f6-c138b1d6ba34'; const TASK_ID = 'e29c36af-999a-407f-9ca9-cfe194ab51f4'; const PROPOSAL_ID = 'aedcb1d1-cc60-42c6-9357-ec90821fce1b'; +const GATEWAY_KEY_ID = 'gateway-2026-08-a'; const GATEWAY_SECRET = Buffer.alloc(32, 7).toString('base64url'); interface JsonHttpResponse { @@ -48,11 +49,12 @@ function signedContextHeaders(path: string): Readonly> { const issuedAt = String(Math.floor(Date.now() / 1000)); const signature = createHmac('sha256', GATEWAY_SECRET) .update( - `life-os.ai-context.v1\n${WORKSPACE_ID}\n${ACTOR_ID}\n${issuedAt}\nPOST\n${path}`, + `life-os.ai-context.v2\n${GATEWAY_KEY_ID}\n${WORKSPACE_ID}\n${ACTOR_ID}\n${issuedAt}\nPOST\n${path}`, 'utf8', ) .digest('base64url'); return { + 'x-life-os-context-key-id': GATEWAY_KEY_ID, 'x-life-os-workspace-id': WORKSPACE_ID, 'x-life-os-actor-id': ACTOR_ID, 'x-life-os-context-issued-at': issuedAt, @@ -163,8 +165,10 @@ describe('AI proposal no-silent-mutation contract', () => { it('exercises the authenticated HTTP module without exposing a mutation route', async () => { const state = userOwnedState(); const before = JSON.stringify(state); - const originalSecret = process.env.AI_GATEWAY_CONTEXT_SECRET; - process.env.AI_GATEWAY_CONTEXT_SECRET = GATEWAY_SECRET; + const originalKeyId = process.env.AI_GATEWAY_ACTIVE_KEY_ID; + const originalSecret = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET; + process.env.AI_GATEWAY_ACTIVE_KEY_ID = GATEWAY_KEY_ID; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = GATEWAY_SECRET; const app = await NestFactory.create(AiAppModule, { logger: false }); await app.listen(0, '127.0.0.1'); try { @@ -192,10 +196,15 @@ describe('AI proposal no-silent-mutation contract', () => { expect(unsupportedMutation.statusCode).toBe(404); } finally { await app.close(); + if (originalKeyId === undefined) { + delete process.env.AI_GATEWAY_ACTIVE_KEY_ID; + } else { + process.env.AI_GATEWAY_ACTIVE_KEY_ID = originalKeyId; + } if (originalSecret === undefined) { - delete process.env.AI_GATEWAY_CONTEXT_SECRET; + delete process.env.AI_GATEWAY_ACTIVE_KEY_SECRET; } else { - process.env.AI_GATEWAY_CONTEXT_SECRET = originalSecret; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = originalSecret; } } }); diff --git a/apps/ai-service/src/quality-coverage.test.ts b/apps/ai-service/src/quality-coverage.test.ts index f017293d4..068385fd4 100644 --- a/apps/ai-service/src/quality-coverage.test.ts +++ b/apps/ai-service/src/quality-coverage.test.ts @@ -54,10 +54,12 @@ const EVENT_ID = '55555555-5555-4555-8555-555555555555'; const IDEMPOTENCY_KEY = '66666666-6666-4666-8666-666666666666'; const OTHER_EVENT_ID = '77777777-7777-4777-8777-777777777777'; const OTHER_ACTOR_ID = '88888888-8888-4888-8888-888888888888'; +const GATEWAY_KEY_ID = 'gateway-2026-08-a'; const GATEWAY_SECRET = Buffer.alloc(32, 0x51).toString('base64url'); /** Exact signed headers accepted by one method-and-path-bound controller call. */ interface SignedControllerContext { + readonly keyId: string; readonly workspaceId: string; readonly actorId: string; readonly issuedAt: string; @@ -72,11 +74,12 @@ function signedControllerContext( const issuedAt = String(Math.floor(Date.now() / 1_000)); const signature = createHmac('sha256', GATEWAY_SECRET) .update( - `life-os.ai-context.v1\n${WORKSPACE_ID}\n${ACTOR_ID}\n${issuedAt}\n${method}\n${path}`, + `life-os.ai-context.v2\n${GATEWAY_KEY_ID}\n${WORKSPACE_ID}\n${ACTOR_ID}\n${issuedAt}\n${method}\n${path}`, 'utf8', ) .digest('base64url'); return { + keyId: GATEWAY_KEY_ID, workspaceId: WORKSPACE_ID, actorId: ACTOR_ID, issuedAt, @@ -800,7 +803,8 @@ describe('PostgreSQL proposal audit residual safety branches', () => { describe('AI controllers and bootstrap error contracts', () => { beforeEach(() => { - vi.stubEnv('AI_GATEWAY_CONTEXT_SECRET', GATEWAY_SECRET); + vi.stubEnv('AI_GATEWAY_ACTIVE_KEY_ID', GATEWAY_KEY_ID); + vi.stubEnv('AI_GATEWAY_ACTIVE_KEY_SECRET', GATEWAY_SECRET); }); afterEach(() => { @@ -820,6 +824,7 @@ describe('AI controllers and bootstrap error contracts', () => { }); await expect( controller.createProposal( + proposalContext.keyId, proposalContext.workspaceId, proposalContext.actorId, proposalContext.issuedAt, @@ -830,6 +835,7 @@ describe('AI controllers and bootstrap error contracts', () => { await expectProblem( controller.createProposal( + proposalContext.keyId, undefined, proposalContext.actorId, proposalContext.issuedAt, @@ -841,6 +847,7 @@ describe('AI controllers and bootstrap error contracts', () => { ); await expectProblem( controller.createProposal( + proposalContext.keyId, proposalContext.workspaceId, proposalContext.actorId, proposalContext.issuedAt, @@ -862,6 +869,7 @@ describe('AI controllers and bootstrap error contracts', () => { throw error; }, }).createProposal( + proposalContext.keyId, proposalContext.workspaceId, proposalContext.actorId, proposalContext.issuedAt, @@ -896,6 +904,7 @@ describe('AI controllers and bootstrap error contracts', () => { await expect( controller.listProposals( + listContext.keyId, listContext.workspaceId, listContext.actorId, listContext.issuedAt, @@ -904,6 +913,7 @@ describe('AI controllers and bootstrap error contracts', () => { ).resolves.toEqual([audit]); await expect( controller.findProposal( + detailContext.keyId, detailContext.workspaceId, detailContext.actorId, detailContext.issuedAt, @@ -913,6 +923,7 @@ describe('AI controllers and bootstrap error contracts', () => { ).resolves.toEqual(audit); await expect( controller.listDecisions( + decisionsReadContext.keyId, decisionsReadContext.workspaceId, decisionsReadContext.actorId, decisionsReadContext.issuedAt, @@ -922,6 +933,7 @@ describe('AI controllers and bootstrap error contracts', () => { ).resolves.toEqual([event]); await expect( controller.appendDecision( + decisionsWriteContext.keyId, decisionsWriteContext.workspaceId, decisionsWriteContext.actorId, decisionsWriteContext.issuedAt, @@ -938,6 +950,7 @@ describe('AI controllers and bootstrap error contracts', () => { await expectProblem( controller.listProposals( + listContext.keyId, undefined, listContext.actorId, listContext.issuedAt, @@ -948,6 +961,7 @@ describe('AI controllers and bootstrap error contracts', () => { ); await expectProblem( controller.findProposal( + detailContext.keyId, undefined, detailContext.actorId, detailContext.issuedAt, @@ -959,6 +973,7 @@ describe('AI controllers and bootstrap error contracts', () => { ); await expectProblem( controller.listDecisions( + decisionsReadContext.keyId, undefined, decisionsReadContext.actorId, decisionsReadContext.issuedAt, @@ -970,6 +985,7 @@ describe('AI controllers and bootstrap error contracts', () => { ); await expectProblem( controller.appendDecision( + decisionsWriteContext.keyId, undefined, decisionsWriteContext.actorId, decisionsWriteContext.issuedAt, @@ -982,6 +998,7 @@ describe('AI controllers and bootstrap error contracts', () => { ); await expectProblem( controller.appendDecision( + decisionsWriteContext.keyId, decisionsWriteContext.workspaceId, undefined, decisionsWriteContext.issuedAt, @@ -1012,6 +1029,7 @@ describe('AI controllers and bootstrap error contracts', () => { for (const [error, status, code] of cases) { await expectProblem( new AiProposalAuditController(throwingApplication(error)).listProposals( + listContext.keyId, listContext.workspaceId, listContext.actorId, listContext.issuedAt, diff --git a/apps/web/app/ai-proposal-client-core.ts b/apps/web/app/ai-proposal-client-core.ts index 9488a968c..fa559c223 100644 --- a/apps/web/app/ai-proposal-client-core.ts +++ b/apps/web/app/ai-proposal-client-core.ts @@ -4,6 +4,7 @@ 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 CANONICAL_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}$/u; +const AI_GATEWAY_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; const SHA_256_PATTERN = /^[0-9a-f]{64}$/u; const RFC_3339_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/u; @@ -41,6 +42,12 @@ export interface AiSessionPrincipal { readonly actorId: string; } +/** Active server-only key used to sign one exact AI service request. */ +export interface AiGatewaySigningKey { + readonly keyId: string; + readonly secret: string; +} + /** Internal marker distinguishing invalid browser input from dependency failures. */ class InvalidAiRequestError extends Error { constructor() { @@ -204,6 +211,24 @@ export function requireAiGatewaySecret(value: string | undefined): string { return value; } +/** Requires one bounded case-sensitive key identifier. */ +export function requireAiGatewayKeyId(value: string | undefined): string { + if (typeof value !== 'string' || !AI_GATEWAY_KEY_ID_PATTERN.test(value)) { + throw new Error('AI gateway context key identifier is invalid'); + } + return value; +} + +/** Parses exactly the active web signing pair and exposes no previous key. */ +export function requireAiGatewaySigningKey( + environment: WebEnvironment, +): AiGatewaySigningKey { + return Object.freeze({ + keyId: requireAiGatewayKeyId(environment.AI_GATEWAY_ACTIVE_KEY_ID), + secret: requireAiGatewaySecret(environment.AI_GATEWAY_ACTIVE_KEY_SECRET), + }); +} + /** Extracts only workspace and actor identity from identity-service session data. */ export function parseAiSessionPrincipal(value: unknown): AiSessionPrincipal { if (!isPlainObject(value)) { @@ -249,7 +274,7 @@ function requireAiTarget( export function createAiContextHeaders( workspaceId: string, actorId: string, - secretValue: string, + signingKeyValue: AiGatewaySigningKey, nowSeconds: number, methodValue: unknown, pathValue: unknown, @@ -259,19 +284,23 @@ export function createAiContextHeaders( 'AI gateway context is invalid', ); const safeActorId = requireUuid(actorId, 'AI gateway context is invalid'); - const secret = requireAiGatewaySecret(secretValue); + const signingKey = Object.freeze({ + keyId: requireAiGatewayKeyId(signingKeyValue?.keyId), + secret: requireAiGatewaySecret(signingKeyValue?.secret), + }); const { method, path } = requireAiTarget(methodValue, pathValue); if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { throw new Error('AI gateway context is invalid'); } const issuedAt = String(nowSeconds); - const signature = createHmac('sha256', secret) + const signature = createHmac('sha256', signingKey.secret) .update( - `life-os.ai-context.v1\n${safeWorkspaceId}\n${safeActorId}\n${issuedAt}\n${method}\n${path}`, + `life-os.ai-context.v2\n${signingKey.keyId}\n${safeWorkspaceId}\n${safeActorId}\n${issuedAt}\n${method}\n${path}`, 'utf8', ) .digest('base64url'); return Object.freeze({ + 'x-life-os-context-key-id': signingKey.keyId, 'x-life-os-workspace-id': safeWorkspaceId, 'x-life-os-actor-id': safeActorId, 'x-life-os-context-issued-at': issuedAt, @@ -819,9 +848,7 @@ export async function handleAiProposalRequest( environment.IDENTITY_SERVICE_ORIGIN, ); const aiOrigin = requireAiServiceOrigin(environment.AI_SERVICE_ORIGIN); - const secret = requireAiGatewaySecret( - environment.AI_GATEWAY_CONTEXT_SECRET, - ); + const signingKey = requireAiGatewaySigningKey(environment); const identityResponse = await fetcher( new URL('/v1/session', identityOrigin), { @@ -852,7 +879,7 @@ export async function handleAiProposalRequest( const contextHeaders = createAiContextHeaders( principal.workspaceId, principal.actorId, - secret, + signingKey, nowSeconds, parsedRequest.method, parsedRequest.path, diff --git a/apps/web/app/ai-proposal-client.test.ts b/apps/web/app/ai-proposal-client.test.ts index 627bb6388..e8506500e 100644 --- a/apps/web/app/ai-proposal-client.test.ts +++ b/apps/web/app/ai-proposal-client.test.ts @@ -5,7 +5,9 @@ import { createAiContextHeaders, handleAiProposalRequest, parseAiSessionPrincipal, + requireAiGatewayKeyId, requireAiGatewaySecret, + requireAiGatewaySigningKey, requireAiServiceOrigin, type AiProposalFetch, type AiProposalRoute, @@ -18,13 +20,15 @@ const PROPOSAL_ID = '44444444-4444-4444-8444-444444444444'; const TASK_ID = '55555555-5555-4555-8555-555555555555'; const DECISION_ID = '66666666-6666-4666-8666-666666666666'; const IDEMPOTENCY_KEY = '77777777-7777-4777-8777-777777777777'; +const ACTIVE_KEY_ID = 'gateway-2026-08-a'; const GATEWAY_SECRET = Buffer.alloc(32, 7).toString('base64url'); const NOW_SECONDS = 1_785_806_400; const environment = { IDENTITY_SERVICE_ORIGIN: 'http://identity-service:4101', AI_SERVICE_ORIGIN: 'http://ai-service:4105', - AI_GATEWAY_CONTEXT_SECRET: GATEWAY_SECRET, + AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_KEY_ID, + AI_GATEWAY_ACTIVE_KEY_SECRET: GATEWAY_SECRET, }; const proposalRequest = { @@ -141,7 +145,7 @@ function browserRequest( function expectedSignature(method: string, path: string): string { return createHmac('sha256', GATEWAY_SECRET) .update( - `life-os.ai-context.v1\n${WORKSPACE_ID}\n${ACTOR_ID}\n${NOW_SECONDS}\n${method}\n${path}`, + `life-os.ai-context.v2\n${ACTIVE_KEY_ID}\n${WORKSPACE_ID}\n${ACTOR_ID}\n${NOW_SECONDS}\n${method}\n${path}`, 'utf8', ) .digest('base64url'); @@ -203,6 +207,7 @@ describe('authenticated AI proposal BFF', () => { assert.equal(aiHeaders.get('authorization'), null); assert.equal(aiHeaders.get('x-workspace-id'), null); assert.equal(aiHeaders.get('x-actor-id'), null); + assert.equal(aiHeaders.get('x-life-os-context-key-id'), ACTIVE_KEY_ID); assert.equal(aiHeaders.get('x-life-os-workspace-id'), WORKSPACE_ID); assert.equal(aiHeaders.get('x-life-os-actor-id'), ACTOR_ID); assert.equal( @@ -488,7 +493,7 @@ describe('authenticated AI proposal BFF', () => { fetcher: AiProposalFetch; }> = [ { - environment: { ...environment, AI_GATEWAY_CONTEXT_SECRET: 'short' }, + environment: { ...environment, AI_GATEWAY_ACTIVE_KEY_SECRET: 'short' }, fetcher: async () => sessionResponse(), }, { @@ -570,7 +575,12 @@ describe('AI proposal BFF helpers', () => { requireAiServiceOrigin('https://ai.example.test'), 'https://ai.example.test', ); + assert.equal(requireAiGatewayKeyId(ACTIVE_KEY_ID), ACTIVE_KEY_ID); assert.equal(requireAiGatewaySecret(GATEWAY_SECRET), GATEWAY_SECRET); + assert.deepEqual(requireAiGatewaySigningKey(environment), { + keyId: ACTIVE_KEY_ID, + secret: GATEWAY_SECRET, + }); assert.deepEqual( parseAiSessionPrincipal({ sessionId: SESSION_ID, @@ -584,11 +594,12 @@ describe('AI proposal BFF helpers', () => { const headers = createAiContextHeaders( WORKSPACE_ID, ACTOR_ID, - GATEWAY_SECRET, + { keyId: ACTIVE_KEY_ID, secret: GATEWAY_SECRET }, NOW_SECONDS, 'POST', `/v1/proposals/${PROPOSAL_ID}/decisions`, ); + assert.equal(headers['x-life-os-context-key-id'], ACTIVE_KEY_ID); assert.equal(headers['x-life-os-workspace-id'], WORKSPACE_ID); assert.equal(headers['x-life-os-actor-id'], ACTOR_ID); assert.equal(headers['x-life-os-context-issued-at'], String(NOW_SECONDS)); @@ -612,6 +623,12 @@ describe('AI proposal BFF helpers', () => { new Error('AI service origin is invalid'), ); } + for (const keyId of ['', '-leading', 'white space', 'a'.repeat(65)]) { + assert.throws( + () => requireAiGatewayKeyId(keyId), + new Error('AI gateway context key identifier is invalid'), + ); + } for (const secret of [ '', 'short', diff --git a/apps/web/app/ai-proposal-client.ts b/apps/web/app/ai-proposal-client.ts index 9d6647c26..26dbc172d 100644 --- a/apps/web/app/ai-proposal-client.ts +++ b/apps/web/app/ai-proposal-client.ts @@ -2,20 +2,30 @@ import { createAiContextHeaders, handleAiProposalRequest as handleAiProposalRequestCore, parseAiSessionPrincipal, + requireAiGatewayKeyId, requireAiGatewaySecret, + requireAiGatewaySigningKey, requireAiServiceOrigin, type AiProposalFetch, type AiProposalRoute, + type AiGatewaySigningKey, type AiSessionPrincipal, } from './ai-proposal-client-core'; export { createAiContextHeaders, parseAiSessionPrincipal, + requireAiGatewayKeyId, requireAiGatewaySecret, + requireAiGatewaySigningKey, requireAiServiceOrigin, }; -export type { AiProposalFetch, AiProposalRoute, AiSessionPrincipal }; +export type { + AiGatewaySigningKey, + AiProposalFetch, + AiProposalRoute, + AiSessionPrincipal, +}; const MAXIMUM_IDENTITY_RESPONSE_BYTES = 32 * 1024; diff --git a/apps/web/app/ai-proposal-identity-stream-regression.test.ts b/apps/web/app/ai-proposal-identity-stream-regression.test.ts index a59f998ad..70ee0ad65 100644 --- a/apps/web/app/ai-proposal-identity-stream-regression.test.ts +++ b/apps/web/app/ai-proposal-identity-stream-regression.test.ts @@ -10,13 +10,15 @@ const ACTOR_ID = '22222222-2222-4222-8222-222222222222'; const SESSION_ID = '33333333-3333-4333-8333-333333333333'; const PROPOSAL_ID = '44444444-4444-4444-8444-444444444444'; const TASK_ID = '55555555-5555-4555-8555-555555555555'; +const ACTIVE_KEY_ID = 'gateway-2026-08-a'; const GATEWAY_SECRET = 'trusted-ai-gateway-context-secret-32-bytes'; const NOW_SECONDS = 1_785_806_400; const environment = { IDENTITY_SERVICE_ORIGIN: 'http://identity-service:4101', AI_SERVICE_ORIGIN: 'http://ai-service:4105', - AI_GATEWAY_CONTEXT_SECRET: GATEWAY_SECRET, + AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_KEY_ID, + AI_GATEWAY_ACTIVE_KEY_SECRET: GATEWAY_SECRET, }; /** Creates one bounded JSON response for deterministic dependency simulation. */ diff --git a/apps/web/app/ai-proposal-scope-regression.test.ts b/apps/web/app/ai-proposal-scope-regression.test.ts index 2678fac8d..ba332f2b0 100644 --- a/apps/web/app/ai-proposal-scope-regression.test.ts +++ b/apps/web/app/ai-proposal-scope-regression.test.ts @@ -15,13 +15,15 @@ const PROPOSAL_ID = '66666666-6666-4666-8666-666666666666'; const TASK_ID = '77777777-7777-4777-8777-777777777777'; const DECISION_ID = '88888888-8888-4888-8888-888888888888'; const IDEMPOTENCY_KEY = '99999999-9999-4999-8999-999999999999'; +const ACTIVE_KEY_ID = 'gateway-2026-08-a'; const SECRET = 'authenticated-ai-scope-regression-secret'; const NOW_SECONDS = 1_785_806_400; const environment = { IDENTITY_SERVICE_ORIGIN: 'http://identity-service:4101', AI_SERVICE_ORIGIN: 'http://ai-service:4105', - AI_GATEWAY_CONTEXT_SECRET: SECRET, + AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_KEY_ID, + AI_GATEWAY_ACTIVE_KEY_SECRET: SECRET, }; /** Creates one bounded JSON response for deterministic dependency simulation. */ diff --git a/apps/web/app/api/ai/proposals/routes.test.ts b/apps/web/app/api/ai/proposals/routes.test.ts index 771d1828b..02fb3a440 100644 --- a/apps/web/app/api/ai/proposals/routes.test.ts +++ b/apps/web/app/api/ai/proposals/routes.test.ts @@ -14,6 +14,7 @@ const PROPOSAL_ID = '44444444-4444-4444-8444-444444444444'; const TASK_ID = '55555555-5555-4555-8555-555555555555'; const DECISION_ID = '66666666-6666-4666-8666-666666666666'; const IDEMPOTENCY_KEY = '77777777-7777-4777-8777-777777777777'; +const ACTIVE_KEY_ID = 'gateway-2026-08-a'; const SECRET = 'trusted-ai-gateway-context-secret-32-bytes'; const proposalRequest = { @@ -99,11 +100,13 @@ describe('AI proposal Next.js route handlers', () => { const originalFetch = globalThis.fetch; const originalIdentityOrigin = process.env.IDENTITY_SERVICE_ORIGIN; const originalAiOrigin = process.env.AI_SERVICE_ORIGIN; - const originalSecret = process.env.AI_GATEWAY_CONTEXT_SECRET; + const originalKeyId = process.env.AI_GATEWAY_ACTIVE_KEY_ID; + const originalSecret = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET; const aiCalls: Array<{ method: string; path: string }> = []; process.env.IDENTITY_SERVICE_ORIGIN = 'http://identity-service:4101'; process.env.AI_SERVICE_ORIGIN = 'http://ai-service:4105'; - process.env.AI_GATEWAY_CONTEXT_SECRET = SECRET; + process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = SECRET; globalThis.fetch = async (input, init) => { const url = new URL(String(input)); if (url.pathname === '/v1/session') { @@ -204,10 +207,15 @@ describe('AI proposal Next.js route handlers', () => { } else { process.env.AI_SERVICE_ORIGIN = originalAiOrigin; } + if (originalKeyId === undefined) { + delete process.env.AI_GATEWAY_ACTIVE_KEY_ID; + } else { + process.env.AI_GATEWAY_ACTIVE_KEY_ID = originalKeyId; + } if (originalSecret === undefined) { - delete process.env.AI_GATEWAY_CONTEXT_SECRET; + delete process.env.AI_GATEWAY_ACTIVE_KEY_SECRET; } else { - process.env.AI_GATEWAY_CONTEXT_SECRET = originalSecret; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = originalSecret; } } }); From 96e100f63e2960d3ac0ffd584d81361fb8bc2d09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:22:58 +0900 Subject: [PATCH 24/39] fix(ci): format and verify keyed gateway context --- .../workflows/run-ai-key-rotation-codemod.yml | 53 +++++++------------ 1 file changed, 19 insertions(+), 34 deletions(-) diff --git a/.github/workflows/run-ai-key-rotation-codemod.yml b/.github/workflows/run-ai-key-rotation-codemod.yml index 2da09f7ae..bc086cae8 100644 --- a/.github/workflows/run-ai-key-rotation-codemod.yml +++ b/.github/workflows/run-ai-key-rotation-codemod.yml @@ -1,4 +1,4 @@ -name: Run AI Key Rotation Codemod +name: Verify AI Key Rotation Slice on: push: @@ -14,7 +14,7 @@ env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - run: + verify: runs-on: ubuntu-24.04 timeout-minutes: 30 steps: @@ -34,52 +34,37 @@ jobs: corepack enable pnpm install --no-frozen-lockfile - - name: Extract and execute reviewed codemod - run: | - set -Eeuo pipefail - awk "/python3 - <<'PY'/{capture=1; next} /^ PY$/{capture=0} capture{sub(/^ /, \"\"); print}" \ - .github/workflows/complete-ai-key-rotation.yml > /tmp/complete-ai-key-rotation.py - test -s /tmp/complete-ai-key-rotation.py - python3 - <<'PY' - from pathlib import Path - - path = Path('/tmp/complete-ai-key-rotation.py') - source = path.read_text(encoding='utf-8') - start_marker = '# Production PostgreSQL HTTP integration and rotation lifecycle evidence.' - end_marker = '# Public example exposes active signer and optional previous verifier only.' - start = source.index(start_marker) - end = source.index(end_marker, start) - source = ( - source[:start] - + '# Production PostgreSQL integration is applied by a focused follow-up.\n' - + source[end:] - ) - path.write_text(source, encoding='utf-8') - PY - python3 /tmp/complete-ai-key-rotation.py - - - name: Format modified sources + - name: Format complete keyed-context slice run: | pnpm exec prettier --single-quote --write \ + apps/ai-service/src/ai-gateway-keyring.ts \ + apps/ai-service/src/ai-gateway-keyring.test.ts \ + apps/ai-service/src/ai-http-boundary.ts \ + apps/ai-service/src/ai-http-boundary.test.ts \ + apps/ai-service/src/main.ts \ + apps/ai-service/src/quality-coverage.test.ts \ + apps/ai-service/src/no-silent-mutation.integration.test.ts \ apps/web/app/ai-proposal-client-core.ts \ apps/web/app/ai-proposal-client.ts \ apps/web/app/ai-proposal-client.test.ts \ apps/web/app/ai-proposal-scope-regression.test.ts \ apps/web/app/ai-proposal-identity-stream-regression.test.ts \ - apps/web/app/api/ai/proposals/routes.test.ts \ - apps/ai-service/src/quality-coverage.test.ts \ - apps/ai-service/src/no-silent-mutation.integration.test.ts + apps/web/app/api/ai/proposals/routes.test.ts - - name: Commit codemod result + - name: Commit canonical formatting when needed run: | set -Eeuo pipefail rm -f pnpm-lock.yaml git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add .env.example apps/web apps/ai-service + git add apps/ai-service apps/web git diff --cached --check - git commit -m 'feat(ai): wire versioned keyed gateway context' - git push origin HEAD:feat/ai-gateway-key-rotation + if git diff --cached --quiet; then + echo 'No formatting changes required.' + else + git commit -m 'style(ai): format keyed gateway context slice' + git push origin HEAD:feat/ai-gateway-key-rotation + fi - name: Verify AI and web boundaries run: | From 7f42499d4dfacc42cec42542295fad412157ab6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:23:17 +0000 Subject: [PATCH 25/39] style(ai): format keyed gateway context slice --- apps/ai-service/src/ai-gateway-keyring.ts | 5 +- apps/ai-service/src/ai-http-boundary.test.ts | 74 +++++++++++--------- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/apps/ai-service/src/ai-gateway-keyring.ts b/apps/ai-service/src/ai-gateway-keyring.ts index 9c27dfb2a..481d1835b 100644 --- a/apps/ai-service/src/ai-gateway-keyring.ts +++ b/apps/ai-service/src/ai-gateway-keyring.ts @@ -97,10 +97,7 @@ export function requireAiGatewayContextKeyRing( return Object.freeze({ active }); } const previous = contextKey(previousId, previousSecret); - if ( - previous.keyId === active.keyId || - previous.secret === active.secret - ) { + if (previous.keyId === active.keyId || previous.secret === active.secret) { throw new AiGatewayKeyConfigurationError(); } return Object.freeze({ active, previous }); diff --git a/apps/ai-service/src/ai-http-boundary.test.ts b/apps/ai-service/src/ai-http-boundary.test.ts index 750002592..c6c6cf3d8 100644 --- a/apps/ai-service/src/ai-http-boundary.test.ts +++ b/apps/ai-service/src/ai-http-boundary.test.ts @@ -189,23 +189,26 @@ describe('trusted AI service context', () => { AI_GATEWAY_PREVIOUS_KEY_ID: PREVIOUS_KEY_ID, AI_GATEWAY_PREVIOUS_KEY_SECRET: ACTIVE_SECRET, }, - ])('fails closed when gateway key configuration is unavailable: %#', (keys) => { - expectProblem( - () => - requireTrustedAiContext( - contextHeaders(), - keys, - 'POST', - '/v1/proposals', - NOW_SECONDS, - ), - { - title: 'Trusted gateway context is unavailable', - status: 503, - code: 'gateway_context_unavailable', - }, - ); - }); + ])( + 'fails closed when gateway key configuration is unavailable: %#', + (keys) => { + expectProblem( + () => + requireTrustedAiContext( + contextHeaders(), + keys, + 'POST', + '/v1/proposals', + NOW_SECONDS, + ), + { + title: 'Trusted gateway context is unavailable', + status: 503, + code: 'gateway_context_unavailable', + }, + ); + }, + ); it.each([ { field: 'keyId', value: undefined }, @@ -223,23 +226,26 @@ describe('trusted AI service context', () => { { field: 'signature', value: undefined }, { field: 'signature', value: 'invalid' }, { field: 'signature', value: `${'a'.repeat(42)}!` }, - ])('rejects malformed or unknown context field $field: %#', ({ field, value }) => { - expectProblem( - () => - requireTrustedAiContext( - contextHeaders({ [field]: value }), - overlapEnvironment(), - 'POST', - '/v1/proposals', - NOW_SECONDS, - ), - { - title: 'Trusted gateway context is invalid', - status: 401, - code: 'invalid_gateway_context', - }, - ); - }); + ])( + 'rejects malformed or unknown context field $field: %#', + ({ field, value }) => { + expectProblem( + () => + requireTrustedAiContext( + contextHeaders({ [field]: value }), + overlapEnvironment(), + 'POST', + '/v1/proposals', + NOW_SECONDS, + ), + { + title: 'Trusted gateway context is invalid', + status: 401, + code: 'invalid_gateway_context', + }, + ); + }, + ); it('rejects the previous key immediately after retirement', () => { expectProblem( From 175efc5cf1932f895aad7f0ff1cad06d303edde3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:25:11 +0900 Subject: [PATCH 26/39] fix(ci): repair explicit signing key evidence --- .../workflows/run-ai-key-rotation-codemod.yml | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/run-ai-key-rotation-codemod.yml b/.github/workflows/run-ai-key-rotation-codemod.yml index bc086cae8..79df52a59 100644 --- a/.github/workflows/run-ai-key-rotation-codemod.yml +++ b/.github/workflows/run-ai-key-rotation-codemod.yml @@ -34,6 +34,31 @@ jobs: corepack enable pnpm install --no-frozen-lockfile + - name: Repair the remaining signing-key test call + run: | + set -Eeuo pipefail + python3 - <<'PY' + from pathlib import Path + + path = Path('apps/web/app/ai-proposal-client.test.ts') + source = path.read_text(encoding='utf-8') + old = """ createAiContextHeaders( + WORKSPACE_ID, + ACTOR_ID, + GATEWAY_SECRET, + -1, + """.replace(' ', '') + new = """ createAiContextHeaders( + WORKSPACE_ID, + ACTOR_ID, + { keyId: ACTIVE_KEY_ID, secret: GATEWAY_SECRET }, + -1, + """.replace(' ', '') + if source.count(old) != 1: + raise SystemExit('Expected exactly one legacy signing-key test call') + path.write_text(source.replace(old, new, 1), encoding='utf-8') + PY + - name: Format complete keyed-context slice run: | pnpm exec prettier --single-quote --write \ @@ -51,7 +76,7 @@ jobs: apps/web/app/ai-proposal-identity-stream-regression.test.ts \ apps/web/app/api/ai/proposals/routes.test.ts - - name: Commit canonical formatting when needed + - name: Commit corrections when needed run: | set -Eeuo pipefail rm -f pnpm-lock.yaml @@ -60,9 +85,9 @@ jobs: git add apps/ai-service apps/web git diff --cached --check if git diff --cached --quiet; then - echo 'No formatting changes required.' + echo 'No source changes required.' else - git commit -m 'style(ai): format keyed gateway context slice' + git commit -m 'fix(ai): pass explicit signing key in web evidence' git push origin HEAD:feat/ai-gateway-key-rotation fi From 03928b8769bcaf9d15ee49efc65a8597beb2886b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:27:04 +0900 Subject: [PATCH 27/39] fix(ci): use structural signing-key test repair --- .../workflows/run-ai-key-rotation-codemod.yml | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/.github/workflows/run-ai-key-rotation-codemod.yml b/.github/workflows/run-ai-key-rotation-codemod.yml index 79df52a59..583300851 100644 --- a/.github/workflows/run-ai-key-rotation-codemod.yml +++ b/.github/workflows/run-ai-key-rotation-codemod.yml @@ -38,25 +38,20 @@ jobs: run: | set -Eeuo pipefail python3 - <<'PY' + import re from pathlib import Path path = Path('apps/web/app/ai-proposal-client.test.ts') source = path.read_text(encoding='utf-8') - old = """ createAiContextHeaders( - WORKSPACE_ID, - ACTOR_ID, - GATEWAY_SECRET, - -1, - """.replace(' ', '') - new = """ createAiContextHeaders( - WORKSPACE_ID, - ACTOR_ID, - { keyId: ACTIVE_KEY_ID, secret: GATEWAY_SECRET }, - -1, - """.replace(' ', '') - if source.count(old) != 1: + source, count = re.subn( + r'(createAiContextHeaders\(\s*WORKSPACE_ID,\s*ACTOR_ID,\s*)GATEWAY_SECRET,(\s*-1,)', + r'\1{ keyId: ACTIVE_KEY_ID, secret: GATEWAY_SECRET },\2', + source, + count=1, + ) + if count != 1: raise SystemExit('Expected exactly one legacy signing-key test call') - path.write_text(source.replace(old, new, 1), encoding='utf-8') + path.write_text(source, encoding='utf-8') PY - name: Format complete keyed-context slice From 9b5904e1d92676c5176ebff442602d1a10a13feb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:27:23 +0000 Subject: [PATCH 28/39] fix(ai): pass explicit signing key in web evidence --- apps/web/app/ai-proposal-client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/ai-proposal-client.test.ts b/apps/web/app/ai-proposal-client.test.ts index e8506500e..5b016f9d5 100644 --- a/apps/web/app/ai-proposal-client.test.ts +++ b/apps/web/app/ai-proposal-client.test.ts @@ -656,7 +656,7 @@ describe('AI proposal BFF helpers', () => { createAiContextHeaders( WORKSPACE_ID, ACTOR_ID, - GATEWAY_SECRET, + { keyId: ACTIVE_KEY_ID, secret: GATEWAY_SECRET }, -1, 'POST', '/v1/proposals', From 7926cdfd9d3836b37eaf59c7d39f060d635a8443 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:32:17 +0900 Subject: [PATCH 29/39] test(ai): wire PostgreSQL key rotation lifecycle evidence --- .../workflows/run-ai-key-rotation-codemod.yml | 245 +++++++++++++++--- 1 file changed, 203 insertions(+), 42 deletions(-) diff --git a/.github/workflows/run-ai-key-rotation-codemod.yml b/.github/workflows/run-ai-key-rotation-codemod.yml index 583300851..8b458e427 100644 --- a/.github/workflows/run-ai-key-rotation-codemod.yml +++ b/.github/workflows/run-ai-key-rotation-codemod.yml @@ -1,4 +1,4 @@ -name: Verify AI Key Rotation Slice +name: Verify AI Gateway Rotation Integration on: push: @@ -12,11 +12,27 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + AI_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_ai_key_rotation_test + AI_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_ai_key_rotation_test jobs: verify: runs-on: ubuntu-24.04 timeout-minutes: 30 + services: + postgres: + image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 + env: + POSTGRES_DB: life_os_ai_key_rotation_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d life_os_ai_key_rotation_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - name: Checkout feature branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -32,63 +48,208 @@ jobs: - name: Enable Corepack and install dependencies run: | corepack enable - pnpm install --no-frozen-lockfile + pnpm install --frozen-lockfile - - name: Repair the remaining signing-key test call + - name: Wire active and previous keys into PostgreSQL HTTP evidence run: | set -Eeuo pipefail python3 - <<'PY' - import re from pathlib import Path - path = Path('apps/web/app/ai-proposal-client.test.ts') + path = Path('apps/ai-service/src/proposal-audit-http.integration.test.ts') source = path.read_text(encoding='utf-8') - source, count = re.subn( - r'(createAiContextHeaders\(\s*WORKSPACE_ID,\s*ACTOR_ID,\s*)GATEWAY_SECRET,(\s*-1,)', - r'\1{ keyId: ACTIVE_KEY_ID, secret: GATEWAY_SECRET },\2', - source, - count=1, + + def replace_once(old: str, new: str, label: str) -> None: + global source + count = source.count(old) + if count != 1: + raise SystemExit(f'{label}: expected one match, found {count}') + source = source.replace(old, new, 1) + + replace_once( + """const ORIGINAL_APPLICATION_DATABASE_URL = process.env.AI_DATABASE_URL; + const ORIGINAL_GATEWAY_CONTEXT_SECRET = process.env.AI_GATEWAY_CONTEXT_SECRET; + const GATEWAY_CONTEXT_SECRET = 'ai-http-integration-gateway-secret-32-bytes'; + """.replace(' ', ''), + """const ORIGINAL_APPLICATION_DATABASE_URL = process.env.AI_DATABASE_URL; + const ORIGINAL_ACTIVE_KEY_ID = process.env.AI_GATEWAY_ACTIVE_KEY_ID; + const ORIGINAL_ACTIVE_KEY_SECRET = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET; + const ORIGINAL_PREVIOUS_KEY_ID = process.env.AI_GATEWAY_PREVIOUS_KEY_ID; + const ORIGINAL_PREVIOUS_KEY_SECRET = + process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; + const ACTIVE_KEY_ID = 'gateway-2026-08-b'; + const PREVIOUS_KEY_ID = 'gateway-2026-07-a'; + const ACTIVE_KEY_SECRET = Buffer.alloc(32, 0x41).toString('base64url'); + const PREVIOUS_KEY_SECRET = Buffer.alloc(32, 0x42).toString('base64url'); + """.replace(' ', ''), + 'key environment constants', + ) + + replace_once( + """ issuedAt?: number; + secret?: string; + }): Readonly> { + """.replace(' ', ''), + """ issuedAt?: number; + keyId?: string; + secret?: string; + }): Readonly> { + """.replace(' ', ''), + 'signed context input', + ) + + replace_once( + """ const issuedAt = String(input.issuedAt ?? Math.floor(Date.now() / 1000)); + const signature = createHmac('sha256', input.secret ?? GATEWAY_CONTEXT_SECRET) + .update( + `life-os.ai-context.v1\\n${workspaceId}\\n${actorId}\\n${issuedAt}\\n${input.method}\\n${input.path}`, + 'utf8', + ) + .digest('base64url'); + return { + 'x-life-os-workspace-id': workspaceId, + """.replace(' ', ''), + """ const issuedAt = String(input.issuedAt ?? Math.floor(Date.now() / 1000)); + const keyId = input.keyId ?? ACTIVE_KEY_ID; + const signature = createHmac('sha256', input.secret ?? ACTIVE_KEY_SECRET) + .update( + `life-os.ai-context.v2\\n${keyId}\\n${workspaceId}\\n${actorId}\\n${issuedAt}\\n${input.method}\\n${input.path}`, + 'utf8', + ) + .digest('base64url'); + return { + 'x-life-os-context-key-id': keyId, + 'x-life-os-workspace-id': workspaceId, + """.replace(' ', ''), + 'v2 signature and key header', + ) + + replace_once( + """ process.env.AI_DATABASE_URL = testDatabaseUrl; + process.env.AI_GATEWAY_CONTEXT_SECRET = GATEWAY_CONTEXT_SECRET; + """.replace(' ', ''), + """ process.env.AI_DATABASE_URL = testDatabaseUrl; + process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; + """.replace(' ', ''), + 'beforeAll key setup', + ) + + replace_once( + """ if (ORIGINAL_GATEWAY_CONTEXT_SECRET === undefined) { + delete process.env.AI_GATEWAY_CONTEXT_SECRET; + } else { + process.env.AI_GATEWAY_CONTEXT_SECRET = ORIGINAL_GATEWAY_CONTEXT_SECRET; + } + """.replace(' ', ''), + """ for (const [name, value] of [ + ['AI_GATEWAY_ACTIVE_KEY_ID', ORIGINAL_ACTIVE_KEY_ID], + ['AI_GATEWAY_ACTIVE_KEY_SECRET', ORIGINAL_ACTIVE_KEY_SECRET], + ['AI_GATEWAY_PREVIOUS_KEY_ID', ORIGINAL_PREVIOUS_KEY_ID], + ['AI_GATEWAY_PREVIOUS_KEY_SECRET', ORIGINAL_PREVIOUS_KEY_SECRET], + ] as const) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + """.replace(' ', ''), + 'afterAll key restoration', ) - if count != 1: - raise SystemExit('Expected exactly one legacy signing-key test call') + + marker = """ it('persists proposals across restarts and appends replay-safe decisions', async () => { + """.replace(' ', '') + rotation_test = """ it('accepts the previous key during overlap and rejects it immediately after retirement', async () => { + const workspaceId = randomUUID(); + const actorId = randomUUID(); + process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; + process.env.AI_GATEWAY_PREVIOUS_KEY_ID = PREVIOUS_KEY_ID; + process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET = PREVIOUS_KEY_SECRET; + const app = await NestFactory.create(AiProductionModule, { logger: false }); + await app.listen(0, '127.0.0.1'); + try { + const address = app.getHttpServer().address() as AddressInfo; + const previousAccepted = await requestJson( + address, + 'POST', + '/v1/proposals', + proposalRequest(randomUUID()), + signedContextHeaders({ + workspaceId, + actorId, + method: 'POST', + path: '/v1/proposals', + keyId: PREVIOUS_KEY_ID, + secret: PREVIOUS_KEY_SECRET, + }), + ); + expect(previousAccepted.statusCode).toBe(201); + + delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; + const retiredRejected = await requestJson( + address, + 'POST', + '/v1/proposals', + proposalRequest(randomUUID()), + signedContextHeaders({ + workspaceId, + actorId, + method: 'POST', + path: '/v1/proposals', + keyId: PREVIOUS_KEY_ID, + secret: PREVIOUS_KEY_SECRET, + }), + ); + expect(retiredRejected).toMatchObject({ + statusCode: 401, + body: { code: 'invalid_gateway_context' }, + }); + + const activeAccepted = await requestJson( + address, + 'POST', + '/v1/proposals', + proposalRequest(randomUUID()), + signedContextHeaders({ + workspaceId, + actorId, + method: 'POST', + path: '/v1/proposals', + }), + ); + expect(activeAccepted.statusCode).toBe(201); + } finally { + await app.close(); + process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; + } + }); + + """.replace(' ', '') + replace_once(marker, rotation_test + marker, 'rotation lifecycle test') path.write_text(source, encoding='utf-8') PY - - name: Format complete keyed-context slice - run: | - pnpm exec prettier --single-quote --write \ - apps/ai-service/src/ai-gateway-keyring.ts \ - apps/ai-service/src/ai-gateway-keyring.test.ts \ - apps/ai-service/src/ai-http-boundary.ts \ - apps/ai-service/src/ai-http-boundary.test.ts \ - apps/ai-service/src/main.ts \ - apps/ai-service/src/quality-coverage.test.ts \ - apps/ai-service/src/no-silent-mutation.integration.test.ts \ - apps/web/app/ai-proposal-client-core.ts \ - apps/web/app/ai-proposal-client.ts \ - apps/web/app/ai-proposal-client.test.ts \ - apps/web/app/ai-proposal-scope-regression.test.ts \ - apps/web/app/ai-proposal-identity-stream-regression.test.ts \ - apps/web/app/api/ai/proposals/routes.test.ts - - - name: Commit corrections when needed + - name: Format and commit integration evidence run: | set -Eeuo pipefail - rm -f pnpm-lock.yaml + pnpm exec prettier --single-quote --write \ + apps/ai-service/src/proposal-audit-http.integration.test.ts git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add apps/ai-service apps/web + git add apps/ai-service/src/proposal-audit-http.integration.test.ts git diff --cached --check - if git diff --cached --quiet; then - echo 'No source changes required.' - else - git commit -m 'fix(ai): pass explicit signing key in web evidence' - git push origin HEAD:feat/ai-gateway-key-rotation - fi - - - name: Verify AI and web boundaries + git commit -m 'test(ai): prove zero-downtime gateway key rotation' + git push origin HEAD:feat/ai-gateway-key-rotation + + - name: Run complete AI service evidence with PostgreSQL run: | pnpm --filter @life-os/ai-service lint pnpm --filter @life-os/ai-service test - pnpm --filter @life-os/web lint - pnpm --filter @life-os/web test From 400be9e5831f23849edd9c15ca0a55b20c633e0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:35:23 +0900 Subject: [PATCH 30/39] fix(ci): match wrapped gateway secret restoration --- .../workflows/run-ai-key-rotation-codemod.yml | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/run-ai-key-rotation-codemod.yml b/.github/workflows/run-ai-key-rotation-codemod.yml index 8b458e427..3a4b6d6bb 100644 --- a/.github/workflows/run-ai-key-rotation-codemod.yml +++ b/.github/workflows/run-ai-key-rotation-codemod.yml @@ -54,6 +54,7 @@ jobs: run: | set -Eeuo pipefail python3 - <<'PY' + import re from pathlib import Path path = Path('apps/ai-service/src/proposal-audit-http.integration.test.ts') @@ -137,14 +138,7 @@ jobs: 'beforeAll key setup', ) - replace_once( - """ if (ORIGINAL_GATEWAY_CONTEXT_SECRET === undefined) { - delete process.env.AI_GATEWAY_CONTEXT_SECRET; - } else { - process.env.AI_GATEWAY_CONTEXT_SECRET = ORIGINAL_GATEWAY_CONTEXT_SECRET; - } - """.replace(' ', ''), - """ for (const [name, value] of [ + restoration = """for (const [name, value] of [ ['AI_GATEWAY_ACTIVE_KEY_ID', ORIGINAL_ACTIVE_KEY_ID], ['AI_GATEWAY_ACTIVE_KEY_SECRET', ORIGINAL_ACTIVE_KEY_SECRET], ['AI_GATEWAY_PREVIOUS_KEY_ID', ORIGINAL_PREVIOUS_KEY_ID], @@ -155,10 +149,17 @@ jobs: } else { process.env[name] = value; } - } - """.replace(' ', ''), - 'afterAll key restoration', + }""" + source, restoration_count = re.subn( + r"if \(ORIGINAL_GATEWAY_CONTEXT_SECRET === undefined\) \{\s*delete process\.env\.AI_GATEWAY_CONTEXT_SECRET;\s*\} else \{\s*process\.env\.AI_GATEWAY_CONTEXT_SECRET =\s*ORIGINAL_GATEWAY_CONTEXT_SECRET;\s*\}", + restoration, + source, + count=1, ) + if restoration_count != 1: + raise SystemExit( + f'afterAll key restoration: expected one match, found {restoration_count}' + ) marker = """ it('persists proposals across restarts and appends replay-safe decisions', async () => { """.replace(' ', '') From ca4c18d760b805d4153e70b1f93fc3fcc4b65cf0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:35:54 +0000 Subject: [PATCH 31/39] test(ai): prove zero-downtime gateway key rotation --- .../proposal-audit-http.integration.test.ts | 107 ++++++++++++++++-- 1 file changed, 98 insertions(+), 9 deletions(-) diff --git a/apps/ai-service/src/proposal-audit-http.integration.test.ts b/apps/ai-service/src/proposal-audit-http.integration.test.ts index 02e35d18e..803be66ca 100644 --- a/apps/ai-service/src/proposal-audit-http.integration.test.ts +++ b/apps/ai-service/src/proposal-audit-http.integration.test.ts @@ -10,8 +10,14 @@ import { AiProductionModule } from './main'; const TEST_DATABASE_URL = process.env.AI_TEST_DATABASE_URL; const ORIGINAL_APPLICATION_DATABASE_URL = process.env.AI_DATABASE_URL; -const ORIGINAL_GATEWAY_CONTEXT_SECRET = process.env.AI_GATEWAY_CONTEXT_SECRET; -const GATEWAY_CONTEXT_SECRET = 'ai-http-integration-gateway-secret-32-bytes'; +const ORIGINAL_ACTIVE_KEY_ID = process.env.AI_GATEWAY_ACTIVE_KEY_ID; +const ORIGINAL_ACTIVE_KEY_SECRET = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET; +const ORIGINAL_PREVIOUS_KEY_ID = process.env.AI_GATEWAY_PREVIOUS_KEY_ID; +const ORIGINAL_PREVIOUS_KEY_SECRET = process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; +const ACTIVE_KEY_ID = 'gateway-2026-08-b'; +const PREVIOUS_KEY_ID = 'gateway-2026-07-a'; +const ACTIVE_KEY_SECRET = Buffer.alloc(32, 0x41).toString('base64url'); +const PREVIOUS_KEY_SECRET = Buffer.alloc(32, 0x42).toString('base64url'); const describeWithPostgres = TEST_DATABASE_URL ? describe : describe.skip; let administrativePool: Pool; @@ -60,18 +66,21 @@ function signedContextHeaders(input: { method: 'GET' | 'POST'; path: string; issuedAt?: number; + keyId?: string; secret?: string; }): Readonly> { const workspaceId = input.workspaceId.toLowerCase(); const actorId = input.actorId.toLowerCase(); const issuedAt = String(input.issuedAt ?? Math.floor(Date.now() / 1000)); - const signature = createHmac('sha256', input.secret ?? GATEWAY_CONTEXT_SECRET) + const keyId = input.keyId ?? ACTIVE_KEY_ID; + const signature = createHmac('sha256', input.secret ?? ACTIVE_KEY_SECRET) .update( - `life-os.ai-context.v1\n${workspaceId}\n${actorId}\n${issuedAt}\n${input.method}\n${input.path}`, + `life-os.ai-context.v2\n${keyId}\n${workspaceId}\n${actorId}\n${issuedAt}\n${input.method}\n${input.path}`, 'utf8', ) .digest('base64url'); return { + 'x-life-os-context-key-id': keyId, 'x-life-os-workspace-id': workspaceId, 'x-life-os-actor-id': actorId, 'x-life-os-context-issued-at': issuedAt, @@ -156,7 +165,10 @@ describeWithPostgres('AI production proposal audit HTTP API', () => { beforeAll(async () => { const testDatabaseUrl = requireTestDatabaseUrl(); process.env.AI_DATABASE_URL = testDatabaseUrl; - process.env.AI_GATEWAY_CONTEXT_SECRET = GATEWAY_CONTEXT_SECRET; + process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; administrativePool = new Pool({ connectionString: testDatabaseUrl, application_name: 'life-os-ai-http-integration-admin', @@ -176,10 +188,17 @@ describeWithPostgres('AI production proposal audit HTTP API', () => { } else { process.env.AI_DATABASE_URL = ORIGINAL_APPLICATION_DATABASE_URL; } - if (ORIGINAL_GATEWAY_CONTEXT_SECRET === undefined) { - delete process.env.AI_GATEWAY_CONTEXT_SECRET; - } else { - process.env.AI_GATEWAY_CONTEXT_SECRET = ORIGINAL_GATEWAY_CONTEXT_SECRET; + for (const [name, value] of [ + ['AI_GATEWAY_ACTIVE_KEY_ID', ORIGINAL_ACTIVE_KEY_ID], + ['AI_GATEWAY_ACTIVE_KEY_SECRET', ORIGINAL_ACTIVE_KEY_SECRET], + ['AI_GATEWAY_PREVIOUS_KEY_ID', ORIGINAL_PREVIOUS_KEY_ID], + ['AI_GATEWAY_PREVIOUS_KEY_SECRET', ORIGINAL_PREVIOUS_KEY_SECRET], + ] as const) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } } } }); @@ -242,6 +261,76 @@ describeWithPostgres('AI production proposal audit HTTP API', () => { } }); + it('accepts the previous key during overlap and rejects it immediately after retirement', async () => { + const workspaceId = randomUUID(); + const actorId = randomUUID(); + process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; + process.env.AI_GATEWAY_PREVIOUS_KEY_ID = PREVIOUS_KEY_ID; + process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET = PREVIOUS_KEY_SECRET; + const app = await NestFactory.create(AiProductionModule, { logger: false }); + await app.listen(0, '127.0.0.1'); + try { + const address = app.getHttpServer().address() as AddressInfo; + const previousAccepted = await requestJson( + address, + 'POST', + '/v1/proposals', + proposalRequest(randomUUID()), + signedContextHeaders({ + workspaceId, + actorId, + method: 'POST', + path: '/v1/proposals', + keyId: PREVIOUS_KEY_ID, + secret: PREVIOUS_KEY_SECRET, + }), + ); + expect(previousAccepted.statusCode).toBe(201); + + delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; + const retiredRejected = await requestJson( + address, + 'POST', + '/v1/proposals', + proposalRequest(randomUUID()), + signedContextHeaders({ + workspaceId, + actorId, + method: 'POST', + path: '/v1/proposals', + keyId: PREVIOUS_KEY_ID, + secret: PREVIOUS_KEY_SECRET, + }), + ); + expect(retiredRejected).toMatchObject({ + statusCode: 401, + body: { code: 'invalid_gateway_context' }, + }); + + const activeAccepted = await requestJson( + address, + 'POST', + '/v1/proposals', + proposalRequest(randomUUID()), + signedContextHeaders({ + workspaceId, + actorId, + method: 'POST', + path: '/v1/proposals', + }), + ); + expect(activeAccepted.statusCode).toBe(201); + } finally { + await app.close(); + process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; + process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; + delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; + } + }); + it('persists proposals across restarts and appends replay-safe decisions', async () => { const workspaceId = randomUUID(); const otherWorkspaceId = randomUUID(); From 321a93f63ae7c4fbbe0f6e5c2b034f42f1af5979 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:44:01 +0900 Subject: [PATCH 32/39] docs(ai): finalize rotation standards and release evidence --- .../workflows/run-ai-key-rotation-codemod.yml | 354 +++++++++--------- 1 file changed, 172 insertions(+), 182 deletions(-) diff --git a/.github/workflows/run-ai-key-rotation-codemod.yml b/.github/workflows/run-ai-key-rotation-codemod.yml index 3a4b6d6bb..04c83bcc0 100644 --- a/.github/workflows/run-ai-key-rotation-codemod.yml +++ b/.github/workflows/run-ai-key-rotation-codemod.yml @@ -1,4 +1,4 @@ -name: Verify AI Gateway Rotation Integration +name: Finalize AI Gateway Key Rotation Evidence on: push: @@ -12,27 +12,11 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - AI_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_ai_key_rotation_test - AI_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/life_os_ai_key_rotation_test jobs: - verify: + finalize: runs-on: ubuntu-24.04 - timeout-minutes: 30 - services: - postgres: - image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 - env: - POSTGRES_DB: life_os_ai_key_rotation_test - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres -d life_os_ai_key_rotation_test" - --health-interval 5s - --health-timeout 5s - --health-retries 10 + timeout-minutes: 20 steps: - name: Checkout feature branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -50,207 +34,213 @@ jobs: corepack enable pnpm install --frozen-lockfile - - name: Wire active and previous keys into PostgreSQL HTTP evidence + - name: Finalize runtime, research, capability, and release evidence run: | set -Eeuo pipefail python3 - <<'PY' - import re + import json from pathlib import Path - path = Path('apps/ai-service/src/proposal-audit-http.integration.test.ts') - source = path.read_text(encoding='utf-8') - - def replace_once(old: str, new: str, label: str) -> None: - global source + def replace_once(path: str, old: str, new: str, label: str) -> None: + file_path = Path(path) + source = file_path.read_text(encoding='utf-8') count = source.count(old) if count != 1: raise SystemExit(f'{label}: expected one match, found {count}') - source = source.replace(old, new, 1) + file_path.write_text(source.replace(old, new, 1), encoding='utf-8') + readme_path = 'apps/ai-service/migrations/README.md' replace_once( - """const ORIGINAL_APPLICATION_DATABASE_URL = process.env.AI_DATABASE_URL; - const ORIGINAL_GATEWAY_CONTEXT_SECRET = process.env.AI_GATEWAY_CONTEXT_SECRET; - const GATEWAY_CONTEXT_SECRET = 'ai-http-integration-gateway-secret-32-bytes'; + readme_path, + """Every route requires a short-lived signed service context produced only after a trusted proxy authenticates a session and authorizes workspace membership. The headers are `x-life-os-workspace-id`, `x-life-os-actor-id`, `x-life-os-context-issued-at`, and `x-life-os-context-signature`. The HMAC-SHA-256 payload binds canonical workspace and actor UUIDv4 values to issuance time, uppercase HTTP method, and the exact `/v1/...` path. The shared `AI_GATEWAY_CONTEXT_SECRET` must contain 32–4096 UTF-8 bytes, remain server-only, and be identical in the trusted proxy and AI service. """.replace(' ', ''), - """const ORIGINAL_APPLICATION_DATABASE_URL = process.env.AI_DATABASE_URL; - const ORIGINAL_ACTIVE_KEY_ID = process.env.AI_GATEWAY_ACTIVE_KEY_ID; - const ORIGINAL_ACTIVE_KEY_SECRET = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET; - const ORIGINAL_PREVIOUS_KEY_ID = process.env.AI_GATEWAY_PREVIOUS_KEY_ID; - const ORIGINAL_PREVIOUS_KEY_SECRET = - process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; - const ACTIVE_KEY_ID = 'gateway-2026-08-b'; - const PREVIOUS_KEY_ID = 'gateway-2026-07-a'; - const ACTIVE_KEY_SECRET = Buffer.alloc(32, 0x41).toString('base64url'); - const PREVIOUS_KEY_SECRET = Buffer.alloc(32, 0x42).toString('base64url'); + """Every route requires a short-lived signed service context produced only after a trusted proxy authenticates a session and authorizes workspace membership. The headers are `x-life-os-context-key-id`, `x-life-os-workspace-id`, `x-life-os-actor-id`, `x-life-os-context-issued-at`, and `x-life-os-context-signature`. The version 2 HMAC-SHA-256 payload binds the case-sensitive key identifier, canonical workspace and actor UUIDv4 values, issuance time, uppercase HTTP method, and exact `/v1/...` path. + +The trusted web boundary requires `AI_GATEWAY_ACTIVE_KEY_ID` and `AI_GATEWAY_ACTIVE_KEY_SECRET` and signs only with that pair. AI service requires the same active pair and may additionally receive the complete verification-only overlap pair `AI_GATEWAY_PREVIOUS_KEY_ID` and `AI_GATEWAY_PREVIOUS_KEY_SECRET`. Each secret must contain 32–4096 UTF-8 bytes, active and previous identifiers and secrets must be distinct, and all material remains server-only. """.replace(' ', ''), - 'key environment constants', + 'runtime keyed context contract', ) - replace_once( - """ issuedAt?: number; - secret?: string; - }): Readonly> { + readme_path, + """## Secret rotation and rollback + + This contract currently supports one active secret. Rotation requires a coordinated trusted-proxy and AI-service deployment; zero-downtime overlapping verification keys are deferred. If signer and verifier become incompatible, disable external AI proposal traffic rather than falling back to unsigned ownership headers. Secret compromise requires coordinated replacement, waiting at least the 60-second context lifetime before treating old tags as expired, and reviewing proposal/decision audit evidence for forged activity. """.replace(' ', ''), - """ issuedAt?: number; - keyId?: string; - secret?: string; - }): Readonly> { + """## Secret rotation and rollback + + LifeOS supports one active signing key and one bounded previous verification key. Follow `docs/operations/ai-gateway-key-rotation.md` to expand verifier configuration, switch the signer, retain the former active key only through the request-validity and deployment overlap window, and retire it by removing the previous pair. Unknown and retired identifiers fail closed immediately; the verifier never trials every configured secret. If signer and verifier become incompatible, disable external AI proposal traffic rather than falling back to unsigned ownership headers. Suspected compromise requires immediate revocation rather than a normal overlap. """.replace(' ', ''), - 'signed context input', + 'runtime rotation runbook', ) - replace_once( - """ const issuedAt = String(input.issuedAt ?? Math.floor(Date.now() / 1000)); - const signature = createHmac('sha256', input.secret ?? GATEWAY_CONTEXT_SECRET) - .update( - `life-os.ai-context.v1\\n${workspaceId}\\n${actorId}\\n${issuedAt}\\n${input.method}\\n${input.path}`, - 'utf8', - ) - .digest('base64url'); - return { - 'x-life-os-workspace-id': workspaceId, - """.replace(' ', ''), - """ const issuedAt = String(input.issuedAt ?? Math.floor(Date.now() / 1000)); - const keyId = input.keyId ?? ACTIVE_KEY_ID; - const signature = createHmac('sha256', input.secret ?? ACTIVE_KEY_SECRET) - .update( - `life-os.ai-context.v2\\n${keyId}\\n${workspaceId}\\n${actorId}\\n${issuedAt}\\n${input.method}\\n${input.path}`, - 'utf8', - ) - .digest('base64url'); - return { - 'x-life-os-context-key-id': keyId, - 'x-life-os-workspace-id': workspaceId, - """.replace(' ', ''), - 'v2 signature and key header', + readme_path, + "External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, multi-secret rotation windows, asymmetric workload identity, and separately authorized action execution remain independent reviewed capabilities.", + "External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, asymmetric workload identity, and separately authorized action execution remain independent reviewed capabilities.", + 'deferred work update', ) + research_path = 'docs/research/2026-08-04-ai-gateway-key-rotation-standards.md' replace_once( - """ process.env.AI_DATABASE_URL = testDatabaseUrl; - process.env.AI_GATEWAY_CONTEXT_SECRET = GATEWAY_CONTEXT_SECRET; - """.replace(' ', ''), - """ process.env.AI_DATABASE_URL = testDatabaseUrl; - process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; - process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; - delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; - delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; + research_path, + """### Key lifecycle and bounded overlap + + NIST SP 800-57 Part 1 Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. """.replace(' ', ''), - 'beforeAll key setup', - ) + """### Key lifecycle and bounded overlap - restoration = """for (const [name, value] of [ - ['AI_GATEWAY_ACTIVE_KEY_ID', ORIGINAL_ACTIVE_KEY_ID], - ['AI_GATEWAY_ACTIVE_KEY_SECRET', ORIGINAL_ACTIVE_KEY_SECRET], - ['AI_GATEWAY_PREVIOUS_KEY_ID', ORIGINAL_PREVIOUS_KEY_ID], - ['AI_GATEWAY_PREVIOUS_KEY_SECRET', ORIGINAL_PREVIOUS_KEY_SECRET], - ] as const) { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } - }""" - source, restoration_count = re.subn( - r"if \(ORIGINAL_GATEWAY_CONTEXT_SECRET === undefined\) \{\s*delete process\.env\.AI_GATEWAY_CONTEXT_SECRET;\s*\} else \{\s*process\.env\.AI_GATEWAY_CONTEXT_SECRET =\s*ORIGINAL_GATEWAY_CONTEXT_SECRET;\s*\}", - restoration, - source, - count=1, + NIST SP 800-57 Part 1 Revision 5 remains the current final publication. NIST published Revision 6 as an Initial Public Draft in December 2025; its comment period closed in February 2026. LifeOS treats Revision 5 as the final normative baseline while tracking the newer draft's expanded keying-material storage and lifecycle direction so the implementation does not silently depend on draft status. + + Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. + """.replace(' ', ''), + 'current NIST publication status', ) - if restoration_count != 1: - raise SystemExit( - f'afterAll key restoration: expected one match, found {restoration_count}' - ) + research_file = Path(research_path) + research = research_file.read_text(encoding='utf-8') + final_reference = """Barker, E. (2020). *Recommendation for key management: Part 1—General* (NIST Special Publication 800-57 Part 1 Revision 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5 + """.replace(' ', '') + draft_reference = """Barker, E., & Barker, W. (2025). *Recommendation for key management: Part 1—General* (Initial Public Draft, NIST Special Publication 800-57 Part 1 Revision 6). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/57/pt1/r6/ipd - marker = """ it('persists proposals across restarts and appends replay-safe decisions', async () => { """.replace(' ', '') - rotation_test = """ it('accepts the previous key during overlap and rejects it immediately after retirement', async () => { - const workspaceId = randomUUID(); - const actorId = randomUUID(); - process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; - process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; - process.env.AI_GATEWAY_PREVIOUS_KEY_ID = PREVIOUS_KEY_ID; - process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET = PREVIOUS_KEY_SECRET; - const app = await NestFactory.create(AiProductionModule, { logger: false }); - await app.listen(0, '127.0.0.1'); - try { - const address = app.getHttpServer().address() as AddressInfo; - const previousAccepted = await requestJson( - address, - 'POST', - '/v1/proposals', - proposalRequest(randomUUID()), - signedContextHeaders({ - workspaceId, - actorId, - method: 'POST', - path: '/v1/proposals', - keyId: PREVIOUS_KEY_ID, - secret: PREVIOUS_KEY_SECRET, - }), - ); - expect(previousAccepted.statusCode).toBe(201); + if draft_reference.strip() not in research: + if research.count(final_reference) != 1: + raise SystemExit('NIST final reference insertion point was not unique') + research = research.replace( + final_reference, + final_reference + '\n' + draft_reference, + 1, + ) + research_file.write_text(research, encoding='utf-8') - delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; - delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; - const retiredRejected = await requestJson( - address, - 'POST', - '/v1/proposals', - proposalRequest(randomUUID()), - signedContextHeaders({ - workspaceId, - actorId, - method: 'POST', - path: '/v1/proposals', - keyId: PREVIOUS_KEY_ID, - secret: PREVIOUS_KEY_SECRET, - }), - ); - expect(retiredRejected).toMatchObject({ - statusCode: 401, - body: { code: 'invalid_gateway_context' }, - }); + changelog_path = Path('CHANGELOG.md') + changelog = changelog_path.read_text(encoding='utf-8') + security_heading = '### Security\n\n' + security_entry = ( + '- AI gateway service-context authentication now carries an integrity-protected key identifier, signs only with one active key, verifies one explicitly selected active or previous key during a bounded overlap, and rejects retired identifiers immediately without trial verification.\n' + ) + if security_entry.strip() not in changelog: + if changelog.count(security_heading) != 1: + raise SystemExit('CHANGELOG Security heading was not unique') + changelog = changelog.replace( + security_heading, + security_heading + security_entry, + 1, + ) + changelog_path.write_text(changelog, encoding='utf-8') + + capabilities_path = Path('product/capabilities.json') + capabilities = json.loads(capabilities_path.read_text(encoding='utf-8')) + capability = next( + ( + item + for item in capabilities['capabilities'] + if item['id'] == 'ai.auditable-proposals' + ), + None, + ) + if capability is None: + raise SystemExit('AI auditable proposal capability was not found') + additions = [ + { + 'maturity': 'usable', + 'kind': 'implementation', + 'mode': 'exists', + 'path': 'apps/ai-service/src/ai-gateway-keyring.ts', + }, + { + 'maturity': 'production', + 'kind': 'test', + 'mode': 'exists', + 'path': 'apps/ai-service/src/proposal-audit-http.integration.test.ts', + }, + { + 'maturity': 'production', + 'kind': 'documentation', + 'mode': 'exists', + 'path': 'docs/operations/ai-gateway-key-rotation.md', + }, + ] + existing_paths = {item['path'] for item in capability['evidence']} + capability['evidence'].extend( + item for item in additions if item['path'] not in existing_paths + ) + capabilities_path.write_text( + json.dumps(capabilities, indent=2) + '\n', + encoding='utf-8', + ) - const activeAccepted = await requestJson( - address, - 'POST', - '/v1/proposals', - proposalRequest(randomUUID()), - signedContextHeaders({ - workspaceId, - actorId, - method: 'POST', - path: '/v1/proposals', - }), - ); - expect(activeAccepted.statusCode).toBe(201); - } finally { - await app.close(); - process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; - process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; - delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; - delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; - } - }); + ai_package_path = Path('apps/ai-service/package.json') + ai_package = json.loads(ai_package_path.read_text(encoding='utf-8')) + ai_targets = [ + 'migrations/README.md', + '../../docs/operations/ai-gateway-key-rotation.md', + '../../docs/research/2026-08-04-ai-gateway-key-rotation-standards.md', + '../../docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md', + '../../docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md', + ] + ai_lint = ai_package['scripts']['lint'] + for target in ai_targets: + token = f' {target}' + if token not in ai_lint: + ai_lint += token + ai_package['scripts']['lint'] = ai_lint + ai_package_path.write_text( + json.dumps(ai_package, indent=2) + '\n', + encoding='utf-8', + ) - """.replace(' ', '') - replace_once(marker, rotation_test + marker, 'rotation lifecycle test') - path.write_text(source, encoding='utf-8') + root_package_path = Path('package.json') + root_package = json.loads(root_package_path.read_text(encoding='utf-8')) + root_targets = [ + 'CHANGELOG.md', + 'apps/ai-service/src/ai-gateway-keyring.ts', + 'apps/ai-service/src/ai-gateway-keyring.test.ts', + 'apps/ai-service/src/ai-http-boundary.ts', + 'apps/ai-service/src/ai-http-boundary.test.ts', + 'docs/operations/ai-gateway-key-rotation.md', + 'docs/research/2026-08-04-ai-gateway-key-rotation-standards.md', + 'docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md', + 'docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md', + ] + root_format = root_package['scripts']['format:check'] + for target in root_targets: + token = f' {target}' + if token not in root_format: + root_format += token + root_package['scripts']['format:check'] = root_format + root_package_path.write_text( + json.dumps(root_package, indent=2) + '\n', + encoding='utf-8', + ) PY - - name: Format and commit integration evidence + - name: Format and commit final evidence run: | set -Eeuo pipefail pnpm exec prettier --single-quote --write \ - apps/ai-service/src/proposal-audit-http.integration.test.ts + package.json \ + apps/ai-service/package.json \ + apps/ai-service/migrations/README.md \ + CHANGELOG.md \ + product/capabilities.json \ + docs/operations/ai-gateway-key-rotation.md \ + docs/research/2026-08-04-ai-gateway-key-rotation-standards.md \ + docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md \ + docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add apps/ai-service/src/proposal-audit-http.integration.test.ts + git add \ + package.json \ + apps/ai-service/package.json \ + apps/ai-service/migrations/README.md \ + CHANGELOG.md \ + product/capabilities.json \ + docs git diff --cached --check - git commit -m 'test(ai): prove zero-downtime gateway key rotation' + git commit -m 'docs(ai): finalize gateway key rotation evidence' git push origin HEAD:feat/ai-gateway-key-rotation - - name: Run complete AI service evidence with PostgreSQL + - name: Verify formatting, AI quality, and capability schema run: | + pnpm format:check pnpm --filter @life-os/ai-service lint - pnpm --filter @life-os/ai-service test + pnpm --filter @life-os/commercial-readiness test From 56ca9c2403a4e3bc16016f47b1a2efd228d0591e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:49:47 +0900 Subject: [PATCH 33/39] chore(ai): add temporary rotation evidence finalizer --- .github/scripts/finalize-ai-key-rotation.py | 199 ++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 .github/scripts/finalize-ai-key-rotation.py diff --git a/.github/scripts/finalize-ai-key-rotation.py b/.github/scripts/finalize-ai-key-rotation.py new file mode 100644 index 000000000..a17d00c5a --- /dev/null +++ b/.github/scripts/finalize-ai-key-rotation.py @@ -0,0 +1,199 @@ +"""Finalize AI gateway key-rotation documentation and capability evidence.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def replace_once(path: str, old: str, new: str, label: str) -> None: + """Replace one exact text block or fail before mutating the file.""" + + file_path = Path(path) + source = file_path.read_text(encoding="utf-8") + count = source.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one match, found {count}") + file_path.write_text(source.replace(old, new, 1), encoding="utf-8") + + +def update_runtime_documentation() -> None: + """Replace the obsolete single-secret runtime and rotation contracts.""" + + readme_path = "apps/ai-service/migrations/README.md" + replace_once( + readme_path, + """Every route requires a short-lived signed service context produced only after a trusted proxy authenticates a session and authorizes workspace membership. The headers are `x-life-os-workspace-id`, `x-life-os-actor-id`, `x-life-os-context-issued-at`, and `x-life-os-context-signature`. The HMAC-SHA-256 payload binds canonical workspace and actor UUIDv4 values to issuance time, uppercase HTTP method, and the exact `/v1/...` path. The shared `AI_GATEWAY_CONTEXT_SECRET` must contain 32–4096 UTF-8 bytes, remain server-only, and be identical in the trusted proxy and AI service. +""", + """Every route requires a short-lived signed service context produced only after a trusted proxy authenticates a session and authorizes workspace membership. The headers are `x-life-os-context-key-id`, `x-life-os-workspace-id`, `x-life-os-actor-id`, `x-life-os-context-issued-at`, and `x-life-os-context-signature`. The version 2 HMAC-SHA-256 payload binds the case-sensitive key identifier, canonical workspace and actor UUIDv4 values, issuance time, uppercase HTTP method, and exact `/v1/...` path. + +The trusted web boundary requires `AI_GATEWAY_ACTIVE_KEY_ID` and `AI_GATEWAY_ACTIVE_KEY_SECRET` and signs only with that pair. AI service requires the same active pair and may additionally receive the complete verification-only overlap pair `AI_GATEWAY_PREVIOUS_KEY_ID` and `AI_GATEWAY_PREVIOUS_KEY_SECRET`. Each secret must contain 32–4096 UTF-8 bytes, active and previous identifiers and secrets must be distinct, and all material remains server-only. +""", + "runtime keyed context contract", + ) + replace_once( + readme_path, + """## Secret rotation and rollback + +This contract currently supports one active secret. Rotation requires a coordinated trusted-proxy and AI-service deployment; zero-downtime overlapping verification keys are deferred. If signer and verifier become incompatible, disable external AI proposal traffic rather than falling back to unsigned ownership headers. Secret compromise requires coordinated replacement, waiting at least the 60-second context lifetime before treating old tags as expired, and reviewing proposal/decision audit evidence for forged activity. +""", + """## Secret rotation and rollback + +LifeOS supports one active signing key and one bounded previous verification key. Follow `docs/operations/ai-gateway-key-rotation.md` to expand verifier configuration, switch the signer, retain the former active key only through the request-validity and deployment overlap window, and retire it by removing the previous pair. Unknown and retired identifiers fail closed immediately; the verifier never trials every configured secret. If signer and verifier become incompatible, disable external AI proposal traffic rather than falling back to unsigned ownership headers. Suspected compromise requires immediate revocation rather than a normal overlap. +""", + "runtime rotation runbook", + ) + replace_once( + readme_path, + "External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, multi-secret rotation windows, asymmetric workload identity, and separately authorized action execution remain independent reviewed capabilities.", + "External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, asymmetric workload identity, and separately authorized action execution remain independent reviewed capabilities.", + "deferred work update", + ) + + +def update_research_review() -> None: + """Record the current final NIST baseline and newer public draft status.""" + + research_path = "docs/research/2026-08-04-ai-gateway-key-rotation-standards.md" + replace_once( + research_path, + """### Key lifecycle and bounded overlap + +NIST SP 800-57 Part 1 Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. +""", + """### Key lifecycle and bounded overlap + +NIST SP 800-57 Part 1 Revision 5 remains the current final publication. NIST published Revision 6 as an Initial Public Draft in December 2025; its comment period closed in February 2026. LifeOS treats Revision 5 as the final normative baseline while tracking the newer draft's expanded keying-material storage and lifecycle direction so the implementation does not silently depend on draft status. + +Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. +""", + "current NIST publication status", + ) + research_file = Path(research_path) + research = research_file.read_text(encoding="utf-8") + final_reference = "Barker, E. (2020). *Recommendation for key management: Part 1—General* (NIST Special Publication 800-57 Part 1 Revision 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5\n" + draft_reference = "Barker, E., & Barker, W. (2025). *Recommendation for key management: Part 1—General* (Initial Public Draft, NIST Special Publication 800-57 Part 1 Revision 6). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/57/pt1/r6/ipd\n\n" + if draft_reference.strip() not in research: + if research.count(final_reference) != 1: + raise SystemExit("NIST final reference insertion point was not unique") + research = research.replace( + final_reference, + final_reference + "\n" + draft_reference, + 1, + ) + research_file.write_text(research, encoding="utf-8") + + +def update_changelog() -> None: + """Add the buyer-visible key-rotation security outcome to Unreleased.""" + + path = Path("CHANGELOG.md") + source = path.read_text(encoding="utf-8") + heading = "### Security\n\n" + entry = "- AI gateway service-context authentication now carries an integrity-protected key identifier, signs only with one active key, verifies one explicitly selected active or previous key during a bounded overlap, and rejects retired identifiers immediately without trial verification.\n" + if entry.strip() not in source: + if source.count(heading) != 1: + raise SystemExit("CHANGELOG Security heading was not unique") + source = source.replace(heading, heading + entry, 1) + path.write_text(source, encoding="utf-8") + + +def update_capability_evidence() -> None: + """Register implementation, integration, and runbook evidence.""" + + path = Path("product/capabilities.json") + document = json.loads(path.read_text(encoding="utf-8")) + capability = next( + ( + item + for item in document["capabilities"] + if item["id"] == "ai.auditable-proposals" + ), + None, + ) + if capability is None: + raise SystemExit("AI auditable proposal capability was not found") + additions = [ + { + "maturity": "usable", + "kind": "implementation", + "mode": "exists", + "path": "apps/ai-service/src/ai-gateway-keyring.ts", + }, + { + "maturity": "production", + "kind": "test", + "mode": "exists", + "path": "apps/ai-service/src/proposal-audit-http.integration.test.ts", + }, + { + "maturity": "production", + "kind": "documentation", + "mode": "exists", + "path": "docs/operations/ai-gateway-key-rotation.md", + }, + ] + existing_paths = {item["path"] for item in capability["evidence"]} + capability["evidence"].extend( + item for item in additions if item["path"] not in existing_paths + ) + path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") + + +def append_script_targets(package_path: str, script_name: str, targets: list[str]) -> None: + """Append unique whitespace-delimited formatter targets to one package script.""" + + path = Path(package_path) + package = json.loads(path.read_text(encoding="utf-8")) + command = package["scripts"][script_name] + for target in targets: + token = f" {target}" + if token not in command: + command += token + package["scripts"][script_name] = command + path.write_text(json.dumps(package, indent=2) + "\n", encoding="utf-8") + + +def update_formatting_contracts() -> None: + """Put every new source and document under deterministic formatting checks.""" + + append_script_targets( + "apps/ai-service/package.json", + "lint", + [ + "migrations/README.md", + "../../docs/operations/ai-gateway-key-rotation.md", + "../../docs/research/2026-08-04-ai-gateway-key-rotation-standards.md", + "../../docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md", + "../../docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md", + ], + ) + append_script_targets( + "package.json", + "format:check", + [ + "CHANGELOG.md", + "apps/ai-service/src/ai-gateway-keyring.ts", + "apps/ai-service/src/ai-gateway-keyring.test.ts", + "apps/ai-service/src/ai-http-boundary.ts", + "apps/ai-service/src/ai-http-boundary.test.ts", + "docs/operations/ai-gateway-key-rotation.md", + "docs/research/2026-08-04-ai-gateway-key-rotation-standards.md", + "docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md", + "docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md", + ], + ) + + +def main() -> None: + """Apply every finalization mutation in a fail-closed order.""" + + update_runtime_documentation() + update_research_review() + update_changelog() + update_capability_evidence() + update_formatting_contracts() + + +if __name__ == "__main__": + main() From 6ed34cb453e29c3f141f67de11d0e817fcc53de3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:50:32 +0900 Subject: [PATCH 34/39] fix(ci): run rotation finalizer from a valid workflow --- .../workflows/run-ai-key-rotation-codemod.yml | 180 +----------------- 1 file changed, 2 insertions(+), 178 deletions(-) diff --git a/.github/workflows/run-ai-key-rotation-codemod.yml b/.github/workflows/run-ai-key-rotation-codemod.yml index 04c83bcc0..160ea4f0c 100644 --- a/.github/workflows/run-ai-key-rotation-codemod.yml +++ b/.github/workflows/run-ai-key-rotation-codemod.yml @@ -34,184 +34,8 @@ jobs: corepack enable pnpm install --frozen-lockfile - - name: Finalize runtime, research, capability, and release evidence - run: | - set -Eeuo pipefail - python3 - <<'PY' - import json - from pathlib import Path - - def replace_once(path: str, old: str, new: str, label: str) -> None: - file_path = Path(path) - source = file_path.read_text(encoding='utf-8') - count = source.count(old) - if count != 1: - raise SystemExit(f'{label}: expected one match, found {count}') - file_path.write_text(source.replace(old, new, 1), encoding='utf-8') - - readme_path = 'apps/ai-service/migrations/README.md' - replace_once( - readme_path, - """Every route requires a short-lived signed service context produced only after a trusted proxy authenticates a session and authorizes workspace membership. The headers are `x-life-os-workspace-id`, `x-life-os-actor-id`, `x-life-os-context-issued-at`, and `x-life-os-context-signature`. The HMAC-SHA-256 payload binds canonical workspace and actor UUIDv4 values to issuance time, uppercase HTTP method, and the exact `/v1/...` path. The shared `AI_GATEWAY_CONTEXT_SECRET` must contain 32–4096 UTF-8 bytes, remain server-only, and be identical in the trusted proxy and AI service. - """.replace(' ', ''), - """Every route requires a short-lived signed service context produced only after a trusted proxy authenticates a session and authorizes workspace membership. The headers are `x-life-os-context-key-id`, `x-life-os-workspace-id`, `x-life-os-actor-id`, `x-life-os-context-issued-at`, and `x-life-os-context-signature`. The version 2 HMAC-SHA-256 payload binds the case-sensitive key identifier, canonical workspace and actor UUIDv4 values, issuance time, uppercase HTTP method, and exact `/v1/...` path. - -The trusted web boundary requires `AI_GATEWAY_ACTIVE_KEY_ID` and `AI_GATEWAY_ACTIVE_KEY_SECRET` and signs only with that pair. AI service requires the same active pair and may additionally receive the complete verification-only overlap pair `AI_GATEWAY_PREVIOUS_KEY_ID` and `AI_GATEWAY_PREVIOUS_KEY_SECRET`. Each secret must contain 32–4096 UTF-8 bytes, active and previous identifiers and secrets must be distinct, and all material remains server-only. - """.replace(' ', ''), - 'runtime keyed context contract', - ) - replace_once( - readme_path, - """## Secret rotation and rollback - - This contract currently supports one active secret. Rotation requires a coordinated trusted-proxy and AI-service deployment; zero-downtime overlapping verification keys are deferred. If signer and verifier become incompatible, disable external AI proposal traffic rather than falling back to unsigned ownership headers. Secret compromise requires coordinated replacement, waiting at least the 60-second context lifetime before treating old tags as expired, and reviewing proposal/decision audit evidence for forged activity. - """.replace(' ', ''), - """## Secret rotation and rollback - - LifeOS supports one active signing key and one bounded previous verification key. Follow `docs/operations/ai-gateway-key-rotation.md` to expand verifier configuration, switch the signer, retain the former active key only through the request-validity and deployment overlap window, and retire it by removing the previous pair. Unknown and retired identifiers fail closed immediately; the verifier never trials every configured secret. If signer and verifier become incompatible, disable external AI proposal traffic rather than falling back to unsigned ownership headers. Suspected compromise requires immediate revocation rather than a normal overlap. - """.replace(' ', ''), - 'runtime rotation runbook', - ) - replace_once( - readme_path, - "External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, multi-secret rotation windows, asymmetric workload identity, and separately authorized action execution remain independent reviewed capabilities.", - "External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, asymmetric workload identity, and separately authorized action execution remain independent reviewed capabilities.", - 'deferred work update', - ) - - research_path = 'docs/research/2026-08-04-ai-gateway-key-rotation-standards.md' - replace_once( - research_path, - """### Key lifecycle and bounded overlap - - NIST SP 800-57 Part 1 Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. - """.replace(' ', ''), - """### Key lifecycle and bounded overlap - - NIST SP 800-57 Part 1 Revision 5 remains the current final publication. NIST published Revision 6 as an Initial Public Draft in December 2025; its comment period closed in February 2026. LifeOS treats Revision 5 as the final normative baseline while tracking the newer draft's expanded keying-material storage and lifecycle direction so the implementation does not silently depend on draft status. - - Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. - """.replace(' ', ''), - 'current NIST publication status', - ) - research_file = Path(research_path) - research = research_file.read_text(encoding='utf-8') - final_reference = """Barker, E. (2020). *Recommendation for key management: Part 1—General* (NIST Special Publication 800-57 Part 1 Revision 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5 - """.replace(' ', '') - draft_reference = """Barker, E., & Barker, W. (2025). *Recommendation for key management: Part 1—General* (Initial Public Draft, NIST Special Publication 800-57 Part 1 Revision 6). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/57/pt1/r6/ipd - - """.replace(' ', '') - if draft_reference.strip() not in research: - if research.count(final_reference) != 1: - raise SystemExit('NIST final reference insertion point was not unique') - research = research.replace( - final_reference, - final_reference + '\n' + draft_reference, - 1, - ) - research_file.write_text(research, encoding='utf-8') - - changelog_path = Path('CHANGELOG.md') - changelog = changelog_path.read_text(encoding='utf-8') - security_heading = '### Security\n\n' - security_entry = ( - '- AI gateway service-context authentication now carries an integrity-protected key identifier, signs only with one active key, verifies one explicitly selected active or previous key during a bounded overlap, and rejects retired identifiers immediately without trial verification.\n' - ) - if security_entry.strip() not in changelog: - if changelog.count(security_heading) != 1: - raise SystemExit('CHANGELOG Security heading was not unique') - changelog = changelog.replace( - security_heading, - security_heading + security_entry, - 1, - ) - changelog_path.write_text(changelog, encoding='utf-8') - - capabilities_path = Path('product/capabilities.json') - capabilities = json.loads(capabilities_path.read_text(encoding='utf-8')) - capability = next( - ( - item - for item in capabilities['capabilities'] - if item['id'] == 'ai.auditable-proposals' - ), - None, - ) - if capability is None: - raise SystemExit('AI auditable proposal capability was not found') - additions = [ - { - 'maturity': 'usable', - 'kind': 'implementation', - 'mode': 'exists', - 'path': 'apps/ai-service/src/ai-gateway-keyring.ts', - }, - { - 'maturity': 'production', - 'kind': 'test', - 'mode': 'exists', - 'path': 'apps/ai-service/src/proposal-audit-http.integration.test.ts', - }, - { - 'maturity': 'production', - 'kind': 'documentation', - 'mode': 'exists', - 'path': 'docs/operations/ai-gateway-key-rotation.md', - }, - ] - existing_paths = {item['path'] for item in capability['evidence']} - capability['evidence'].extend( - item for item in additions if item['path'] not in existing_paths - ) - capabilities_path.write_text( - json.dumps(capabilities, indent=2) + '\n', - encoding='utf-8', - ) - - ai_package_path = Path('apps/ai-service/package.json') - ai_package = json.loads(ai_package_path.read_text(encoding='utf-8')) - ai_targets = [ - 'migrations/README.md', - '../../docs/operations/ai-gateway-key-rotation.md', - '../../docs/research/2026-08-04-ai-gateway-key-rotation-standards.md', - '../../docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md', - '../../docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md', - ] - ai_lint = ai_package['scripts']['lint'] - for target in ai_targets: - token = f' {target}' - if token not in ai_lint: - ai_lint += token - ai_package['scripts']['lint'] = ai_lint - ai_package_path.write_text( - json.dumps(ai_package, indent=2) + '\n', - encoding='utf-8', - ) - - root_package_path = Path('package.json') - root_package = json.loads(root_package_path.read_text(encoding='utf-8')) - root_targets = [ - 'CHANGELOG.md', - 'apps/ai-service/src/ai-gateway-keyring.ts', - 'apps/ai-service/src/ai-gateway-keyring.test.ts', - 'apps/ai-service/src/ai-http-boundary.ts', - 'apps/ai-service/src/ai-http-boundary.test.ts', - 'docs/operations/ai-gateway-key-rotation.md', - 'docs/research/2026-08-04-ai-gateway-key-rotation-standards.md', - 'docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md', - 'docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md', - ] - root_format = root_package['scripts']['format:check'] - for target in root_targets: - token = f' {target}' - if token not in root_format: - root_format += token - root_package['scripts']['format:check'] = root_format - root_package_path.write_text( - json.dumps(root_package, indent=2) + '\n', - encoding='utf-8', - ) - PY + - name: Apply final evidence mutations + run: python3 .github/scripts/finalize-ai-key-rotation.py - name: Format and commit final evidence run: | From 83c61feabdd6ac1f8b0283df61f8ab2d6b5b64d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:50:51 +0000 Subject: [PATCH 35/39] docs(ai): finalize gateway key rotation evidence --- CHANGELOG.md | 1 + apps/ai-service/migrations/README.md | 8 +++++--- apps/ai-service/package.json | 2 +- docs/operations/ai-gateway-key-rotation.md | 8 ++++---- ...-08-04-ai-gateway-key-rotation-standards.md | 14 +++++++++----- ...026-08-04-ai-gateway-key-rotation-design.md | 8 ++++---- package.json | 2 +- product/capabilities.json | 18 ++++++++++++++++++ 8 files changed, 43 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e936e795..fd6364c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ All notable changes to LifeOS are documented in this file. ### Security +- AI gateway service-context authentication now carries an integrity-protected key identifier, signs only with one active key, verifies one explicitly selected active or previous key during a bounded overlap, and rejects retired identifiers immediately without trial verification. - Planning-search upstream responses are stopped at a fixed byte limit before they can be fully buffered by the web boundary. - Notification persistence stores SHA-256 idempotency digests instead of raw delivery keys, validates every untrusted row, and keeps all SQL tenant-scoped and parameterized. - The AI production boundary rejects direct client-selected ownership headers, verifies a short-lived HMAC-SHA-256 context bound to workspace, actor, HTTP method, and exact path, returns credential-free problem details, and exposes no proposal apply or execution route. diff --git a/apps/ai-service/migrations/README.md b/apps/ai-service/migrations/README.md index 74187e3c7..beb6f7b64 100644 --- a/apps/ai-service/migrations/README.md +++ b/apps/ai-service/migrations/README.md @@ -24,7 +24,9 @@ The production module exposes the inert proposal-generation route together with - `GET /v1/proposals/:proposalId/decisions` - `POST /v1/proposals/:proposalId/decisions` -Every route requires a short-lived signed service context produced only after a trusted proxy authenticates a session and authorizes workspace membership. The headers are `x-life-os-workspace-id`, `x-life-os-actor-id`, `x-life-os-context-issued-at`, and `x-life-os-context-signature`. The HMAC-SHA-256 payload binds canonical workspace and actor UUIDv4 values to issuance time, uppercase HTTP method, and the exact `/v1/...` path. The shared `AI_GATEWAY_CONTEXT_SECRET` must contain 32–4096 UTF-8 bytes, remain server-only, and be identical in the trusted proxy and AI service. +Every route requires a short-lived signed service context produced only after a trusted proxy authenticates a session and authorizes workspace membership. The headers are `x-life-os-context-key-id`, `x-life-os-workspace-id`, `x-life-os-actor-id`, `x-life-os-context-issued-at`, and `x-life-os-context-signature`. The version 2 HMAC-SHA-256 payload binds the case-sensitive key identifier, canonical workspace and actor UUIDv4 values, issuance time, uppercase HTTP method, and exact `/v1/...` path. + +The trusted web boundary requires `AI_GATEWAY_ACTIVE_KEY_ID` and `AI_GATEWAY_ACTIVE_KEY_SECRET` and signs only with that pair. AI service requires the same active pair and may additionally receive the complete verification-only overlap pair `AI_GATEWAY_PREVIOUS_KEY_ID` and `AI_GATEWAY_PREVIOUS_KEY_SECRET`. Each secret must contain 32–4096 UTF-8 bytes, active and previous identifiers and secrets must be distinct, and all material remains server-only. Direct `x-workspace-id` and `x-actor-id` headers never authorize a route. Browser cookies, bearer material, client-selected ownership fields, and the HMAC secret must never be forwarded to or exposed by AI service. Missing, malformed, stale, future-dated, method-replayed, path-replayed, or forged context fails closed before the proposal or audit application receives tenant scope. @@ -60,10 +62,10 @@ CI supplies separate application and disposable-test variables, applies the migr ## Secret rotation and rollback -This contract currently supports one active secret. Rotation requires a coordinated trusted-proxy and AI-service deployment; zero-downtime overlapping verification keys are deferred. If signer and verifier become incompatible, disable external AI proposal traffic rather than falling back to unsigned ownership headers. Secret compromise requires coordinated replacement, waiting at least the 60-second context lifetime before treating old tags as expired, and reviewing proposal/decision audit evidence for forged activity. +LifeOS supports one active signing key and one bounded previous verification key. Follow `docs/operations/ai-gateway-key-rotation.md` to expand verifier configuration, switch the signer, retain the former active key only through the request-validity and deployment overlap window, and retire it by removing the previous pair. Unknown and retired identifiers fail closed immediately; the verifier never trials every configured secret. If signer and verifier become incompatible, disable external AI proposal traffic rather than falling back to unsigned ownership headers. Suspected compromise requires immediate revocation rather than a normal overlap. The database migration is forward-only in automated environments. An operator-approved rollback must export and verify proposal and decision evidence before dropping `ai.proposal_decision_events`, `ai.proposal_audit_records`, `ai.reject_proposal_audit_mutation()`, and the `ai` schema. Do not roll back after recording production decisions unless legal, retention, and audit requirements have been reviewed and documented. ## Deferred work -External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, multi-secret rotation windows, asymmetric workload identity, and separately authorized action execution remain independent reviewed capabilities. The audit service must not gain planning, calendar, habit, identity, notification, or generic command dependencies when those slices are added. +External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, asymmetric workload identity, and separately authorized action execution remain independent reviewed capabilities. The audit service must not gain planning, calendar, habit, identity, notification, or generic command dependencies when those slices are added. diff --git a/apps/ai-service/package.json b/apps/ai-service/package.json index e93cb5b6a..79fbd6334 100644 --- a/apps/ai-service/package.json +++ b/apps/ai-service/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "nest build", "dev": "nest start --watch --entryFile server", - "lint": "tsc --noEmit && prettier --single-quote --check package.json tsconfig.json vitest.config.ts \"src/**/*.ts\" ../../docs/operations/ai-proposal-audit-assurance.md ../../docs/superpowers/specs/2026-08-04-ai-service-quality-gates-design.md ../../docs/superpowers/plans/2026-08-04-ai-service-quality-gates.md", + "lint": "tsc --noEmit && prettier --single-quote --check package.json tsconfig.json vitest.config.ts \"src/**/*.ts\" ../../docs/operations/ai-proposal-audit-assurance.md ../../docs/superpowers/specs/2026-08-04-ai-service-quality-gates-design.md ../../docs/superpowers/plans/2026-08-04-ai-service-quality-gates.md migrations/README.md ../../docs/operations/ai-gateway-key-rotation.md ../../docs/research/2026-08-04-ai-gateway-key-rotation-standards.md ../../docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md ../../docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md", "test": "vitest run --no-file-parallelism --coverage", "typecheck": "tsc --noEmit", "start": "node dist/server.js" diff --git a/docs/operations/ai-gateway-key-rotation.md b/docs/operations/ai-gateway-key-rotation.md index 2ef83c219..95889abed 100644 --- a/docs/operations/ai-gateway-key-rotation.md +++ b/docs/operations/ai-gateway-key-rotation.md @@ -4,10 +4,10 @@ This runbook rotates the private web-gateway-to-AI-service context credential wi ## Configuration contract -| Role | Identifier | Secret | Sign | Verify | -| --- | --- | --- | --- | --- | -| Active | `AI_GATEWAY_ACTIVE_KEY_ID` | `AI_GATEWAY_ACTIVE_KEY_SECRET` | Yes | Yes | -| Previous | `AI_GATEWAY_PREVIOUS_KEY_ID` | `AI_GATEWAY_PREVIOUS_KEY_SECRET` | No | Yes | +| Role | Identifier | Secret | Sign | Verify | +| -------- | ---------------------------- | -------------------------------- | ---- | ------ | +| Active | `AI_GATEWAY_ACTIVE_KEY_ID` | `AI_GATEWAY_ACTIVE_KEY_SECRET` | Yes | Yes | +| Previous | `AI_GATEWAY_PREVIOUS_KEY_ID` | `AI_GATEWAY_PREVIOUS_KEY_SECRET` | No | Yes | The active pair is mandatory. The previous pair is optional but must be configured completely. Identifiers are case-sensitive and must be distinct. Secrets must be independently generated and delivered through the platform secret manager; never place them in Git, container images, command history, tickets, chat, or logs. diff --git a/docs/research/2026-08-04-ai-gateway-key-rotation-standards.md b/docs/research/2026-08-04-ai-gateway-key-rotation-standards.md index fc6760a70..b4f43b9a6 100644 --- a/docs/research/2026-08-04-ai-gateway-key-rotation-standards.md +++ b/docs/research/2026-08-04-ai-gateway-key-rotation-standards.md @@ -16,7 +16,9 @@ This design supports a gradual write-new/read-old rotation pattern while limitin ### Key lifecycle and bounded overlap -NIST SP 800-57 Part 1 Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. +NIST SP 800-57 Part 1 Revision 5 remains the current final publication. NIST published Revision 6 as an Initial Public Draft in December 2025; its comment period closed in February 2026. LifeOS treats Revision 5 as the final normative baseline while tracking the newer draft's expanded keying-material storage and lifecycle direction so the implementation does not silently depend on draft status. + +Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. OWASP recommends automated secret rotation, gradual rotation strategies that introduce new credentials for write operations while temporarily retaining old credentials for read or verification operations, least privilege, lifecycle metadata, and immediate revocation of no-longer-required secrets. The two-slot keyring is a deliberately bounded realization of that guidance. @@ -47,10 +49,12 @@ The existing boundary uses HMAC-SHA-256, constant-time comparison, a versioned c ## References -Barker, E. (2020). *Recommendation for key management: Part 1—General* (NIST Special Publication 800-57 Part 1 Revision 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5 +Barker, E. (2020). _Recommendation for key management: Part 1—General_ (NIST Special Publication 800-57 Part 1 Revision 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5 + +Barker, E., & Barker, W. (2025). _Recommendation for key management: Part 1—General_ (Initial Public Draft, NIST Special Publication 800-57 Part 1 Revision 6). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/57/pt1/r6/ipd -Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Signature (JWS)* (RFC 7515). Internet Engineering Task Force. https://doi.org/10.17487/RFC7515 +Jones, M., Bradley, J., & Sakimura, N. (2015). _JSON Web Signature (JWS)_ (RFC 7515). Internet Engineering Task Force. https://doi.org/10.17487/RFC7515 -Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current practices* (RFC 8725). Internet Engineering Task Force. https://doi.org/10.17487/RFC8725 +Sheffer, Y., Hardt, D., & Jones, M. (2020). _JSON Web Token best current practices_ (RFC 8725). Internet Engineering Task Force. https://doi.org/10.17487/RFC8725 -OWASP Foundation. (n.d.). *Secrets management cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 4, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html +OWASP Foundation. (n.d.). _Secrets management cheat sheet_. OWASP Cheat Sheet Series. Retrieved August 4, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html diff --git a/docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md b/docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md index 2752cd128..db2c3d2ee 100644 --- a/docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md +++ b/docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md @@ -53,10 +53,10 @@ The active pair is mandatory. Previous values are both absent or both present. A ## Failure mapping -| Condition | Boundary classification | -| --- | --- | -| Missing/malformed/unknown request key identifier | `401 invalid_gateway_context` | -| Signature mismatch, stale context, replayed method/path | `401 invalid_gateway_context` | +| Condition | Boundary classification | +| ----------------------------------------------------------------- | --------------------------------- | +| Missing/malformed/unknown request key identifier | `401 invalid_gateway_context` | +| Signature mismatch, stale context, replayed method/path | `401 invalid_gateway_context` | | Missing, partial, duplicate, or malformed local key configuration | `503 gateway_context_unavailable` | Responses remain RFC 9457-compatible, credential-free, and stable. Logs record only fixed failure classes and correlation metadata, never key identifiers, signatures, or secrets. diff --git a/package.json b/package.json index 3e3df4152..019d73072 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint": "turbo run lint", "test": "turbo run test", "typecheck": "turbo run typecheck", - "format:check": "prettier --single-quote --check README.md CONTRIBUTING.md SECURITY.md docs/legal/privacy.md docs/legal/terms.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs packages/commercial-readiness/src/legal-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md apps/planning-service/package.json apps/planning-service/migrations/README.md apps/planning-service/src/main.ts apps/planning-service/src/http-boundary.ts apps/planning-service/src/http-boundary.test.ts apps/planning-service/src/planning-domain.ts apps/planning-service/src/planning-domain.test.ts apps/planning-service/src/planning-runtime.ts apps/planning-service/src/planning-runtime.test.ts apps/planning-service/src/postgres-planning-repository.ts apps/planning-service/src/postgres-planning-repository.test.ts apps/planning-service/src/postgres-planning-repository.integration.test.ts apps/planning-service/src/search.ts apps/planning-service/src/search.test.ts apps/planning-service/src/postgres-planning-search.test.ts docs/superpowers/plans/2026-08-03-planning-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-planning-postgres-runtime-slice.md apps/habit-service/package.json apps/habit-service/migrations/README.md apps/habit-service/src/main.ts apps/habit-service/src/habit-domain.ts apps/habit-service/src/habit-domain.test.ts apps/habit-service/src/postgres-habit-repository.ts apps/habit-service/src/postgres-habit-repository.test.ts apps/habit-service/src/postgres-habit-repository.integration.test.ts apps/habit-service/src/habit-runtime.ts apps/habit-service/src/habit-runtime.test.ts apps/habit-service/src/http-boundary.ts apps/habit-service/src/http-boundary.test.ts apps/habit-service/src/habit-service.integration.test.ts docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md docs/superpowers/plans/2026-08-04-habit-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-habit-http-api-slice.md apps/ai-service/package.json apps/ai-service/tsconfig.json apps/ai-service/src/main.ts apps/ai-service/src/proposal-service.ts apps/ai-service/src/proposal-service.test.ts apps/ai-service/src/no-silent-mutation.integration.test.ts apps/ai-service/migrations/README.md apps/ai-service/src/proposal-audit-domain.ts apps/ai-service/src/proposal-audit-domain.test.ts apps/ai-service/src/postgres-proposal-audit-repository.ts apps/ai-service/src/postgres-proposal-audit-repository.test.ts apps/ai-service/src/postgres-proposal-audit-repository.integration.test.ts apps/ai-service/src/proposal-audit-application.ts apps/ai-service/src/proposal-audit-application.test.ts apps/ai-service/src/ai-runtime.ts apps/ai-service/src/ai-runtime.test.ts apps/ai-service/src/proposal-audit-http.integration.test.ts docs/superpowers/plans/2026-08-04-ai-proposal-audit-repository-slice.md docs/superpowers/plans/2026-08-04-ai-proposal-audit-api-slice.md apps/gateway/package.json apps/gateway/src/app.module.ts apps/gateway/src/main.ts apps/gateway/src/observability.ts apps/gateway/src/observability.test.ts packages/observability/package.json packages/observability/src/index.cjs packages/observability/src/index.d.ts packages/observability/src/index.test.cjs infra/observability/prometheus.yml infra/observability/alerts.yml docs/operations/service-level-objectives.md docs/superpowers/plans/2026-08-04-observability-slo-foundation-slice.md apps/planning-service/src/observability.ts apps/planning-service/src/observability.test.ts infra/observability/planning-alerts.yml docs/operations/planning-service-level-objectives.md docs/superpowers/plans/2026-08-04-gateway-structured-logging-slice.md apps/ai-service/vitest.config.ts apps/ai-service/src/docstring-coverage.test.ts docs/operations/ai-proposal-audit-assurance.md docs/superpowers/specs/2026-08-04-ai-service-quality-gates-design.md docs/superpowers/plans/2026-08-04-ai-service-quality-gates.md apps/ai-service/src/server.ts apps/ai-service/src/quality-coverage.test.ts apps/ai-service/src/server.test.ts", + "format:check": "prettier --single-quote --check README.md CONTRIBUTING.md SECURITY.md docs/legal/privacy.md docs/legal/terms.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs packages/commercial-readiness/src/legal-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md apps/planning-service/package.json apps/planning-service/migrations/README.md apps/planning-service/src/main.ts apps/planning-service/src/http-boundary.ts apps/planning-service/src/http-boundary.test.ts apps/planning-service/src/planning-domain.ts apps/planning-service/src/planning-domain.test.ts apps/planning-service/src/planning-runtime.ts apps/planning-service/src/planning-runtime.test.ts apps/planning-service/src/postgres-planning-repository.ts apps/planning-service/src/postgres-planning-repository.test.ts apps/planning-service/src/postgres-planning-repository.integration.test.ts apps/planning-service/src/search.ts apps/planning-service/src/search.test.ts apps/planning-service/src/postgres-planning-search.test.ts docs/superpowers/plans/2026-08-03-planning-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-planning-postgres-runtime-slice.md apps/habit-service/package.json apps/habit-service/migrations/README.md apps/habit-service/src/main.ts apps/habit-service/src/habit-domain.ts apps/habit-service/src/habit-domain.test.ts apps/habit-service/src/postgres-habit-repository.ts apps/habit-service/src/postgres-habit-repository.test.ts apps/habit-service/src/postgres-habit-repository.integration.test.ts apps/habit-service/src/habit-runtime.ts apps/habit-service/src/habit-runtime.test.ts apps/habit-service/src/http-boundary.ts apps/habit-service/src/http-boundary.test.ts apps/habit-service/src/habit-service.integration.test.ts docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md docs/superpowers/plans/2026-08-04-habit-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-habit-http-api-slice.md apps/ai-service/package.json apps/ai-service/tsconfig.json apps/ai-service/src/main.ts apps/ai-service/src/proposal-service.ts apps/ai-service/src/proposal-service.test.ts apps/ai-service/src/no-silent-mutation.integration.test.ts apps/ai-service/migrations/README.md apps/ai-service/src/proposal-audit-domain.ts apps/ai-service/src/proposal-audit-domain.test.ts apps/ai-service/src/postgres-proposal-audit-repository.ts apps/ai-service/src/postgres-proposal-audit-repository.test.ts apps/ai-service/src/postgres-proposal-audit-repository.integration.test.ts apps/ai-service/src/proposal-audit-application.ts apps/ai-service/src/proposal-audit-application.test.ts apps/ai-service/src/ai-runtime.ts apps/ai-service/src/ai-runtime.test.ts apps/ai-service/src/proposal-audit-http.integration.test.ts docs/superpowers/plans/2026-08-04-ai-proposal-audit-repository-slice.md docs/superpowers/plans/2026-08-04-ai-proposal-audit-api-slice.md apps/gateway/package.json apps/gateway/src/app.module.ts apps/gateway/src/main.ts apps/gateway/src/observability.ts apps/gateway/src/observability.test.ts packages/observability/package.json packages/observability/src/index.cjs packages/observability/src/index.d.ts packages/observability/src/index.test.cjs infra/observability/prometheus.yml infra/observability/alerts.yml docs/operations/service-level-objectives.md docs/superpowers/plans/2026-08-04-observability-slo-foundation-slice.md apps/planning-service/src/observability.ts apps/planning-service/src/observability.test.ts infra/observability/planning-alerts.yml docs/operations/planning-service-level-objectives.md docs/superpowers/plans/2026-08-04-gateway-structured-logging-slice.md apps/ai-service/vitest.config.ts apps/ai-service/src/docstring-coverage.test.ts docs/operations/ai-proposal-audit-assurance.md docs/superpowers/specs/2026-08-04-ai-service-quality-gates-design.md docs/superpowers/plans/2026-08-04-ai-service-quality-gates.md apps/ai-service/src/server.ts apps/ai-service/src/quality-coverage.test.ts apps/ai-service/src/server.test.ts CHANGELOG.md apps/ai-service/src/ai-gateway-keyring.ts apps/ai-service/src/ai-gateway-keyring.test.ts apps/ai-service/src/ai-http-boundary.ts apps/ai-service/src/ai-http-boundary.test.ts docs/operations/ai-gateway-key-rotation.md docs/research/2026-08-04-ai-gateway-key-rotation-standards.md docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md", "format": "prettier --single-quote --write ." }, "devDependencies": { diff --git a/product/capabilities.json b/product/capabilities.json index ec1f58290..a32549c66 100644 --- a/product/capabilities.json +++ b/product/capabilities.json @@ -721,6 +721,24 @@ "kind": "test", "mode": "exists", "path": "apps/ai-service/src/no-silent-mutation.integration.test.ts" + }, + { + "maturity": "usable", + "kind": "implementation", + "mode": "exists", + "path": "apps/ai-service/src/ai-gateway-keyring.ts" + }, + { + "maturity": "production", + "kind": "test", + "mode": "exists", + "path": "apps/ai-service/src/proposal-audit-http.integration.test.ts" + }, + { + "maturity": "production", + "kind": "documentation", + "mode": "exists", + "path": "docs/operations/ai-gateway-key-rotation.md" } ] }, From 75b823ddc778beb201720f122968e31c0287e099 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:52:23 +0900 Subject: [PATCH 36/39] chore(ai): remove completed key rotation helper workflow --- .../workflows/complete-ai-key-rotation.yml | 545 ------------------ 1 file changed, 545 deletions(-) delete mode 100644 .github/workflows/complete-ai-key-rotation.yml diff --git a/.github/workflows/complete-ai-key-rotation.yml b/.github/workflows/complete-ai-key-rotation.yml deleted file mode 100644 index 8241a4bf6..000000000 --- a/.github/workflows/complete-ai-key-rotation.yml +++ /dev/null @@ -1,545 +0,0 @@ -name: Complete AI Gateway Key Rotation - -on: - push: - branches: - - feat/ai-gateway-key-rotation - paths: - - '.github/workflows/complete-ai-key-rotation.yml' - -permissions: - contents: write - -concurrency: - group: complete-ai-gateway-key-rotation - cancel-in-progress: false - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - complete: - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - name: Checkout feature branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feat/ai-gateway-key-rotation - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@fdbd5b5d6a1c9bb1f77880bc2f733d820f4737e0 # v6.0.0 - with: - node-version: 22 - - - name: Enable Corepack - run: corepack enable - - - name: Install workspace dependencies - run: pnpm install --no-frozen-lockfile - - - name: Wire keyed context through web and test boundaries - shell: bash - run: | - set -Eeuo pipefail - python3 - <<'PY' - from pathlib import Path - - def read(path: str) -> str: - return Path(path).read_text(encoding='utf-8') - - def write(path: str, text: str) -> None: - Path(path).write_text(text, encoding='utf-8') - - def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f'{label}: expected one match, found {count}') - return text.replace(old, new, 1) - - # Web transport core: active-only signer and v2 keyed canonical input. - path = 'apps/web/app/ai-proposal-client-core.ts' - text = read(path) - text = replace_once( - text, - "const CANONICAL_UUID_V4_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;\n", - "const CANONICAL_UUID_V4_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;\nconst AI_GATEWAY_KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;\n", - 'web key-id pattern', - ) - text = replace_once( - text, - "export interface AiSessionPrincipal {\n readonly workspaceId: string;\n readonly actorId: string;\n}\n", - "export interface AiSessionPrincipal {\n readonly workspaceId: string;\n readonly actorId: string;\n}\n\n/** Active server-only key used to sign one exact AI service request. */\nexport interface AiGatewaySigningKey {\n readonly keyId: string;\n readonly secret: string;\n}\n", - 'web signing key interface', - ) - marker = """export function requireAiGatewaySecret(value: string | undefined): string { - if (typeof value !== 'string') { - throw new Error('AI gateway context secret is invalid'); - } - const bytes = Buffer.byteLength(value, 'utf8'); - if ( - bytes < MINIMUM_GATEWAY_SECRET_BYTES || - bytes > MAXIMUM_GATEWAY_SECRET_BYTES || - /[\\r\\n\\u0000]/u.test(value) - ) { - throw new Error('AI gateway context secret is invalid'); - } - return value; - } - """.replace(' ', '') - addition = marker + """ - /** Requires one bounded case-sensitive key identifier. */ - export function requireAiGatewayKeyId(value: string | undefined): string { - if (typeof value !== 'string' || !AI_GATEWAY_KEY_ID_PATTERN.test(value)) { - throw new Error('AI gateway context key identifier is invalid'); - } - return value; - } - - /** Parses exactly the active web signing pair and exposes no previous key. */ - export function requireAiGatewaySigningKey( - environment: WebEnvironment, - ): AiGatewaySigningKey { - return Object.freeze({ - keyId: requireAiGatewayKeyId(environment.AI_GATEWAY_ACTIVE_KEY_ID), - secret: requireAiGatewaySecret( - environment.AI_GATEWAY_ACTIVE_KEY_SECRET, - ), - }); - } - """.replace(' ', '') - text = replace_once(text, marker, addition, 'web signing parser') - - old_create = """/** Creates an exact short-lived method-and-path-bound AI service context. */ - export function createAiContextHeaders( - workspaceId: string, - actorId: string, - secretValue: string, - nowSeconds: number, - methodValue: unknown, - pathValue: unknown, - ): Readonly> { - const safeWorkspaceId = requireUuid( - workspaceId, - 'AI gateway context is invalid', - ); - const safeActorId = requireUuid(actorId, 'AI gateway context is invalid'); - const secret = requireAiGatewaySecret(secretValue); - const { method, path } = requireAiTarget(methodValue, pathValue); - if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { - throw new Error('AI gateway context is invalid'); - } - const issuedAt = String(nowSeconds); - const signature = createHmac('sha256', secret) - .update( - `life-os.ai-context.v1\\n${safeWorkspaceId}\\n${safeActorId}\\n${issuedAt}\\n${method}\\n${path}`, - 'utf8', - ) - .digest('base64url'); - return Object.freeze({ - 'x-life-os-workspace-id': safeWorkspaceId, - 'x-life-os-actor-id': safeActorId, - 'x-life-os-context-issued-at': issuedAt, - 'x-life-os-context-signature': signature, - }); - } - """.replace(' ', '') - new_create = """/** Creates an exact short-lived method-and-path-bound AI service context. */ - export function createAiContextHeaders( - workspaceId: string, - actorId: string, - signingKeyValue: AiGatewaySigningKey, - nowSeconds: number, - methodValue: unknown, - pathValue: unknown, - ): Readonly> { - const safeWorkspaceId = requireUuid( - workspaceId, - 'AI gateway context is invalid', - ); - const safeActorId = requireUuid(actorId, 'AI gateway context is invalid'); - const signingKey = Object.freeze({ - keyId: requireAiGatewayKeyId(signingKeyValue?.keyId), - secret: requireAiGatewaySecret(signingKeyValue?.secret), - }); - const { method, path } = requireAiTarget(methodValue, pathValue); - if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { - throw new Error('AI gateway context is invalid'); - } - const issuedAt = String(nowSeconds); - const signature = createHmac('sha256', signingKey.secret) - .update( - `life-os.ai-context.v2\\n${signingKey.keyId}\\n${safeWorkspaceId}\\n${safeActorId}\\n${issuedAt}\\n${method}\\n${path}`, - 'utf8', - ) - .digest('base64url'); - return Object.freeze({ - 'x-life-os-context-key-id': signingKey.keyId, - 'x-life-os-workspace-id': safeWorkspaceId, - 'x-life-os-actor-id': safeActorId, - 'x-life-os-context-issued-at': issuedAt, - 'x-life-os-context-signature': signature, - }); - } - """.replace(' ', '') - text = replace_once(text, old_create, new_create, 'web v2 context creator') - text = replace_once( - text, - " const secret = requireAiGatewaySecret(\n environment.AI_GATEWAY_CONTEXT_SECRET,\n );\n", - " const signingKey = requireAiGatewaySigningKey(environment);\n", - 'web active signer environment', - ) - text = replace_once( - text, - " secret,\n nowSeconds,\n parsedRequest.method,\n", - " signingKey,\n nowSeconds,\n parsedRequest.method,\n", - 'web signer call', - ) - write(path, text) - - # Re-export the keyed signing API. - path = 'apps/web/app/ai-proposal-client.ts' - text = read(path) - text = text.replace( - " parseAiSessionPrincipal,\n requireAiGatewaySecret,\n", - " parseAiSessionPrincipal,\n requireAiGatewayKeyId,\n requireAiGatewaySecret,\n requireAiGatewaySigningKey,\n", - ) - text = text.replace( - " type AiProposalRoute,\n type AiSessionPrincipal,\n", - " type AiProposalRoute,\n type AiGatewaySigningKey,\n type AiSessionPrincipal,\n", - ) - text = text.replace( - " parseAiSessionPrincipal,\n requireAiGatewaySecret,\n", - " parseAiSessionPrincipal,\n requireAiGatewayKeyId,\n requireAiGatewaySecret,\n requireAiGatewaySigningKey,\n", - 1, - ) - text = text.replace( - "export type { AiProposalFetch, AiProposalRoute, AiSessionPrincipal };", - "export type {\n AiGatewaySigningKey,\n AiProposalFetch,\n AiProposalRoute,\n AiSessionPrincipal,\n};", - ) - write(path, text) - - # Web transport evidence. - path = 'apps/web/app/ai-proposal-client.test.ts' - text = read(path) - text = text.replace( - " parseAiSessionPrincipal,\n requireAiGatewaySecret,\n", - " parseAiSessionPrincipal,\n requireAiGatewayKeyId,\n requireAiGatewaySecret,\n requireAiGatewaySigningKey,\n", - ) - text = text.replace( - "const GATEWAY_SECRET = Buffer.alloc(32, 7).toString('base64url');", - "const ACTIVE_KEY_ID = 'gateway-2026-08-a';\nconst GATEWAY_SECRET = Buffer.alloc(32, 7).toString('base64url');", - ) - text = text.replace( - " AI_GATEWAY_CONTEXT_SECRET: GATEWAY_SECRET,", - " AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_KEY_ID,\n AI_GATEWAY_ACTIVE_KEY_SECRET: GATEWAY_SECRET,", - ) - text = text.replace( - "`life-os.ai-context.v1\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${NOW_SECONDS}\\n${method}\\n${path}`", - "`life-os.ai-context.v2\\n${ACTIVE_KEY_ID}\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${NOW_SECONDS}\\n${method}\\n${path}`", - ) - text = replace_once( - text, - " assert.equal(aiHeaders.get('x-life-os-workspace-id'), WORKSPACE_ID);\n", - " assert.equal(aiHeaders.get('x-life-os-context-key-id'), ACTIVE_KEY_ID);\n assert.equal(aiHeaders.get('x-life-os-workspace-id'), WORKSPACE_ID);\n", - 'web forwarded key header assertion', - ) - text = text.replace( - "environment: { ...environment, AI_GATEWAY_CONTEXT_SECRET: 'short' },", - "environment: { ...environment, AI_GATEWAY_ACTIVE_KEY_SECRET: 'short' },", - ) - text = replace_once( - text, - " assert.equal(requireAiGatewaySecret(GATEWAY_SECRET), GATEWAY_SECRET);\n", - " assert.equal(requireAiGatewayKeyId(ACTIVE_KEY_ID), ACTIVE_KEY_ID);\n assert.equal(requireAiGatewaySecret(GATEWAY_SECRET), GATEWAY_SECRET);\n assert.deepEqual(requireAiGatewaySigningKey(environment), {\n keyId: ACTIVE_KEY_ID,\n secret: GATEWAY_SECRET,\n });\n", - 'web helper key assertions', - ) - text = text.replace( - " GATEWAY_SECRET,\n NOW_SECONDS,", - " { keyId: ACTIVE_KEY_ID, secret: GATEWAY_SECRET },\n NOW_SECONDS,", - ) - text = replace_once( - text, - " assert.equal(headers['x-life-os-workspace-id'], WORKSPACE_ID);\n", - " assert.equal(headers['x-life-os-context-key-id'], ACTIVE_KEY_ID);\n assert.equal(headers['x-life-os-workspace-id'], WORKSPACE_ID);\n", - 'web helper key header assertion', - ) - text = replace_once( - text, - " for (const secret of [\n", - " for (const keyId of ['', '-leading', 'white space', 'a'.repeat(65)]) {\n assert.throws(\n () => requireAiGatewayKeyId(keyId),\n new Error('AI gateway context key identifier is invalid'),\n );\n }\n for (const secret of [\n", - 'web invalid key-id helpers', - ) - write(path, text) - - # Other web tests use active-only server configuration. - for path in [ - 'apps/web/app/ai-proposal-scope-regression.test.ts', - 'apps/web/app/ai-proposal-identity-stream-regression.test.ts', - ]: - text = read(path) - const_name = 'SECRET' if 'scope' in path else 'GATEWAY_SECRET' - text = text.replace( - f"const {const_name} =", - "const ACTIVE_KEY_ID = 'gateway-2026-08-a';\n" + f"const {const_name} =", - 1, - ) - text = text.replace( - f" AI_GATEWAY_CONTEXT_SECRET: {const_name},", - f" AI_GATEWAY_ACTIVE_KEY_ID: ACTIVE_KEY_ID,\n AI_GATEWAY_ACTIVE_KEY_SECRET: {const_name},", - ) - write(path, text) - - # Next.js route integration uses both active environment fields. - path = 'apps/web/app/api/ai/proposals/routes.test.ts' - text = read(path) - text = text.replace( - "const SECRET = 'trusted-ai-gateway-context-secret-32-bytes';", - "const ACTIVE_KEY_ID = 'gateway-2026-08-a';\nconst SECRET = 'trusted-ai-gateway-context-secret-32-bytes';", - ) - text = text.replace( - " const originalSecret = process.env.AI_GATEWAY_CONTEXT_SECRET;", - " const originalKeyId = process.env.AI_GATEWAY_ACTIVE_KEY_ID;\n const originalSecret = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;", - ) - text = text.replace( - " process.env.AI_GATEWAY_CONTEXT_SECRET = SECRET;", - " process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID;\n process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = SECRET;", - ) - text = text.replace( - " if (originalSecret === undefined) {\n delete process.env.AI_GATEWAY_CONTEXT_SECRET;\n } else {\n process.env.AI_GATEWAY_CONTEXT_SECRET = originalSecret;\n }", - " if (originalKeyId === undefined) {\n delete process.env.AI_GATEWAY_ACTIVE_KEY_ID;\n } else {\n process.env.AI_GATEWAY_ACTIVE_KEY_ID = originalKeyId;\n }\n if (originalSecret === undefined) {\n delete process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;\n } else {\n process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = originalSecret;\n }", - ) - write(path, text) - - # AI unit/controller coverage: v2 signatures and five-header calls. - path = 'apps/ai-service/src/quality-coverage.test.ts' - text = read(path) - text = text.replace( - "const GATEWAY_SECRET = Buffer.alloc(32, 0x51).toString('base64url');", - "const GATEWAY_KEY_ID = 'gateway-2026-08-a';\nconst GATEWAY_SECRET = Buffer.alloc(32, 0x51).toString('base64url');", - ) - text = text.replace( - "interface SignedControllerContext {\n readonly workspaceId: string;", - "interface SignedControllerContext {\n readonly keyId: string;\n readonly workspaceId: string;", - ) - text = text.replace( - "`life-os.ai-context.v1\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${issuedAt}\\n${method}\\n${path}`", - "`life-os.ai-context.v2\\n${GATEWAY_KEY_ID}\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${issuedAt}\\n${method}\\n${path}`", - ) - text = text.replace( - " return {\n workspaceId: WORKSPACE_ID,", - " return {\n keyId: GATEWAY_KEY_ID,\n workspaceId: WORKSPACE_ID,", - 1, - ) - text = text.replace( - " vi.stubEnv('AI_GATEWAY_CONTEXT_SECRET', GATEWAY_SECRET);", - " vi.stubEnv('AI_GATEWAY_ACTIVE_KEY_ID', GATEWAY_KEY_ID);\n vi.stubEnv('AI_GATEWAY_ACTIVE_KEY_SECRET', GATEWAY_SECRET);", - ) - for variable in [ - 'proposalContext', - 'listContext', - 'detailContext', - 'decisionsReadContext', - 'decisionsWriteContext', - ]: - text = text.replace( - f"(\n {variable}.workspaceId,", - f"(\n {variable}.keyId,\n {variable}.workspaceId,", - ) - text = text.replace( - f"(\n {variable}.workspaceId,", - f"(\n {variable}.keyId,\n {variable}.workspaceId,", - ) - text = text.replace( - f"(\n undefined,\n {variable}.actorId,", - f"(\n {variable}.keyId,\n undefined,\n {variable}.actorId,", - ) - # Actor-missing decision call still needs a key identifier first. - text = text.replace( - "controller.appendDecision(\n decisionsWriteContext.workspaceId,\n undefined,", - "controller.appendDecision(\n decisionsWriteContext.keyId,\n decisionsWriteContext.workspaceId,\n undefined,", - ) - write(path, text) - - # No-silent-mutation HTTP integration. - path = 'apps/ai-service/src/no-silent-mutation.integration.test.ts' - text = read(path) - text = text.replace( - "const GATEWAY_SECRET = Buffer.alloc(32, 7).toString('base64url');", - "const GATEWAY_KEY_ID = 'gateway-2026-08-a';\nconst GATEWAY_SECRET = Buffer.alloc(32, 7).toString('base64url');", - ) - text = text.replace( - "`life-os.ai-context.v1\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${issuedAt}\\nPOST\\n${path}`", - "`life-os.ai-context.v2\\n${GATEWAY_KEY_ID}\\n${WORKSPACE_ID}\\n${ACTOR_ID}\\n${issuedAt}\\nPOST\\n${path}`", - ) - text = text.replace( - " return {\n 'x-life-os-workspace-id': WORKSPACE_ID,", - " return {\n 'x-life-os-context-key-id': GATEWAY_KEY_ID,\n 'x-life-os-workspace-id': WORKSPACE_ID,", - ) - text = text.replace( - " const originalSecret = process.env.AI_GATEWAY_CONTEXT_SECRET;\n process.env.AI_GATEWAY_CONTEXT_SECRET = GATEWAY_SECRET;", - " const originalKeyId = process.env.AI_GATEWAY_ACTIVE_KEY_ID;\n const originalSecret = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;\n process.env.AI_GATEWAY_ACTIVE_KEY_ID = GATEWAY_KEY_ID;\n process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = GATEWAY_SECRET;", - ) - text = text.replace( - " if (originalSecret === undefined) {\n delete process.env.AI_GATEWAY_CONTEXT_SECRET;\n } else {\n process.env.AI_GATEWAY_CONTEXT_SECRET = originalSecret;\n }", - " if (originalKeyId === undefined) {\n delete process.env.AI_GATEWAY_ACTIVE_KEY_ID;\n } else {\n process.env.AI_GATEWAY_ACTIVE_KEY_ID = originalKeyId;\n }\n if (originalSecret === undefined) {\n delete process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;\n } else {\n process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = originalSecret;\n }", - ) - write(path, text) - - # Production PostgreSQL HTTP integration and rotation lifecycle evidence. - path = 'apps/ai-service/src/proposal-audit-http.integration.test.ts' - text = read(path) - text = text.replace( - "const ORIGINAL_GATEWAY_CONTEXT_SECRET = process.env.AI_GATEWAY_CONTEXT_SECRET;\nconst GATEWAY_CONTEXT_SECRET = 'ai-http-integration-gateway-secret-32-bytes';", - "const ORIGINAL_ACTIVE_KEY_ID = process.env.AI_GATEWAY_ACTIVE_KEY_ID;\nconst ORIGINAL_ACTIVE_KEY_SECRET = process.env.AI_GATEWAY_ACTIVE_KEY_SECRET;\nconst ORIGINAL_PREVIOUS_KEY_ID = process.env.AI_GATEWAY_PREVIOUS_KEY_ID;\nconst ORIGINAL_PREVIOUS_KEY_SECRET = process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET;\nconst ACTIVE_KEY_ID = 'gateway-2026-08-b';\nconst PREVIOUS_KEY_ID = 'gateway-2026-07-a';\nconst ACTIVE_KEY_SECRET = Buffer.alloc(32, 0x41).toString('base64url');\nconst PREVIOUS_KEY_SECRET = Buffer.alloc(32, 0x42).toString('base64url');", - ) - text = text.replace( - " secret?: string;\n}", - " keyId?: string;\n secret?: string;\n}", - 1, - ) - text = text.replace( - " const signature = createHmac('sha256', input.secret ?? GATEWAY_CONTEXT_SECRET)\n .update(\n `life-os.ai-context.v1\\n${workspaceId}\\n${actorId}\\n${issuedAt}\\n${input.method}\\n${input.path}`, - " const keyId = input.keyId ?? ACTIVE_KEY_ID;\n const signature = createHmac('sha256', input.secret ?? ACTIVE_KEY_SECRET)\n .update(\n `life-os.ai-context.v2\\n${keyId}\\n${workspaceId}\\n${actorId}\\n${issuedAt}\\n${input.method}\\n${input.path}`, - ) - text = text.replace( - " return {\n 'x-life-os-workspace-id': workspaceId,", - " return {\n 'x-life-os-context-key-id': keyId,\n 'x-life-os-workspace-id': workspaceId,", - ) - text = text.replace( - " process.env.AI_GATEWAY_CONTEXT_SECRET = GATEWAY_CONTEXT_SECRET;", - " process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID;\n process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET;\n delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID;\n delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET;", - ) - old_restore = """ if (ORIGINAL_GATEWAY_CONTEXT_SECRET === undefined) { - delete process.env.AI_GATEWAY_CONTEXT_SECRET; - } else { - process.env.AI_GATEWAY_CONTEXT_SECRET = ORIGINAL_GATEWAY_CONTEXT_SECRET; - } - """.replace(' ', '') - new_restore = """ for (const [name, value] of [ - ['AI_GATEWAY_ACTIVE_KEY_ID', ORIGINAL_ACTIVE_KEY_ID], - ['AI_GATEWAY_ACTIVE_KEY_SECRET', ORIGINAL_ACTIVE_KEY_SECRET], - ['AI_GATEWAY_PREVIOUS_KEY_ID', ORIGINAL_PREVIOUS_KEY_ID], - ['AI_GATEWAY_PREVIOUS_KEY_SECRET', ORIGINAL_PREVIOUS_KEY_SECRET], - ] as const) { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } - } - """.replace(' ', '') - text = replace_once(text, old_restore, new_restore, 'AI integration env restore') - insertion_marker = """ it('persists proposals across restarts and appends replay-safe decisions', async () => { - """.replace(' ', '') - rotation_test = """ it('accepts one previous in-flight key during overlap and rejects it after retirement', async () => { - const workspaceId = randomUUID(); - const actorId = randomUUID(); - const taskId = randomUUID(); - process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; - process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; - process.env.AI_GATEWAY_PREVIOUS_KEY_ID = PREVIOUS_KEY_ID; - process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET = PREVIOUS_KEY_SECRET; - const app = await NestFactory.create(AiProductionModule, { logger: false }); - await app.listen(0, '127.0.0.1'); - try { - const address = app.getHttpServer().address() as AddressInfo; - const overlap = await requestJson( - address, - 'POST', - '/v1/proposals', - proposalRequest(taskId), - signedContextHeaders({ - workspaceId, - actorId, - method: 'POST', - path: '/v1/proposals', - keyId: PREVIOUS_KEY_ID, - secret: PREVIOUS_KEY_SECRET, - }), - ); - expect(overlap.statusCode).toBe(201); - - delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; - delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; - const retired = await requestJson( - address, - 'POST', - '/v1/proposals', - proposalRequest(randomUUID()), - signedContextHeaders({ - workspaceId, - actorId, - method: 'POST', - path: '/v1/proposals', - keyId: PREVIOUS_KEY_ID, - secret: PREVIOUS_KEY_SECRET, - }), - ); - expect(retired).toMatchObject({ - statusCode: 401, - body: { code: 'invalid_gateway_context' }, - }); - } finally { - await app.close(); - process.env.AI_GATEWAY_ACTIVE_KEY_ID = ACTIVE_KEY_ID; - process.env.AI_GATEWAY_ACTIVE_KEY_SECRET = ACTIVE_KEY_SECRET; - delete process.env.AI_GATEWAY_PREVIOUS_KEY_ID; - delete process.env.AI_GATEWAY_PREVIOUS_KEY_SECRET; - } - }); - - """.replace(' ', '') - text = replace_once(text, insertion_marker, rotation_test + insertion_marker, 'AI overlap integration') - write(path, text) - - # Public example exposes active signer and optional previous verifier only. - path = '.env.example' - text = read(path) - text = replace_once( - text, - 'AI_GATEWAY_CONTEXT_SECRET=replace-with-at-least-32-random-bytes\n', - 'AI_GATEWAY_ACTIVE_KEY_ID=gateway-2026-08-a\nAI_GATEWAY_ACTIVE_KEY_SECRET=replace-with-at-least-32-random-bytes\nAI_GATEWAY_PREVIOUS_KEY_ID=\nAI_GATEWAY_PREVIOUS_KEY_SECRET=\n', - 'environment example', - ) - write(path, text) - PY - - - name: Format modified sources - run: | - pnpm exec prettier --single-quote --write \ - apps/web/app/ai-proposal-client-core.ts \ - apps/web/app/ai-proposal-client.ts \ - apps/web/app/ai-proposal-client.test.ts \ - apps/web/app/ai-proposal-scope-regression.test.ts \ - apps/web/app/ai-proposal-identity-stream-regression.test.ts \ - apps/web/app/api/ai/proposals/routes.test.ts \ - apps/ai-service/src/quality-coverage.test.ts \ - apps/ai-service/src/no-silent-mutation.integration.test.ts \ - apps/ai-service/src/proposal-audit-http.integration.test.ts - - - name: Commit the wired slice before verification - run: | - set -Eeuo pipefail - rm -f pnpm-lock.yaml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add .env.example apps/web apps/ai-service - git diff --cached --check - git commit -m 'feat(ai): wire versioned keyed gateway context' - git push origin HEAD:feat/ai-gateway-key-rotation - - - name: Verify AI and web boundaries - run: | - pnpm --filter @life-os/ai-service lint - pnpm --filter @life-os/ai-service test - pnpm --filter @life-os/web lint - pnpm --filter @life-os/web test - - - name: Remove one-shot workflow after verified execution - run: | - set -Eeuo pipefail - git rm .github/workflows/complete-ai-key-rotation.yml - git commit -m 'chore(ai): remove key rotation implementation helper' - git push origin HEAD:feat/ai-gateway-key-rotation From a2ce614c6a25c0ff5b927813b94c090fb339702e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:52:36 +0900 Subject: [PATCH 37/39] chore(ai): remove completed key rotation fixer workflow --- .../workflows/fix-ai-key-rotation-helper.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/fix-ai-key-rotation-helper.yml diff --git a/.github/workflows/fix-ai-key-rotation-helper.yml b/.github/workflows/fix-ai-key-rotation-helper.yml deleted file mode 100644 index da055e172..000000000 --- a/.github/workflows/fix-ai-key-rotation-helper.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Fix AI Key Rotation Helper - -on: - push: - branches: - - feat/ai-gateway-key-rotation - paths: - - '.github/workflows/fix-ai-key-rotation-helper.yml' - -permissions: - contents: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - fix: - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - name: Checkout feature branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feat/ai-gateway-key-rotation - fetch-depth: 0 - - - name: Repair helper action pin and remove this fixer - run: | - set -Eeuo pipefail - sed -i \ - 's#actions/setup-node@fdbd5b5d6a1c9bb1f77880bc2f733d820f4737e0 #actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #' \ - .github/workflows/complete-ai-key-rotation.yml - git rm .github/workflows/fix-ai-key-rotation-helper.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add .github/workflows/complete-ai-key-rotation.yml - git diff --cached --check - git commit -m 'fix(ci): use available setup-node action pin' - git push origin HEAD:feat/ai-gateway-key-rotation From 8c6d85df75f7bc289dd1a347361b0061341a54a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:52:53 +0900 Subject: [PATCH 38/39] chore(ai): remove completed key rotation verification workflow --- .../workflows/run-ai-key-rotation-codemod.yml | 70 ------------------- 1 file changed, 70 deletions(-) delete mode 100644 .github/workflows/run-ai-key-rotation-codemod.yml diff --git a/.github/workflows/run-ai-key-rotation-codemod.yml b/.github/workflows/run-ai-key-rotation-codemod.yml deleted file mode 100644 index 160ea4f0c..000000000 --- a/.github/workflows/run-ai-key-rotation-codemod.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: Finalize AI Gateway Key Rotation Evidence - -on: - push: - branches: - - feat/ai-gateway-key-rotation - paths: - - '.github/workflows/run-ai-key-rotation-codemod.yml' - -permissions: - contents: write - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - finalize: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Checkout feature branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feat/ai-gateway-key-rotation - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - - - name: Enable Corepack and install dependencies - run: | - corepack enable - pnpm install --frozen-lockfile - - - name: Apply final evidence mutations - run: python3 .github/scripts/finalize-ai-key-rotation.py - - - name: Format and commit final evidence - run: | - set -Eeuo pipefail - pnpm exec prettier --single-quote --write \ - package.json \ - apps/ai-service/package.json \ - apps/ai-service/migrations/README.md \ - CHANGELOG.md \ - product/capabilities.json \ - docs/operations/ai-gateway-key-rotation.md \ - docs/research/2026-08-04-ai-gateway-key-rotation-standards.md \ - docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md \ - docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add \ - package.json \ - apps/ai-service/package.json \ - apps/ai-service/migrations/README.md \ - CHANGELOG.md \ - product/capabilities.json \ - docs - git diff --cached --check - git commit -m 'docs(ai): finalize gateway key rotation evidence' - git push origin HEAD:feat/ai-gateway-key-rotation - - - name: Verify formatting, AI quality, and capability schema - run: | - pnpm format:check - pnpm --filter @life-os/ai-service lint - pnpm --filter @life-os/commercial-readiness test From a35a6097621983fc5ae55d25924273248da3d3c3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 5 Aug 2026 08:53:06 +0900 Subject: [PATCH 39/39] chore(ai): remove completed rotation evidence finalizer --- .github/scripts/finalize-ai-key-rotation.py | 199 -------------------- 1 file changed, 199 deletions(-) delete mode 100644 .github/scripts/finalize-ai-key-rotation.py diff --git a/.github/scripts/finalize-ai-key-rotation.py b/.github/scripts/finalize-ai-key-rotation.py deleted file mode 100644 index a17d00c5a..000000000 --- a/.github/scripts/finalize-ai-key-rotation.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Finalize AI gateway key-rotation documentation and capability evidence.""" - -from __future__ import annotations - -import json -from pathlib import Path - - -def replace_once(path: str, old: str, new: str, label: str) -> None: - """Replace one exact text block or fail before mutating the file.""" - - file_path = Path(path) - source = file_path.read_text(encoding="utf-8") - count = source.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one match, found {count}") - file_path.write_text(source.replace(old, new, 1), encoding="utf-8") - - -def update_runtime_documentation() -> None: - """Replace the obsolete single-secret runtime and rotation contracts.""" - - readme_path = "apps/ai-service/migrations/README.md" - replace_once( - readme_path, - """Every route requires a short-lived signed service context produced only after a trusted proxy authenticates a session and authorizes workspace membership. The headers are `x-life-os-workspace-id`, `x-life-os-actor-id`, `x-life-os-context-issued-at`, and `x-life-os-context-signature`. The HMAC-SHA-256 payload binds canonical workspace and actor UUIDv4 values to issuance time, uppercase HTTP method, and the exact `/v1/...` path. The shared `AI_GATEWAY_CONTEXT_SECRET` must contain 32–4096 UTF-8 bytes, remain server-only, and be identical in the trusted proxy and AI service. -""", - """Every route requires a short-lived signed service context produced only after a trusted proxy authenticates a session and authorizes workspace membership. The headers are `x-life-os-context-key-id`, `x-life-os-workspace-id`, `x-life-os-actor-id`, `x-life-os-context-issued-at`, and `x-life-os-context-signature`. The version 2 HMAC-SHA-256 payload binds the case-sensitive key identifier, canonical workspace and actor UUIDv4 values, issuance time, uppercase HTTP method, and exact `/v1/...` path. - -The trusted web boundary requires `AI_GATEWAY_ACTIVE_KEY_ID` and `AI_GATEWAY_ACTIVE_KEY_SECRET` and signs only with that pair. AI service requires the same active pair and may additionally receive the complete verification-only overlap pair `AI_GATEWAY_PREVIOUS_KEY_ID` and `AI_GATEWAY_PREVIOUS_KEY_SECRET`. Each secret must contain 32–4096 UTF-8 bytes, active and previous identifiers and secrets must be distinct, and all material remains server-only. -""", - "runtime keyed context contract", - ) - replace_once( - readme_path, - """## Secret rotation and rollback - -This contract currently supports one active secret. Rotation requires a coordinated trusted-proxy and AI-service deployment; zero-downtime overlapping verification keys are deferred. If signer and verifier become incompatible, disable external AI proposal traffic rather than falling back to unsigned ownership headers. Secret compromise requires coordinated replacement, waiting at least the 60-second context lifetime before treating old tags as expired, and reviewing proposal/decision audit evidence for forged activity. -""", - """## Secret rotation and rollback - -LifeOS supports one active signing key and one bounded previous verification key. Follow `docs/operations/ai-gateway-key-rotation.md` to expand verifier configuration, switch the signer, retain the former active key only through the request-validity and deployment overlap window, and retire it by removing the previous pair. Unknown and retired identifiers fail closed immediately; the verifier never trials every configured secret. If signer and verifier become incompatible, disable external AI proposal traffic rather than falling back to unsigned ownership headers. Suspected compromise requires immediate revocation rather than a normal overlap. -""", - "runtime rotation runbook", - ) - replace_once( - readme_path, - "External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, multi-secret rotation windows, asymmetric workload identity, and separately authorized action execution remain independent reviewed capabilities.", - "External model transport, prompt and context redaction, policy evaluation, model-quality evaluation, asymmetric workload identity, and separately authorized action execution remain independent reviewed capabilities.", - "deferred work update", - ) - - -def update_research_review() -> None: - """Record the current final NIST baseline and newer public draft status.""" - - research_path = "docs/research/2026-08-04-ai-gateway-key-rotation-standards.md" - replace_once( - research_path, - """### Key lifecycle and bounded overlap - -NIST SP 800-57 Part 1 Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. -""", - """### Key lifecycle and bounded overlap - -NIST SP 800-57 Part 1 Revision 5 remains the current final publication. NIST published Revision 6 as an Initial Public Draft in December 2025; its comment period closed in February 2026. LifeOS treats Revision 5 as the final normative baseline while tracking the newer draft's expanded keying-material storage and lifecycle direction so the implementation does not silently depend on draft status. - -Revision 5 treats cryptographic keys and their metadata as managed assets with defined lifecycle states, protection requirements, cryptoperiods, and accountability. The implementation therefore treats the identifier as protected key metadata, requires explicit active and previous roles, and documents deployment, activation, retirement, and emergency revocation. -""", - "current NIST publication status", - ) - research_file = Path(research_path) - research = research_file.read_text(encoding="utf-8") - final_reference = "Barker, E. (2020). *Recommendation for key management: Part 1—General* (NIST Special Publication 800-57 Part 1 Revision 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-57pt1r5\n" - draft_reference = "Barker, E., & Barker, W. (2025). *Recommendation for key management: Part 1—General* (Initial Public Draft, NIST Special Publication 800-57 Part 1 Revision 6). National Institute of Standards and Technology. https://csrc.nist.gov/pubs/sp/800/57/pt1/r6/ipd\n\n" - if draft_reference.strip() not in research: - if research.count(final_reference) != 1: - raise SystemExit("NIST final reference insertion point was not unique") - research = research.replace( - final_reference, - final_reference + "\n" + draft_reference, - 1, - ) - research_file.write_text(research, encoding="utf-8") - - -def update_changelog() -> None: - """Add the buyer-visible key-rotation security outcome to Unreleased.""" - - path = Path("CHANGELOG.md") - source = path.read_text(encoding="utf-8") - heading = "### Security\n\n" - entry = "- AI gateway service-context authentication now carries an integrity-protected key identifier, signs only with one active key, verifies one explicitly selected active or previous key during a bounded overlap, and rejects retired identifiers immediately without trial verification.\n" - if entry.strip() not in source: - if source.count(heading) != 1: - raise SystemExit("CHANGELOG Security heading was not unique") - source = source.replace(heading, heading + entry, 1) - path.write_text(source, encoding="utf-8") - - -def update_capability_evidence() -> None: - """Register implementation, integration, and runbook evidence.""" - - path = Path("product/capabilities.json") - document = json.loads(path.read_text(encoding="utf-8")) - capability = next( - ( - item - for item in document["capabilities"] - if item["id"] == "ai.auditable-proposals" - ), - None, - ) - if capability is None: - raise SystemExit("AI auditable proposal capability was not found") - additions = [ - { - "maturity": "usable", - "kind": "implementation", - "mode": "exists", - "path": "apps/ai-service/src/ai-gateway-keyring.ts", - }, - { - "maturity": "production", - "kind": "test", - "mode": "exists", - "path": "apps/ai-service/src/proposal-audit-http.integration.test.ts", - }, - { - "maturity": "production", - "kind": "documentation", - "mode": "exists", - "path": "docs/operations/ai-gateway-key-rotation.md", - }, - ] - existing_paths = {item["path"] for item in capability["evidence"]} - capability["evidence"].extend( - item for item in additions if item["path"] not in existing_paths - ) - path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") - - -def append_script_targets(package_path: str, script_name: str, targets: list[str]) -> None: - """Append unique whitespace-delimited formatter targets to one package script.""" - - path = Path(package_path) - package = json.loads(path.read_text(encoding="utf-8")) - command = package["scripts"][script_name] - for target in targets: - token = f" {target}" - if token not in command: - command += token - package["scripts"][script_name] = command - path.write_text(json.dumps(package, indent=2) + "\n", encoding="utf-8") - - -def update_formatting_contracts() -> None: - """Put every new source and document under deterministic formatting checks.""" - - append_script_targets( - "apps/ai-service/package.json", - "lint", - [ - "migrations/README.md", - "../../docs/operations/ai-gateway-key-rotation.md", - "../../docs/research/2026-08-04-ai-gateway-key-rotation-standards.md", - "../../docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md", - "../../docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md", - ], - ) - append_script_targets( - "package.json", - "format:check", - [ - "CHANGELOG.md", - "apps/ai-service/src/ai-gateway-keyring.ts", - "apps/ai-service/src/ai-gateway-keyring.test.ts", - "apps/ai-service/src/ai-http-boundary.ts", - "apps/ai-service/src/ai-http-boundary.test.ts", - "docs/operations/ai-gateway-key-rotation.md", - "docs/research/2026-08-04-ai-gateway-key-rotation-standards.md", - "docs/superpowers/specs/2026-08-04-ai-gateway-key-rotation-design.md", - "docs/superpowers/plans/2026-08-04-ai-gateway-key-rotation.md", - ], - ) - - -def main() -> None: - """Apply every finalization mutation in a fail-closed order.""" - - update_runtime_documentation() - update_research_review() - update_changelog() - update_capability_evidence() - update_formatting_contracts() - - -if __name__ == "__main__": - main()