Repository navigation
feat(ai): add zero-downtime gateway HMAC key rotation #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 29 commits
Commits
Show all changes
39 commits
Select commit
Hold shift + click to select a range
ed27081
feat(ai): add bounded gateway keyring configuration
seonghobae 7b9ae10
test(ai): cover gateway keyring rotation contracts
seonghobae 473c54e
docs(ai): record gateway key rotation standards
seonghobae 0eecc5a
docs(ai): add staged gateway key rotation runbook
seonghobae 38cdf79
docs(ai): design bounded gateway key rotation
seonghobae cbf00e9
docs(ai): plan zero-downtime gateway key rotation slice
seonghobae 0487757
style(ai): format keyring test table
seonghobae c296dff
ci: format AI gateway keyring evidence
seonghobae eee4a3b
style(ai): format gateway keyring evidence
github-actions[bot] 573c354
ci: remove temporary keyring formatter
seonghobae bdf4959
docs(ai): document gateway key error constructors
seonghobae 3f0154d
test(ai): handle empty rejected key material safely
seonghobae 901ed7a
fix(ai): reject reused gateway rotation material
seonghobae 120db48
test(ai): cover duplicate gateway key material
seonghobae 8176aa9
feat(ai): verify versioned keyed gateway context
seonghobae dcefa96
refactor(ai): fail closed on gateway key errors
seonghobae 99524dd
test(ai): cover active overlap and retired gateway keys
seonghobae c91b683
feat(ai): require keyed context on every controller
seonghobae 0840806
ci(ai): wire and verify gateway key rotation
seonghobae 4c456c6
ci(ai): repair key rotation helper action pin
seonghobae 99c8229
ci(ai): execute key rotation codemod with valid action pins
seonghobae b5e273b
fix(ci): defer malformed integration codemod segment
seonghobae e8f3648
feat(ai): wire versioned keyed gateway context
github-actions[bot] 96e100f
fix(ci): format and verify keyed gateway context
seonghobae 7f42499
style(ai): format keyed gateway context slice
github-actions[bot] 175efc5
fix(ci): repair explicit signing key evidence
seonghobae 03928b8
fix(ci): use structural signing-key test repair
seonghobae 9b5904e
fix(ai): pass explicit signing key in web evidence
github-actions[bot] 7926cdf
test(ai): wire PostgreSQL key rotation lifecycle evidence
seonghobae 400be9e
fix(ci): match wrapped gateway secret restoration
seonghobae ca4c18d
test(ai): prove zero-downtime gateway key rotation
github-actions[bot] 321a93f
docs(ai): finalize rotation standards and release evidence
seonghobae 56ca9c2
chore(ai): add temporary rotation evidence finalizer
seonghobae 6ed34cb
fix(ci): run rotation finalizer from a valid workflow
seonghobae 83c61fe
docs(ai): finalize gateway key rotation evidence
github-actions[bot] 75b823d
chore(ai): remove completed key rotation helper workflow
seonghobae a2ce614
chore(ai): remove completed key rotation fixer workflow
seonghobae 8c6d85d
chore(ai): remove completed key rotation verification workflow
seonghobae a35a609
chore(ai): remove completed rotation evidence finalizer
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,255 @@ | ||
| name: Verify AI Gateway Rotation Integration | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - feat/ai-gateway-key-rotation | ||
| paths: | ||
| - '.github/workflows/run-ai-key-rotation-codemod.yml' | ||
|
|
||
| permissions: | ||
| contents: write | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
github-advanced-security[bot] marked this conversation as resolved.
Fixed
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
|
|
||
| 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 | ||
| 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: Wire active and previous keys into PostgreSQL HTTP evidence | ||
| run: | | ||
| set -Eeuo pipefail | ||
| python3 - <<'PY' | ||
| 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 | ||
| 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<Record<string, string>> { | ||
| """.replace(' ', ''), | ||
| """ issuedAt?: number; | ||
| keyId?: string; | ||
| secret?: string; | ||
| }): Readonly<Record<string, string>> { | ||
| """.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', | ||
| ) | ||
|
|
||
| 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 and commit integration evidence | ||
| run: | | ||
| set -Eeuo pipefail | ||
| 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/src/proposal-audit-http.integration.test.ts | ||
| git diff --cached --check | ||
| 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 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.