Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ on:
- web/services/vms/drivers/acpmuxPersonKey.ts
- web/services/vms/drivers/cmuxTuiDaemon.ts
- scripts/cmux-next/check-acpmux-guest-commands.ts
# The production link keyset mint (test-node/link-keyset-mint.test.ts).
- scripts/cmux-next/release/mint-link-keyset.mjs
- .github/workflows/backend.yml
- .github/workflows/backend-migrations.yml
- scripts/cmux-next/release/prod-promote.ts
Expand All @@ -49,6 +51,8 @@ on:
- web/services/vms/drivers/acpmuxPersonKey.ts
- web/services/vms/drivers/cmuxTuiDaemon.ts
- scripts/cmux-next/check-acpmux-guest-commands.ts
# The production link keyset mint (test-node/link-keyset-mint.test.ts).
- scripts/cmux-next/release/mint-link-keyset.mjs
- .github/workflows/backend.yml
- .github/workflows/backend-migrations.yml
- scripts/cmux-next/release/prod-promote.ts
Expand Down
68 changes: 50 additions & 18 deletions .github/workflows/platform-prod-secrets.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
name: Platform production secrets

# One-off. cmux-platform-production is taking over NEXT Cloud's CloudDO routes from cmux-api production
# (the CloudDO move into cmux-platform) and needs the same JWT_PRIVATE_JWK and CLOUD_LINK_SIGNING_KEYS as
# cmux-api production, plus a Freestyle key. The cmux-next-production environment is the only GitHub copy;
# this job pipes what it holds (stdin, never logged) into the Worker's secrets. It writes only these three
# names, only when the Worker lacks the name (it never overwrites one), and prints no value. A name the
# environment does not hold is skipped with a notice. Same pattern as platform-dev-secrets.yml (#30461).
# Dispatch-only: the push that landed it (#32470) registered it and ran it once. Dispatch it from
# feat-cmux-next again only after cmux-next-production holds a skipped name's production copy.
# Its list-then-put check is not atomic (#32470 review): do not dispatch it while anyone else is
# (the CloudDO move into cmux-platform) with a fresh namespace. It needs two secrets:
# - CLOUD_LINK_SIGNING_KEYS: minted fresh here (scripts/cmux-next/release/mint-link-keyset.mjs, one Ed25519 kid
# "production-<UTC date>" that signs at once) and piped straight into wrangler. Production never issued a link
# token, and link tokens are verified only by Cloud VMs against the keyset the platform itself serves, so
# nothing needs cmux-api production's copy.
# - CLOUD_FREESTYLE_API_KEY: piped from the cmux-next-production environment's FREESTYLE_API_KEY.
# It needs no JWT_PRIVATE_JWK: the platform verifies cmux-api's install tokens against cmux-api's public JWKS.
# Every value goes through stdin only: none is printed, echoed or written to disk. It puts a name only when the
# Worker lacks it and never overwrites one. Same pattern as platform-dev-secrets.yml (#30461).
# Dispatch-only: dispatch it from feat-cmux-next.
# Its list-then-put check cannot be atomic (#32470 review; Cloudflare has no create-if-absent for a Worker
# secret): it re-reads the list right before each put, and it must not be dispatched while anyone else is
# putting these secrets on cmux-platform-production.
on:
workflow_dispatch:
Expand All @@ -30,23 +34,51 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
- name: Put missing JWT_PRIVATE_JWK, CLOUD_LINK_SIGNING_KEYS and CLOUD_FREESTYLE_API_KEY on cmux-platform-production
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: scripts/cmux-next/release/mint-link-keyset.mjs
sparse-checkout-cone-mode: false
- name: Put missing CLOUD_LINK_SIGNING_KEYS (fresh) and CLOUD_FREESTYLE_API_KEY on cmux-platform-production
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: 0c1675e0def6de1ab3a50a4e17dc5656
JWT_PRIVATE_JWK: ${{ secrets.JWT_PRIVATE_JWK }}
CLOUD_LINK_SIGNING_KEYS: ${{ secrets.CLOUD_LINK_SIGNING_KEYS }}
CLOUD_FREESTYLE_API_KEY: ${{ secrets.FREESTYLE_API_KEY }}
MINT: ${{ github.workspace }}/scripts/cmux-next/release/mint-link-keyset.mjs
run: |
set -euo pipefail
cd "$RUNNER_TEMP"
W=cmux-platform-production
WR="npx -y wrangler@4.156.0"
$WR secret list --name "$W" --format json > existing.json
node -e 'for (const s of JSON.parse(require("fs").readFileSync("existing.json","utf8"))) console.log(s.name)' > existing.txt
for NAME in JWT_PRIVATE_JWK CLOUD_LINK_SIGNING_KEYS CLOUD_FREESTYLE_API_KEY; do
if grep -qx "$NAME" existing.txt; then echo "::notice::$W already has $NAME; left as is"; continue; fi
if [ -z "${!NAME}" ]; then echo "::warning::cmux-next-production holds no copy for $NAME; skipped"; continue; fi
printf '%s' "${!NAME}" | $WR secret put "$NAME" --name "$W"
done
# Cloudflare has no create-if-absent for Worker secrets (a put always upserts), so each put re-reads the
# list right before it, after any mint, leaving only the seconds of one put for another writer to race.
# set -e does not apply inside an `if` condition, so has() checks itself: a failed or unreadable list stops
# the job (exit 1) instead of reading as "absent". 0 = the Worker has the name, 1 = it does not.
has() {
local out rc=0
out="$($WR secret list --name "$W" --format json)" || { echo "::error::wrangler secret list failed; nothing more put"; exit 1; }
printf '%s' "$out" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const l=JSON.parse(d);if(!Array.isArray(l))process.exit(9);process.exit(l.some(s=>s.name===process.argv[1])?0:3)})' "$1" || rc=$?
case "$rc" in 0) return 0 ;; 3) return 1 ;; *) echo "::error::could not read the secret list; nothing more put"; exit 1 ;; esac
}
if has CLOUD_LINK_SIGNING_KEYS; then
echo "::notice::$W already has CLOUD_LINK_SIGNING_KEYS; left as is"
else
# The value lives only in this shell variable and the pipe; a failed or empty mint stops the job
# before anything is put.
KEYSET="$(node "$MINT" production)"
if [ -z "$KEYSET" ]; then echo "::error::mint-link-keyset printed nothing; nothing put"; exit 1; fi
if has CLOUD_LINK_SIGNING_KEYS; then
echo "::notice::$W gained CLOUD_LINK_SIGNING_KEYS meanwhile; left as is"
else
printf '%s' "$KEYSET" | $WR secret put CLOUD_LINK_SIGNING_KEYS --name "$W"
fi
unset KEYSET
fi
if [ -z "$CLOUD_FREESTYLE_API_KEY" ]; then
echo "::warning::cmux-next-production holds no copy for CLOUD_FREESTYLE_API_KEY; skipped"
elif has CLOUD_FREESTYLE_API_KEY; then
echo "::notice::$W already has CLOUD_FREESTYLE_API_KEY; left as is"
else
printf '%s' "$CLOUD_FREESTYLE_API_KEY" | $WR secret put CLOUD_FREESTYLE_API_KEY --name "$W"
fi
$WR secret list --name "$W" --format json | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{for (const s of JSON.parse(d)) console.log(s.name)})'
38 changes: 38 additions & 0 deletions backend/apps/api/test-node/link-keyset-mint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// scripts/cmux-next/release/mint-link-keyset.mjs mints cmux-platform-production's CLOUD_LINK_SIGNING_KEYS
// (.github/workflows/platform-prod-secrets.yml pipes its stdout into wrangler). Its output must be a keyset
// parseSigningKeys accepts and that signs at once. Run under Node (vitest.node.config.ts): it spawns node.
import { spawnSync } from "node:child_process"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { describe, expect, it } from "vitest"
import { parseSigningKeys, publicKeyset, signingKid } from "../src/link-token.ts"

const script = join(dirname(fileURLToPath(import.meta.url)), "../../../../scripts/cmux-next/release/mint-link-keyset.mjs")
const mint = (...args: string[]) => spawnSync(process.execPath, [script, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })

describe("mint-link-keyset.mjs", () => {
it("prints a keyset parseSigningKeys accepts, with one kid that signs now and a public half without d", async () => {
const r = mint()
expect(r.status).toBe(0)
expect(r.stderr).toBe("")
const keys = parseSigningKeys(r.stdout)
expect(keys).not.toBeNull()
const kids = Object.keys(keys!.keys)
expect(kids).toEqual([keys!.active])
expect(keys!.active).toBe(`production-${new Date().toISOString().slice(0, 10)}`)
expect(signingKid(keys!, Date.now())).toBe(keys!.active)
const pub = await publicKeyset(keys!)
expect(Object.keys(pub.keys)).toEqual(kids)
for (const k of Object.values(pub.keys)) expect(k).not.toHaveProperty("d")
})

it("mints a different key each run", () => {
expect(mint().stdout).not.toBe(mint().stdout)
})

it("refuses a prefix that makes an invalid kid and prints nothing", () => {
const r = mint("bad prefix")
expect(r.status).toBe(2)
expect(r.stdout).toBe("")
})
})
22 changes: 22 additions & 0 deletions scripts/cmux-next/release/mint-link-keyset.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env node
// Mints a fresh CLOUD_LINK_SIGNING_KEYS value: {"active":"<kid>","keys":{"<kid>":<Ed25519 private JWK>}}, the shape
// backend/apps/api/src/link-token.ts parseSigningKeys (and cmux-platform's LinkToken.ts) accepts. One kid and no
// published_at, so it signs at once (signingKid). The value goes to stdout only, for a pipe straight into
// `wrangler secret put`; nothing is logged or written to disk. Usage: node mint-link-keyset.mjs [kid-prefix]
// (default "production"; the kid is "<prefix>-<YYYY-MM-DD>", UTC).
import { generateKeyPairSync } from "node:crypto"

const KID = /^[A-Za-z0-9._-]{1,64}$/
const prefix = process.argv[2] ?? "production"
const kid = `${prefix}-${new Date().toISOString().slice(0, 10)}`
if (!KID.test(kid)) {
process.stderr.write(`mint-link-keyset: invalid kid prefix (must make a kid matching ${KID})\n`)
process.exit(2)
}
const { privateKey } = generateKeyPairSync("ed25519")
const jwk = privateKey.export({ format: "jwk" })
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string" || typeof jwk.d !== "string" || !jwk.x || !jwk.d) {
process.stderr.write("mint-link-keyset: node:crypto returned an unexpected JWK shape\n")
process.exit(1)
}
process.stdout.write(JSON.stringify({ active: kid, keys: { [kid]: { kty: "OKP", crv: "Ed25519", x: jwk.x, d: jwk.d, kid } } }))
Loading