From d33c641c6759d4bfbdc955d1209ceff1d32aeebd Mon Sep 17 00:00:00 2001 From: Liam Cullen Date: Thu, 5 Nov 2026 20:10:38 +1000 Subject: [PATCH] chore(deploy): prepare sealed monorepo secret transfer --- .github/secret-transfer/bun.lock | 23 +++ .github/secret-transfer/core.ts | 150 +++++++++++++++++++ .github/secret-transfer/package.json | 12 ++ .github/secret-transfer/policy.json | 38 +++++ .github/secret-transfer/seal.ts | 59 ++++++++ .github/workflows/ci.yml | 10 +- .github/workflows/sealed-secret-transfer.yml | 55 +++++++ knip.json | 34 +++-- tsconfig.json | 3 +- 9 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 .github/secret-transfer/bun.lock create mode 100644 .github/secret-transfer/core.ts create mode 100644 .github/secret-transfer/package.json create mode 100644 .github/secret-transfer/policy.json create mode 100644 .github/secret-transfer/seal.ts create mode 100644 .github/workflows/sealed-secret-transfer.yml diff --git a/.github/secret-transfer/bun.lock b/.github/secret-transfer/bun.lock new file mode 100644 index 0000000..aac1169 --- /dev/null +++ b/.github/secret-transfer/bun.lock @@ -0,0 +1,23 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bent-mono-sealed-secret-transfer", + "dependencies": { + "libsodium-wrappers": "0.8.4", + "zod": "4.6.7", + }, + }, + }, + "overrides": { + "libsodium": "0.8.4", + }, + "packages": { + "libsodium": ["libsodium@0.8.4", "", {}, "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw=="], + + "libsodium-wrappers": ["libsodium-wrappers@0.8.4", "", { "dependencies": { "libsodium": "^0.8.0" } }, "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw=="], + + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + } +} diff --git a/.github/secret-transfer/core.ts b/.github/secret-transfer/core.ts new file mode 100644 index 0000000..2db70ae --- /dev/null +++ b/.github/secret-transfer/core.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import sodium from 'libsodium-wrappers'; +import { z } from 'zod'; + +const secretName = z.enum([ + 'AUTH_GITHUB_CLIENT_SECRET', + 'BETTER_AUTH_SECRET', + 'CLOUDFLARE_API_TOKEN', + 'FEEDBACK_PROXY_SECRET', + 'VERCEL_TOKEN', +]); +const targetSchema = z.strictObject({ + environment: z.enum(['jp-production', 'toolbar-production']), + keyId: z.string().min(1), + publicKey: z.string().min(1), + names: z.array(secretName), +}); +const policySchema = z.strictObject({ + schemaVersion: z.literal(1), + transferId: z.string().regex(/^[a-zA-Z0-9-]+$/), + target: z.strictObject({ + repository: z.literal('bentbrain/bent-mono'), + repositoryId: z.literal(1446488716), + ownerId: z.literal(28818884), + }), + workflowPath: z.literal('.github/workflows/sealed-secret-transfer.yml'), + ownerId: z.literal(28818884), + groups: z.strictObject({ + toolbar: z.strictObject({ + sourceRepository: z.literal('bentbrain/feedback-toolbar'), + sourceRepositoryId: z.literal(1418167129), + sourceEnvironment: z.literal('production'), + targets: z.array(targetSchema).length(2), + }), + }), +}); +type Policy = z.infer; +const hash = (value: string | Uint8Array): string => + createHash('sha256').update(value).digest('hex'); + +export function parsePolicy(value: unknown): Policy { + return policySchema.parse(value); +} +export function groupPolicy(policy: Policy, group: unknown) { + z.literal('toolbar').parse(group); + const selected = policy.groups.toolbar; + assert.deepEqual(selected.targets.map((target) => target.environment).sort(), [ + 'jp-production', + 'toolbar-production', + ]); + for (const target of selected.targets) { + const expected = + target.environment === 'jp-production' ? ['CLOUDFLARE_API_TOKEN'] : secretName.options; + assert.deepEqual([...target.names].sort(), [...expected].sort(), 'Unexpected secret allowlist'); + } + return selected; +} +export function sourceContext(policy: Policy, group: unknown, env: NodeJS.ProcessEnv) { + const selected = groupPolicy(policy, group); + const context = z + .object({ + GITHUB_EVENT_NAME: z.literal('workflow_dispatch'), + GITHUB_REF: z.literal('refs/heads/main'), + GITHUB_REF_PROTECTED: z.literal('true'), + GITHUB_REPOSITORY: z.literal(selected.sourceRepository), + GITHUB_REPOSITORY_ID: z.literal(String(selected.sourceRepositoryId)), + GITHUB_REPOSITORY_OWNER_ID: z.literal(String(policy.ownerId)), + GITHUB_ACTOR_ID: z.literal(String(policy.ownerId)), + GITHUB_TRIGGERING_ACTOR: z.literal('bentbrain'), + GITHUB_RUN_ATTEMPT: z.literal('1'), + GITHUB_SHA: z.string().regex(/^[a-f0-9]{40}$/), + GITHUB_RUN_ID: z + .string() + .regex(/^[1-9][0-9]*$/) + .transform(Number) + .pipe(z.number().int().positive()), + GITHUB_WORKFLOW_REF: z.literal( + `${selected.sourceRepository}/${policy.workflowPath}@refs/heads/main`, + ), + }) + .parse(env); + return { + repository: selected.sourceRepository, + repositoryId: selected.sourceRepositoryId, + ownerId: policy.ownerId, + sha: context.GITHUB_SHA, + runId: context.GITHUB_RUN_ID, + runAttempt: 1, + workflowRef: context.GITHUB_WORKFLOW_REF, + }; +} +const secretValue = z + .string() + .min(1) + .refine((value) => Buffer.byteLength(value) <= 48 * 1024); +const secretInput = z.strictObject({ + AUTH_GITHUB_CLIENT_SECRET: secretValue, + BETTER_AUTH_SECRET: secretValue, + CLOUDFLARE_API_TOKEN: secretValue, + FEEDBACK_PROXY_SECRET: secretValue, + VERCEL_TOKEN: secretValue, +}); +export async function seal( + policy: Policy, + group: unknown, + context: ReturnType, + environment: NodeJS.ProcessEnv, +) { + await sodium.ready; + const selected = groupPolicy(policy, group); + const secrets = secretInput.parse( + Object.fromEntries( + Object.entries(environment) + .filter(([name]) => name.startsWith('TRANSFER_SECRET_')) + .map(([name, value]) => [name.slice('TRANSFER_SECRET_'.length), value]), + ), + ); + const records = []; + for (const target of selected.targets) { + const key = sodium.from_base64(target.publicKey, sodium.base64_variants.ORIGINAL); + assert.equal(key.length, sodium.crypto_box_PUBLICKEYBYTES, 'Invalid target key'); + for (const name of target.names) { + const plaintext = sodium.from_string(secrets[name]); + try { + records.push({ + name, + environment: target.environment, + keyId: target.keyId, + publicKeySha256: hash(key), + encryptedValue: sodium.to_base64( + sodium.crypto_box_seal(plaintext, key), + sodium.base64_variants.ORIGINAL, + ), + }); + } finally { + sodium.memzero(plaintext); + } + } + } + return { + schemaVersion: 1, + transferId: policy.transferId, + policySha256: hash(JSON.stringify(policy)), + group: 'toolbar', + source: context, + target: policy.target, + records, + }; +} diff --git a/.github/secret-transfer/package.json b/.github/secret-transfer/package.json new file mode 100644 index 0000000..ca81817 --- /dev/null +++ b/.github/secret-transfer/package.json @@ -0,0 +1,12 @@ +{ + "name": "bent-mono-sealed-secret-transfer", + "private": true, + "type": "module", + "dependencies": { + "libsodium-wrappers": "0.8.4", + "zod": "4.6.7" + }, + "overrides": { + "libsodium": "0.8.4" + } +} diff --git a/.github/secret-transfer/policy.json b/.github/secret-transfer/policy.json new file mode 100644 index 0000000..cdd775f --- /dev/null +++ b/.github/secret-transfer/policy.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "transferId": "bent-mono-20261105-sealed-transfer-01", + "target": { + "repository": "bentbrain/bent-mono", + "repositoryId": 1446488716, + "ownerId": 28818884 + }, + "workflowPath": ".github/workflows/sealed-secret-transfer.yml", + "ownerId": 28818884, + "groups": { + "toolbar": { + "sourceRepository": "bentbrain/feedback-toolbar", + "sourceRepositoryId": 1418167129, + "sourceEnvironment": "production", + "targets": [ + { + "environment": "jp-production", + "keyId": "3380204578043523366", + "publicKey": "v81/nI0DG0KpYH0lP4xqX7uKe+6x/uk3nEMRDL6sCls=", + "names": ["CLOUDFLARE_API_TOKEN"] + }, + { + "environment": "toolbar-production", + "keyId": "3380204578043523366", + "publicKey": "zSR8lCo7uN8hxPNw1aFRLur+vbp78m1JYUuyuMyt5SQ=", + "names": [ + "AUTH_GITHUB_CLIENT_SECRET", + "BETTER_AUTH_SECRET", + "CLOUDFLARE_API_TOKEN", + "FEEDBACK_PROXY_SECRET", + "VERCEL_TOKEN" + ] + } + ] + } + } +} diff --git a/.github/secret-transfer/seal.ts b/.github/secret-transfer/seal.ts new file mode 100644 index 0000000..1444443 --- /dev/null +++ b/.github/secret-transfer/seal.ts @@ -0,0 +1,59 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { z } from 'zod'; +import { groupPolicy, parsePolicy, seal, sourceContext } from './core.js'; + +try { + const [group, ...extra] = process.argv.slice(2); + if (extra.length) throw new Error('Unexpected arguments'); + const value: unknown = JSON.parse( + readFileSync(new URL('./policy.json', import.meta.url), 'utf8'), + ); + const policy = parsePolicy(value); + const selected = groupPolicy(policy, group); + const context = sourceContext(policy, group, process.env); + const metadataEnv = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !name.startsWith('TRANSFER_SECRET_')), + ); + const get = (endpoint: string): unknown => { + const output: unknown = JSON.parse( + execFileSync('gh', ['api', endpoint], { + encoding: 'utf8', + env: metadataEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }), + ); + return output; + }; + const branch = z + .object({ object: z.object({ sha: z.string() }) }) + .parse(get(`repos/${selected.sourceRepository}/git/ref/heads/main`)); + if (branch.object.sha !== context.sha) throw new Error('Source main advanced'); + const history = z + .object({ + total_count: z.literal(1), + workflow_runs: z.tuple([z.object({ id: z.number().int().positive() })]), + }) + .parse( + get( + `repos/${selected.sourceRepository}/actions/workflows/sealed-secret-transfer.yml/runs?event=workflow_dispatch&per_page=100`, + ), + ); + if (history.workflow_runs[0].id !== context.runId) + throw new Error('This one-use workflow has a prior dispatch; review before any replacement'); + const manifest = await seal(policy, group, context, process.env); + const temporary = z.string().min(1).parse(process.env.RUNNER_TEMP); + const directory = join(temporary, 'sealed-secret-transfer', 'toolbar'); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + writeFileSync(join(directory, 'sealed-transfer.json'), JSON.stringify(manifest) + '\n', { + flag: 'wx', + mode: 0o600, + }); + // Ciphertext is the only durable output. Never log values or provider errors. +} catch { + console.error( + 'Sealed-secret transfer failed closed. Review source metadata, fixed policy and required secret names.', + ); + process.exitCode = 1; +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f2269e..4667323 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,14 @@ jobs: with: bun-version: '1.3.14' - run: bun install --frozen-lockfile + - name: Install pinned transfer verification dependencies + working-directory: .github/secret-transfer + run: bun install --ignore-scripts --frozen-lockfile - run: ${{ matrix.command }} + - name: Audit transfer dependencies + if: matrix.name == 'Dependency audit' + working-directory: .github/secret-transfer + run: bun audit --audit-level=high browser: name: Browser tests @@ -171,7 +178,8 @@ jobs: name: Deploy production if: >- !cancelled() && needs.gate.result == 'success' && - github.event_name == 'push' && github.ref == 'refs/heads/main' + github.event_name == 'push' && github.ref == 'refs/heads/main' && + vars.LEGACY_DEPLOYMENT_ENABLED == 'true' needs: gate permissions: contents: write diff --git a/.github/workflows/sealed-secret-transfer.yml b/.github/workflows/sealed-secret-transfer.yml new file mode 100644 index 0000000..f2a922d --- /dev/null +++ b/.github/workflows/sealed-secret-transfer.yml @@ -0,0 +1,55 @@ +name: One-use sealed secret transfer +on: + workflow_dispatch: +permissions: + contents: read + actions: read +concurrency: + group: one-use-sealed-secret-transfer + cancel-in-progress: false +jobs: + toolbar: + name: Seal toolbar + if: >- + github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && + github.ref_protected && github.run_attempt == 1 && + github.repository == 'bentbrain/feedback-toolbar' && + github.repository_id == '1418167129' && + github.repository_owner_id == '28818884' && github.actor_id == '28818884' + environment: production + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: '24.18.0' + package-manager-cache: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: '1.4.4' + - name: Install only the pinned sealing implementation + working-directory: .github/secret-transfer + run: bun install --ignore-scripts --frozen-lockfile + - name: Seal fixed secret allowlist to reviewed target keys + working-directory: .github/secret-transfer + env: + GH_TOKEN: ${{ github.token }} + TRANSFER_SECRET_AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.AUTH_GITHUB_CLIENT_SECRET }} + TRANSFER_SECRET_BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + TRANSFER_SECRET_CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + TRANSFER_SECRET_FEEDBACK_PROXY_SECRET: ${{ secrets.FEEDBACK_PROXY_SECRET }} + TRANSFER_SECRET_VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: bun seal.ts toolbar + - name: Upload ciphertext only + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: sealed-transfer-toolbar-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/sealed-secret-transfer/toolbar/sealed-transfer.json + if-no-files-found: error + include-hidden-files: false + retention-days: 1 + compression-level: 0 diff --git a/knip.json b/knip.json index 5ad2539..49cedfd 100644 --- a/knip.json +++ b/knip.json @@ -1,16 +1,24 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": [ - "src/server/vercel-proxy.ts", - "src/browser/toolbar.tsx", - "src/browser/embed.ts", - "src/browser/embed-module.ts", - "tests/**/*.test.ts", - "e2e/**/*.spec.ts", - "src/browser/styles.css", - "src/browser/dashboard/routes/**/*.tsx", - "src/server/main.ts" - ], - "project": ["src/**/*", "scripts/**/*.ts", "tests/**/*.ts", "e2e/**/*.ts", "src/**/*.css"], - "ignoreDependencies": [] + "ignoreDependencies": [], + "workspaces": { + ".": { + "entry": [ + "src/server/vercel-proxy.ts", + "src/browser/toolbar.tsx", + "src/browser/embed.ts", + "src/browser/embed-module.ts", + "tests/**/*.test.ts", + "e2e/**/*.spec.ts", + "src/browser/styles.css", + "src/browser/dashboard/routes/**/*.tsx", + "src/server/main.ts" + ], + "project": ["src/**/*", "scripts/**/*.ts", "tests/**/*.ts", "e2e/**/*.ts", "src/**/*.css"] + }, + ".github/secret-transfer": { + "entry": ["seal.ts"], + "project": ["**/*.ts"] + } + } } diff --git a/tsconfig.json b/tsconfig.json index c90ed94..e23e2ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ "eslint.config.js", "vite.config.ts", "react-router.config.ts", - ".react-router/types/**/*" + ".react-router/types/**/*", + ".github/secret-transfer/**/*.ts" ] }