diff --git a/.github/workflows/yank-list.yml b/.github/workflows/yank-list.yml new file mode 100644 index 00000000..db522972 --- /dev/null +++ b/.github/workflows/yank-list.yml @@ -0,0 +1,214 @@ +name: Yank list + +# RFC-0046 §9 (#226): re-signs yanked.json with the yank key, weekly and on demand, and publishes +# it to the append-only `yank-list` branch, which cloudhal.dev serves at +# /.well-known/cloudhal/yanked.json (apps/docs/src/worker/trust.ts). The list expires 30 days +# after it is signed, so the weekly run keeps it fresh; a stale list blocks only new installs. +# +# - The list signed is every entry of the published list, carried forward only after it verifies +# as a consumer would (its signature by the active yank key of the pinned-root keys.json), plus +# security/yank-list.json (main), plus the emergency entries from the inputs below, with a serial +# one above the published one (scripts/release-keys.ts sign-yanked). A published list that does +# not verify fails the run loudly; it is never re-signed. +# - `check` skips everything, with a notice, until the root ceremony has committed +# security/keys.json, and outside cloudhal-dev/cloudhal. +# - The `release-yank` environment has no required reviewer (the key only reduces trust); its +# branch policy allows `main` only. The job installs exactly @cloudhal/release-verify's pinned +# dependencies, without lifecycle scripts, so sign-yanked can verify the exact bytes it signs as +# a consumer would. The key reaches the Sign step only. +# - The `yank-list` branch is append-only: a ruleset blocks force-pushes and deletion, and its +# only bypass for updates is a deploy key held as YANK_LIST_DEPLOY_KEY by `release-yank`, so no +# other workflow's token can push to it (docs/security/release-signing-keys.md, First-time +# setup). That key reaches the Publish step only; the job's token can only read. +# - Emergency: Actions › Yank list › Run workflow with `yank` (versions) and/or `revoke` (release +# key ids), comma-separated, and a `reason`. Wait for a running dispatch to finish before +# starting another: GitHub keeps only one pending run per concurrency group and cancels older +# pending ones. Then record the entries in security/yank-list.json in a reviewed PR. A failed or +# cancelled run opens (or comments on) an issue titled "Yank list run failed". + +on: + schedule: + - cron: "17 4 * * 1" # Mondays 04:17 UTC + workflow_dispatch: + inputs: + yank: + description: Emergency yank of these versions (X.Y.Z or X.Y.Z-pre, comma-separated) + type: string + required: false + revoke: + description: Emergency revocation of these release key ids (comma-separated) + type: string + required: false + reason: + description: Why (required with yank or revoke; public) + type: string + required: false + first_run: + description: The very first list (no yank-list branch yet) + type: boolean + required: false + default: false + rotate: + description: Prune - start the yank key's list from security/yank-list.json alone (serial 1) + type: boolean + required: false + default: false + accept_expired: + description: Carry forward a published list that expired during an outage (still verified) + type: boolean + required: false + default: false + +permissions: {} + +# One run at a time. A pending run is replaced by a newer one (GitHub's rule), so dispatches wait +# for the previous run; several entries go in one dispatch. +concurrency: + group: yank-list + cancel-in-progress: false + +jobs: + check: + name: Ready to sign + if: github.repository == 'cloudhal-dev/cloudhal' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + ready: ${{ steps.keys.outputs.ready }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Is there a signed key list yet + id: keys + run: | + set -euo pipefail + if [ -f security/keys.json ] && [ -f security/keys.json.sig ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::no security/keys.json yet (before the root ceremony): nothing to sign" + echo "Skipped: no security/keys.json yet (before the root ceremony)." >> "$GITHUB_STEP_SUMMARY" + echo "ready=false" >> "$GITHUB_OUTPUT" + fi + + sign: + name: Sign and publish yanked.json + needs: check + if: needs.check.outputs.ready == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: release-yank + permissions: + contents: read # the push uses the environment's deploy key, the ruleset's only bypass + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + - name: Install the verifier only (no lifecycle scripts) + run: pnpm install --frozen-lockfile --ignore-scripts --filter @cloudhal/release-verify + - name: Read the published list + env: + FIRST_RUN: ${{ inputs.first_run && 'true' || 'false' }} + run: | + set -euo pipefail + if git ls-remote --exit-code --heads "https://github.com/${GITHUB_REPOSITORY}.git" yank-list >/dev/null; then + git clone --quiet --branch yank-list --single-branch "https://github.com/${GITHUB_REPOSITORY}.git" "$RUNNER_TEMP/yank-list" + elif [ "$FIRST_RUN" = "true" ]; then + git init --quiet -b yank-list "$RUNNER_TEMP/yank-list" + git -C "$RUNNER_TEMP/yank-list" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + else + echo "::error::there is no yank-list branch: run once with first_run (runbook, First-time setup)" + exit 1 + fi + - name: Sign + id: sign + env: + RELEASE_YANK_KEY: ${{ secrets.RELEASE_YANK_KEY }} + FIRST_RUN: ${{ inputs.first_run && 'true' || 'false' }} + ROTATE: ${{ inputs.rotate && 'true' || 'false' }} + ACCEPT_EXPIRED: ${{ inputs.accept_expired && 'true' || 'false' }} + YANK: ${{ inputs.yank }} + REVOKE: ${{ inputs.revoke }} + REASON: ${{ inputs.reason }} + run: | + set -euo pipefail + args=(sign-yanked --key-id cloudhal-yank-2026a --published "$RUNNER_TEMP/yank-list/yanked.json" --out "$RUNNER_TEMP/signed") + if [ "$FIRST_RUN" = "true" ]; then args+=(--first-run); fi + if [ "$ROTATE" = "true" ]; then args+=(--rotate); fi + if [ "$ACCEPT_EXPIRED" = "true" ]; then args+=(--accept-expired); fi + # --name=value, so a value that starts with "-" is never read as a flag. + if [ -n "$YANK" ]; then args+=("--yank=$YANK"); fi + if [ -n "$REVOKE" ]; then args+=("--revoke=$REVOKE"); fi + if [ -n "$REASON" ]; then args+=("--reason=$REASON"); fi + node scripts/release-keys.ts "${args[@]}" + - name: Publish to the yank-list branch + id: publish + env: + YANK_LIST_DEPLOY_KEY: ${{ secrets.YANK_LIST_DEPLOY_KEY }} + # github.com's published Ed25519 host key, pinned. + GITHUB_HOST_KEY: "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" + run: | + set -euo pipefail + umask 077 + printf '%s\n' "$YANK_LIST_DEPLOY_KEY" > "$RUNNER_TEMP/deploy-key" + printf '%s\n' "$GITHUB_HOST_KEY" > "$RUNNER_TEMP/known_hosts" + export GIT_SSH_COMMAND="ssh -i $RUNNER_TEMP/deploy-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$RUNNER_TEMP/known_hosts" + trap 'rm -f "$RUNNER_TEMP/deploy-key"' EXIT + cd "$RUNNER_TEMP/yank-list" + git remote set-url origin "git@github.com:${GITHUB_REPOSITORY}.git" + cp "$RUNNER_TEMP/signed/yanked.json" "$RUNNER_TEMP/signed/yanked.json.sig" . + serial="$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync("yanked.json","utf8")).serial))')" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add yanked.json yanked.json.sig + git commit --quiet -m "chore(yank-list): serial ${serial}" + # Never --force: the branch only ever gains commits. + git push --quiet origin yank-list + echo "published yanked.json serial ${serial}" + echo "Published yanked.json serial ${serial}." >> "$GITHUB_STEP_SUMMARY" + - name: Summary + if: always() + env: + SIGN: ${{ steps.sign.outcome }} + PUBLISH: ${{ steps.publish.outcome }} + YANK: ${{ inputs.yank }} + REVOKE: ${{ inputs.revoke }} + run: | + { + echo "| Step | Outcome |" + echo "| --- | --- |" + echo "| Sign | ${SIGN:-not run} |" + echo "| Publish | ${PUBLISH:-not run} |" + if [ -n "$YANK$REVOKE" ]; then echo; echo "Emergency entries requested: yank \`${YANK:-none}\`, revoke \`${REVOKE:-none}\`."; fi + } >> "$GITHUB_STEP_SUMMARY" + + notify: + name: Report a failed or cancelled run + needs: [check, sign] + if: always() && github.repository == 'cloudhal-dev/cloudhal' && (needs.check.result == 'failure' || needs.sign.result == 'failure' || needs.sign.result == 'cancelled') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write # one issue per streak of failures; nothing else + steps: + - name: Open or comment on the issue + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + title="Yank list run failed" + body="The Yank list workflow failed or was cancelled: ${run_url}. A cancelled dispatch may have carried an emergency yank or revocation: check its inputs and run it again (docs/security/release-signing-keys.md)." + existing="$(gh issue list --state open --search "in:title \"${title}\"" --json number --jq '.[0].number // empty')" + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "$body" + else + gh issue create --title "$title" --body "$body" + fi diff --git a/apps/docs/scripts/postbuild.ts b/apps/docs/scripts/postbuild.ts index f1491986..ba221bae 100644 --- a/apps/docs/scripts/postbuild.ts +++ b/apps/docs/scripts/postbuild.ts @@ -6,8 +6,12 @@ * fails when a schema's `$id` is not its URL here; * - every page as Markdown at `.md` and `/index.md`, links absolute; * - `/llms.txt`, `/llms-full.txt` and one pair per section (llmstxt.org); - * - the docs MCP's search index (`/_mcp/search-index.json`, read by `src/worker`). + * - the docs MCP's search index (`/_mcp/search-index.json`, read by `src/worker`); + * - the root-signed key list at `/.well-known/cloudhal/keys.json` and `.sig`, from the repo's + * `security/` once the root ceremony has made them (RFC-0046 §9; `yanked.json` is served live by + * `src/worker/trust.ts`). */ +import { existsSync } from "node:fs"; import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -114,7 +118,18 @@ async function writeLlms(pages: readonly Page[], schemas: readonly SchemaEntry[] } } +/** `security/keys.json` and `.sig`, when both exist, at `/.well-known/cloudhal/`. */ +async function copyKeyList(): Promise { + const files = ["keys.json", "keys.json.sig"].map((f) => path.join(REPO, "security", f)); + if (!files.every((f) => existsSync(f))) return false; + const out = path.join(DIST, ".well-known/cloudhal"); + await mkdir(out, { recursive: true }); + for (const f of files) await cp(f, path.join(out, path.basename(f))); + return true; +} + await cp(path.join(APP, "static"), DIST, { recursive: true }); +const keyList = await copyKeyList(); await writeFile(path.join(DIST, "_redirects"), `${redirectLines().join("\n")}\n`); const schemas = await copySchemas(); const pages = allPages(REPO, true); @@ -122,4 +137,6 @@ await writeMarkdown(pages); await writeLlms(pages, schemas); await mkdir(path.dirname(path.join(DIST, SEARCH_INDEX_PATH)), { recursive: true }); await writeFile(path.join(DIST, SEARCH_INDEX_PATH), JSON.stringify(buildSearchIndex(pages))); -console.log(`cloudhal.dev: ${pages.length} pages, ${schemas.length} schemas`); +console.log( + `cloudhal.dev: ${pages.length} pages, ${schemas.length} schemas${keyList ? ", keys.json" : ""}`, +); diff --git a/apps/docs/src/worker/index.ts b/apps/docs/src/worker/index.ts index 1a2aff79..73af18a9 100644 --- a/apps/docs/src/worker/index.ts +++ b/apps/docs/src/worker/index.ts @@ -4,11 +4,20 @@ * - `/mcp`: the read-only docs MCP (`mcp.ts`), rate limited per IP; * - `Accept: text/markdown` on a page answers its Markdown copy, with `Vary: Accept` and an * `x-markdown-tokens` estimate (RFC-0037 §4); + * - `/.well-known/cloudhal/yanked.json` and `.sig`: the signed yank list, from the `yank-list` + * branch, served only as a pair that verifies (`trust.ts`, RFC-0046 §9); * - everything else goes to the assets binding unchanged. */ import { SEARCH_INDEX_PATH, type SearchIndex } from "../lib/search.ts"; import { CORS, handleMcp } from "./mcp.ts"; import { isPagePath, markdownPath, pageId } from "./paths.ts"; +import { + cachePairStore, + KEY_LIST_PATH, + type PairCache, + YANK_LIST_PATHS, + yankList, +} from "./trust.ts"; /** The parts of the Workers bindings this script uses. */ export interface Env { @@ -35,6 +44,18 @@ async function markdownAsset(env: Env, origin: string, path: string): Promise | undefined> { + const res = await env.ASSETS.fetch(new Request(new URL(path, origin))); + return res.status === 200 ? new Uint8Array(await res.arrayBuffer()) : undefined; +} + /** True when the client asks for Markdown (`text/markdown` in `Accept`, not with `q=0`). */ export function wantsMarkdown(accept: string | null): boolean { return (accept ?? "").split(",").some((part) => { @@ -89,6 +110,11 @@ export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); if (url.pathname === "/mcp" || url.pathname === "/mcp/") return mcp(request, env, url); + if (Object.hasOwn(YANK_LIST_PATHS, url.pathname)) + return yankList(request, url.pathname, { + keyList: () => staticBytes(env, url.origin, KEY_LIST_PATH), + ...(colo ? { store: cachePairStore(colo) } : {}), + }); const page = (request.method === "GET" || request.method === "HEAD") && isPagePath(url.pathname); return page ? negotiate(request, env, url) : env.ASSETS.fetch(request); diff --git a/apps/docs/src/worker/trust.ts b/apps/docs/src/worker/trust.ts new file mode 100644 index 00000000..93128bfb --- /dev/null +++ b/apps/docs/src/worker/trust.ts @@ -0,0 +1,207 @@ +/** + * `/.well-known/cloudhal/yanked.json` and `.sig` (RFC-0046 §9, #226): the yank list the weekly + * *Yank list* workflow publishes to the append-only `yank-list` branch. + * + * The two files change together, so they are served only as a pair that belongs together: the + * Worker fetches both from fixed URLs (no part of the request in them, no edge cache), checks that + * the signature verifies the document with the yank key the static `keys.json` names, and keeps + * that pair as one cached entry for at most five minutes. When the upstream fails, or answers a + * pair that does not belong together (raw.githubusercontent.com caches each file on its own), the + * Worker serves the last good pair, else 502; never a mismatch. Both halves carry the same `etag`. + * + * The check is about consistency, not trust: consumers still verify `keys.json` against the pinned + * root and the yank list against it (`@cloudhal/release-verify`). `keys.json` changes yearly and is + * a static asset (scripts/postbuild.ts). + */ +const UPSTREAM = "https://raw.githubusercontent.com/cloudhal-dev/cloudhal/yank-list/"; +export const KEY_LIST_PATH = "/.well-known/cloudhal/keys.json"; + +/** The two paths this module serves, and which half of the pair and content type each is. */ +export const YANK_LIST_PATHS: Readonly< + Record +> = { + "/.well-known/cloudhal/yanked.json": { + file: "yanked.json", + half: "json", + type: "application/json", + }, + "/.well-known/cloudhal/yanked.json.sig": { + file: "yanked.json.sig", + half: "sig", + type: "application/octet-stream", + }, +}; + +/** Seconds a verified pair is served before it is fetched again; the list is valid for 30 days. */ +export const YANK_LIST_TTL = 300; +const MAX_BYTES = 256 * 1024; +const MAX_KEYS_BYTES = 64 * 1024; + +/** A pair whose signature verified, and when it was fetched. */ +type Bytes = Uint8Array; + +export interface YankPair { + readonly json: Bytes; + readonly sig: Bytes; + readonly fetchedAt: number; +} + +/** Where the last good pair is kept (per colo: the Cache API; tests: memory). */ +export interface PairStore { + get(): Promise; + put(pair: YankPair): Promise; +} + +type Fetch = (input: string, init?: RequestInit) => Promise; + +export interface YankListDeps { + /** The static `keys.json` bytes, or undefined before the root ceremony. */ + readonly keyList: () => Promise; + readonly fetchImpl?: Fetch; + readonly store?: PairStore; + readonly now?: () => number; +} + +const PAIR_KEY = "https://cloudhal.dev/__cloudhal/yank-pair"; + +/** The part of the Workers Cache API the store uses (`caches.default`). */ +export interface PairCache { + match(url: string): Promise; + put(url: string, response: Response): Promise; +} + +/** The last good pair in the colo's cache: signature (64 bytes) then document, one entry. */ +export function cachePairStore(cache: PairCache): PairStore { + return { + async get() { + const res = await cache.match(PAIR_KEY); + if (!res) return undefined; + const bytes = new Uint8Array(await res.arrayBuffer()); + if (bytes.length <= 64) return undefined; + return { + sig: bytes.slice(0, 64), + json: bytes.slice(64), + fetchedAt: Number(res.headers.get("x-fetched-at") ?? 0), + }; + }, + async put(pair) { + const body = new Uint8Array(64 + pair.json.length); + body.set(pair.sig, 0); + body.set(pair.json, 64); + await cache.put( + PAIR_KEY, + new Response(body, { + headers: { + "cache-control": "max-age=2592000", + "x-fetched-at": String(pair.fetchedAt), + }, + }), + ); + }, + }; +} + +function unavailable(reason: string): Response { + return new Response(`The yank list is unavailable upstream (${reason}). Try again shortly.\n`, { + status: 502, + headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }, + }); +} + +const fromBase64 = (s: string): Bytes | undefined => { + try { + return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); + } catch { + return undefined; + } +}; + +/** True when `sig` verifies `json` with the yank key `keys.json` lists under its signer's id. */ +export async function pairVerifies( + json: Bytes, + sig: Bytes, + keyList: Bytes | undefined, +): Promise { + if (!keyList || keyList.length > MAX_KEYS_BYTES || sig.length !== 64) return false; + try { + const doc = JSON.parse(new TextDecoder().decode(json)) as { signing?: { keyId?: unknown } }; + const keys = JSON.parse(new TextDecoder().decode(keyList)) as { + keys?: { keyId?: unknown; role?: unknown; publicKey?: unknown }[]; + }; + const keyId = doc?.signing?.keyId; + const listed = keys?.keys?.find((k) => k.keyId === keyId && k.role === "yank"); + const raw = typeof listed?.publicKey === "string" ? fromBase64(listed.publicKey) : undefined; + if (raw?.length !== 32) return false; + const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]); + return await crypto.subtle.verify({ name: "Ed25519" }, key, sig, json); + } catch { + return false; + } +} + +/** Both files from the upstream, uncached, or the reason they could not be read. */ +async function fetchPair(get: Fetch): Promise<{ json: Bytes; sig: Bytes } | string> { + try { + const [json, sig] = await Promise.all( + ["yanked.json", "yanked.json.sig"].map(async (file) => { + const res = await get(UPSTREAM + file, { cache: "no-store" }); + if (res.status !== 200) throw new Error(String(res.status)); + const bytes = new Uint8Array(await res.arrayBuffer()); + if (bytes.byteLength > MAX_BYTES) throw new Error("413"); + return bytes; + }), + ); + return { json: json as Bytes, sig: sig as Bytes }; + } catch (e) { + return (e as Error).message || "network"; + } +} + +async function pairTag(sig: Bytes): Promise { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", sig)); + return `"${Array.from(digest.subarray(0, 8), (b) => b.toString(16).padStart(2, "0")).join("")}"`; +} + +/** The current good pair: the cached one while fresh, else a fresh one that verifies, else the last good one. */ +async function currentPair(deps: YankListDeps): Promise { + const now = (deps.now ?? Date.now)(); + const stored = await deps.store?.get(); + if (stored && now - stored.fetchedAt < YANK_LIST_TTL * 1000) return stored; + const fresh = await fetchPair(deps.fetchImpl ?? fetch); + if (typeof fresh !== "string") { + if (await pairVerifies(fresh.json, fresh.sig, await deps.keyList())) { + const pair = { ...fresh, fetchedAt: now }; + await deps.store?.put(pair); + return pair; + } + } + if (stored) return stored; + return typeof fresh === "string" ? fresh : "the two files do not belong together"; +} + +/** The response for one of `YANK_LIST_PATHS` (the caller checks the path). */ +export async function yankList( + request: Request, + path: string, + deps: YankListDeps, +): Promise { + const target = YANK_LIST_PATHS[path]; + if (!target) return new Response("Not found\n", { status: 404 }); + if (request.method !== "GET" && request.method !== "HEAD") + return new Response("Method not allowed\n", { status: 405, headers: { allow: "GET, HEAD" } }); + const pair = await currentPair(deps); + if (typeof pair === "string") return unavailable(pair); + const bytes = target.half === "json" ? pair.json : pair.sig; + return new Response(request.method === "HEAD" ? null : bytes, { + status: 200, + headers: { + "content-type": target.type, + "content-length": String(bytes.byteLength), + // Shared caches revalidate: both halves carry the pair's etag, so they never mix. + "cache-control": "no-cache", + etag: await pairTag(pair.sig), + "access-control-allow-origin": "*", + "x-content-type-options": "nosniff", + }, + }); +} diff --git a/apps/docs/static/_headers b/apps/docs/static/_headers index da74a5a3..d1afa1b2 100644 --- a/apps/docs/static/_headers +++ b/apps/docs/static/_headers @@ -25,3 +25,11 @@ # The docs MCP's search index (read by the Worker, not a public API). /_mcp/* X-Robots-Tag: noindex + +# The root-signed release key list (RFC-0046 §9). yanked.json is served by the Worker (trust.ts). +/.well-known/cloudhal/* + Access-Control-Allow-Origin: * + Cache-Control: public, max-age=300 + +/.well-known/cloudhal/*.sig + Content-Type: application/octet-stream diff --git a/apps/docs/test/trust.test.ts b/apps/docs/test/trust.test.ts new file mode 100644 index 00000000..4366cd38 --- /dev/null +++ b/apps/docs/test/trust.test.ts @@ -0,0 +1,201 @@ +/** + * The yank-list proxy (RFC-0046 §9, #226): fixed upstream URLs with no request input and no edge + * cache, served only as a pair whose signature verifies with the yank key of the static keys.json, + * kept as one entry for five minutes; on a failure or a mismatched pair upstream, the last good + * pair or a 502, never a mismatch; and the Worker routing only the two well-known paths to it. + */ +import { generateKeyPairSync, type KeyObject, sign } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import worker, { type Env } from "../src/worker/index.ts"; +import { + type PairStore, + pairVerifies, + YANK_LIST_PATHS, + YANK_LIST_TTL, + type YankPair, + yankList, +} from "../src/worker/trust.ts"; + +const enc = (s: string) => new TextEncoder().encode(s); +function yankKey(): { privateKey: KeyObject; raw: string } { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const der = publicKey.export({ format: "der", type: "spki" }); + return { privateKey, raw: Buffer.from(der.subarray(12)).toString("base64") }; +} +const KEY = yankKey(); +const KEYS_JSON = enc( + JSON.stringify({ keys: [{ keyId: "cloudhal-yank-2026a", role: "yank", publicKey: KEY.raw }] }), +); +function list(serial: number) { + const json = enc(JSON.stringify({ serial, signing: { keyId: "cloudhal-yank-2026a" } })); + return { json, sig: new Uint8Array(sign(null, json, KEY.privateKey)) }; +} +const JSON_PATH = "/.well-known/cloudhal/yanked.json"; +const SIG_PATH = "/.well-known/cloudhal/yanked.json.sig"; + +function memoryStore(): PairStore & { pair?: YankPair } { + const store: PairStore & { pair?: YankPair } = { + get: async () => store.pair, + put: async (pair) => { + store.pair = pair; + }, + }; + return store; +} + +/** An upstream answering `files[name]` (bytes, a status, or a network failure). */ +function upstream(files: Record) { + const calls: { url: string; init: unknown }[] = []; + const fetchImpl = async (url: string, init?: unknown) => { + calls.push({ url, init }); + const answer = files[url.slice(url.lastIndexOf("/") + 1)]; + if (answer instanceof Error) throw answer; + if (typeof answer === "number") return new Response("x", { status: answer }); + return new Response(answer ?? null, { status: answer ? 200 : 404 }); + }; + return { calls, fetchImpl }; +} + +const get = (path: string, method = "GET") => + new Request(`https://cloudhal.dev${path}?x=../../evil`, { method }); +const bytes = async (res: Response) => new Uint8Array(await res.arrayBuffer()); + +describe("yankList", () => { + it("fetches exactly the yank-list branch's two raw files, uncached, and serves a pair that verifies", async () => { + const good = list(3); + const u = upstream({ "yanked.json": good.json, "yanked.json.sig": good.sig }); + const deps = { keyList: async () => KEYS_JSON, fetchImpl: u.fetchImpl, store: memoryStore() }; + const json = await yankList(get(JSON_PATH), JSON_PATH, deps); + expect(json.status).toBe(200); + expect(json.headers.get("content-type")).toBe("application/json"); + expect(json.headers.get("cache-control")).toBe("no-cache"); + expect(await bytes(json)).toEqual(good.json); + const sig = await yankList(get(SIG_PATH), SIG_PATH, deps); + expect(sig.headers.get("content-type")).toBe("application/octet-stream"); + expect(await bytes(sig)).toEqual(good.sig); + // Both halves name the same pair. + expect(sig.headers.get("etag")).toBe(json.headers.get("etag")); + // Fixed URLs, nothing from the request; no edge cache; the second request used the stored pair. + expect(u.calls).toEqual([ + { + url: "https://raw.githubusercontent.com/cloudhal-dev/cloudhal/yank-list/yanked.json", + init: { cache: "no-store" }, + }, + { + url: "https://raw.githubusercontent.com/cloudhal-dev/cloudhal/yank-list/yanked.json.sig", + init: { cache: "no-store" }, + }, + ]); + expect(YANK_LIST_TTL).toBeLessThanOrEqual(300); + }); + + it("never serves a mismatched pair: the last good pair, else 502", async () => { + const old = list(3); + const next = list(4); + // raw.githubusercontent.com caching each file on its own: the new document, the old signature. + const mismatch = { "yanked.json": next.json, "yanked.json.sig": old.sig }; + const fresh = { + keyList: async () => KEYS_JSON, + fetchImpl: upstream(mismatch).fetchImpl, + store: memoryStore(), + }; + const none = await yankList(get(JSON_PATH), JSON_PATH, fresh); + expect(none.status).toBe(502); + expect(none.headers.get("cache-control")).toBe("no-store"); + expect(fresh.store.pair).toBeUndefined(); + + const store = memoryStore(); + store.pair = { ...old, fetchedAt: 0 }; // last good, long past its five minutes + const deps = { ...fresh, store, now: () => 10 * 60 * 1000 }; + const json = await yankList(get(JSON_PATH), JSON_PATH, deps); + const sig = await yankList(get(SIG_PATH), SIG_PATH, deps); + expect(await bytes(json)).toEqual(old.json); + expect(await bytes(sig)).toEqual(old.sig); + expect(await pairVerifies(old.json, old.sig, KEYS_JSON)).toBe(true); + expect(await pairVerifies(next.json, old.sig, KEYS_JSON)).toBe(false); + expect(store.pair?.json).toEqual(old.json); // not replaced by the mismatch + }); + + it("refetches after five minutes and keeps the stored pair until then", async () => { + const old = list(3); + const next = list(4); + const store = memoryStore(); + store.pair = { ...old, fetchedAt: 1_000 }; + const u = upstream({ "yanked.json": next.json, "yanked.json.sig": next.sig }); + const at = (ms: number) => ({ + keyList: async () => KEYS_JSON, + fetchImpl: u.fetchImpl, + store, + now: () => ms, + }); + expect(await bytes(await yankList(get(JSON_PATH), JSON_PATH, at(1_000 + 299_000)))).toEqual( + old.json, + ); + expect(u.calls).toHaveLength(0); + expect(await bytes(await yankList(get(JSON_PATH), JSON_PATH, at(1_000 + 300_000)))).toEqual( + next.json, + ); + expect(store.pair?.fetchedAt).toBe(301_000); + }); + + it("answers 502 when the upstream fails, is too large, or there is no keys.json yet", async () => { + const good = list(3); + for (const files of [ + { "yanked.json": 404, "yanked.json.sig": good.sig }, + { "yanked.json": good.json, "yanked.json.sig": 503 }, + { "yanked.json": new Error("network"), "yanked.json.sig": good.sig }, + { "yanked.json": new Uint8Array(256 * 1024 + 1), "yanked.json.sig": good.sig }, + ]) { + const res = await yankList(get(JSON_PATH), JSON_PATH, { + keyList: async () => KEYS_JSON, + fetchImpl: upstream(files).fetchImpl, + store: memoryStore(), + }); + expect(res.status).toBe(502); + expect(res.headers.get("cache-control")).toBe("no-store"); + } + const beforeCeremony = await yankList(get(JSON_PATH), JSON_PATH, { + keyList: async () => undefined, + fetchImpl: upstream({ "yanked.json": good.json, "yanked.json.sig": good.sig }).fetchImpl, + }); + expect(beforeCeremony.status).toBe(502); + }); + + it("serves GET and HEAD only, and nothing outside its two paths", async () => { + const good = list(3); + const deps = { + keyList: async () => KEYS_JSON, + fetchImpl: upstream({ "yanked.json": good.json, "yanked.json.sig": good.sig }).fetchImpl, + }; + expect((await yankList(get(JSON_PATH, "POST"), JSON_PATH, deps)).status).toBe(405); + const head = await yankList(get(JSON_PATH, "HEAD"), JSON_PATH, deps); + expect(head.status).toBe(200); + expect(head.headers.get("content-length")).toBe(String(good.json.length)); + expect( + (await yankList(new Request("https://cloudhal.dev/x"), "/.well-known/cloudhal/x", deps)) + .status, + ).toBe(404); + expect(Object.keys(YANK_LIST_PATHS)).toEqual([JSON_PATH, SIG_PATH]); + }); + + it("the Worker routes only the two well-known yank paths to it; keys.json stays a static asset", async () => { + const seen: string[] = []; + const env: Env = { + ASSETS: { + fetch: async (request: Request) => { + seen.push(new URL(request.url).pathname); + return new Response("{}", { status: 200 }); + }, + }, + }; + await worker.fetch(new Request("https://cloudhal.dev/.well-known/cloudhal/keys.json"), env); + await worker.fetch( + new Request("https://cloudhal.dev/.well-known/cloudhal/yanked.json.bak"), + env, + ); + expect(seen).toEqual([ + "/.well-known/cloudhal/keys.json", + "/.well-known/cloudhal/yanked.json.bak", + ]); + }); +}); diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index 3d87b9d4..b05a24dc 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -37,6 +37,7 @@ A release carries exactly these assets, all attached before it is published: | `release-statement.json` | `cloudhal.release-statement@1`, canonical JSON, at most 4096 bytes | | `release-statement.sig` | the raw 64-byte Ed25519 signature over the exact bytes of `release-statement.json` | | `cloudhal-release-.sigstore.json` | the Sigstore bundle of the build-provenance attestation over the first three files | +| `keys.json`, `keys.json.sig`, `yanked.json`, `yanked.json.sig` | §9's key list and yank list as they were when the release was made, verified by `publish-ci` before it attaches them. This snapshot starts with the first release after the root ceremony; consumers prefer the canonical URLs (added 2026-11-14, #226) | The manifest's `signing` is `{ "keyId": "", "algorithm": "Ed25519", "signature": "release-statement.sig" }`. The enum also admits `"manifest.sig"`, a detached signature over the manifest bytes: that legacy form exists for development keys a consumer operates itself, and a consumer must never accept it for a project key. Project releases never carry `manifest.sig`. @@ -199,7 +200,10 @@ Tar format: ustar with PAX `path` records for long names; regular files only (no - Both are contract roots in core-contracts, `release-keys` and `release-yank-list`. - **Where they live.** The canonical URLs are `https://cloudhal.dev/.well-known/cloudhal/keys.json` and `https://cloudhal.dev/.well-known/cloudhal/yanked.json`, each with its `.sig`. - Every GitHub release also attaches the copies current when it was made. A release is immutable, so that copy is a snapshot, and a consumer prefers the canonical URL. - - The sources are committed under `security/` in this repository. `apps/docs` (cloudhal.dev) publishes them. + - The sources are committed under `security/` in this repository. + - `keys.json` changes yearly. cloudhal.dev (`apps/docs`) serves it as a static asset, copied at build time. + - `yanked.json` is re-signed weekly. The *Yank list* workflow commits it to the append-only `yank-list` branch. The cloudhal.dev Worker proxies exactly that branch's raw file (a fixed URL, cached for at most five minutes, bytes unchanged), and answers 502 when the upstream fails, never with an old copy. + - No workflow deploys cloudhal.dev yet (RFC-0037's "push to `main` deploys `cloudhal-dev`" is a separate follow-up), so the route ships with one manual docs deploy. - The signatures make the location trust-free; the location only matters for availability. - **The root.** An Ed25519 root key signs only `keys.json`, at the yearly rotation or in an emergency. - The owner generates it offline with `node scripts/release-keys.ts root-init`. The script writes the private key encrypted with a passphrase, never prints it, and writes the public key next to it. @@ -223,12 +227,14 @@ Tar format: ustar with PAX `path` records for long names; regular files only (no - every yank key a root list has revoked, or that a newer yank key replaced, and refuse it forever (`yank-list-invalid`). - **The cure** is the root: a new `keys.json` revokes the stolen yank key and lists a new one. A list from another yank key starts a new memory only when a `keys.json` newer than the remembered one lists that key and no longer lists the old one as active. - **One active yank key.** `keys.json` lists at most one yank key as `active`; `verifyKeys` and `sign-keys` refuse two. A yank key is rotated by replacing it, never by overlapping, so a thief of one key cannot switch to another to start a fresh memory. + - **The published list is verified before it is carried forward.** The `yank-list` branch could be pushed to by more than the signer, so `sign-yanked` verifies the published `yanked.json` as a consumer would (signature by the active yank key of the pinned-root `keys.json`, schema, freshness) and refuses loudly, never re-signing, when it fails. The branch's ruleset lets only a deploy key held by `release-yank` push. After a yank-key rotation, the old key's last list is verified with the old key as the newer `keys.json` still lists it (revoked or retiring), and every entry is carried forward with the serial continuing; only an explicit prune (`--rotate`) starts the new key's list from the committed source alone, at serial 1. + - **Served as a pair.** cloudhal.dev serves `yanked.json` and its `.sig` only as a pair whose signature verifies with the yank key of its `keys.json`, cached together; on a mismatch or failure upstream it serves the last good pair, else 502. - **Revocation becomes permanent through the root.** An emergency revocation goes out at once through `yanked.json`, and the root then records it in `keys.json` within days. Only then does it survive a compromised yank key for stateless consumers. - **Size.** `sign-yanked` refuses a list above 256 KiB, which is the consumer's limit. Pruning happens only through a yank-key rotation: at about 200 KiB the root lists a new yank key, and its first list keeps only what still matters (versions signed by keys that can still verify). Consumers with memory start a new memory with the new key. - **`yanked.json` freshness.** It carries signed `issuedAt` and `expires`, with `expires` exactly 30 days after `issuedAt`, and a signed `serial` that increases by one at every signing. - A consumer that can keep state (the CLI, under the project's `.cloudhal/` or the user's cache directory) refuses a `serial` lower than the last one it saw (`stale-yank-list`). Such a lower serial is a rollback to an older, validly signed list that lacks a recent yank. - A stateless consumer (a Worker deployer) cannot detect such a rollback inside the list's 30 days. That is the remaining risk. - - The `yank-list` branch that serves the list is append-only. A ruleset blocks force-pushes and deletion and lets only GitHub Actions push to it, so a rollback needs a new commit that the branch history shows. + - The `yank-list` branch that serves the list is append-only. A ruleset blocks force-pushes and deletion and lets only the deploy key that `release-yank` holds push to it, so a rollback needs a new commit that the branch history shows. - A scheduled weekly workflow in `release-yank` re-signs it from the committed source list. That workflow has no required reviewer, because the key can only reduce trust. - A stale `yanked.json` blocks only `deploy --release` (§11b). Installed sites keep running. - **Expired keys.** A statement is valid only when its `createdAt` lies within its key's `[notBefore, notAfter]`. The producer (`bundle-sign`) and every consumer enforce this. diff --git a/docs/security/release-signing-keys.md b/docs/security/release-signing-keys.md index 80b94770..c65ff85b 100644 --- a/docs/security/release-signing-keys.md +++ b/docs/security/release-signing-keys.md @@ -66,9 +66,11 @@ None yet. The owner's root ceremony adds the first row (see *First-time setup* b - **`yanked.json`** (`cloudhal.release-yank-list@1`, signed by the yank key in `yanked.json.sig`). - It lists yanked versions and revoked release key ids. - It expires exactly 30 days after `issuedAt`, and carries a signed `serial`, one higher at every signing. A weekly workflow in `release-yank` re-signs it from `security/yank-list.json`, which is append-only. + - Before the workflow carries the published list forward, `sign-yanked` verifies it exactly as a consumer would: its signature by the active yank key of the pinned-root `keys.json`, its schema and its freshness. A list that fails fails the run loudly and is never re-signed. - The CLI remembers the highest serial it has seen and refuses a lower one, which would be a rolled-back list. A stateless consumer, such as a Worker, cannot detect a rollback inside the 30 days. - A stale `yanked.json` blocks `cloudhal deploy --release` only; installed sites keep running. - **Where they live.** `https://cloudhal.dev/.well-known/cloudhal/keys.json` and `.../yanked.json`, each with its `.sig`. Every GitHub release also attaches the copies current when it was made. The signatures make the location trust-free. + - cloudhal.dev serves `yanked.json` and its `.sig` only as a pair whose signature verifies with the yank key of its static `keys.json`, cached together for at most five minutes. When the `yank-list` branch answers a mismatched pair (GitHub's raw CDN caches each file on its own) or fails, it serves the last good pair, else 502. - **How consumers use them.** Online consumers fetch both on every run and refuse when a fetch or a check fails. They use both only to reduce trust in the keys they pin (`mergeTrust`). ## Custody of the release key @@ -91,10 +93,14 @@ This runs once, offline where it says so. Nobody else generates or holds these k 3. **Yank key.** Run `node scripts/release-keys.ts yank-init --key-id cloudhal-yank-2026a --out `. It writes `/cloudhal-yank-2026a.pem` (mode 0600, never printed) and `/cloudhal-yank-2026a.pub.json`. 4. **Environment.** In the repository settings, create the environment `release-yank`, with deployment branches `main` only and no required reviewer. Set its secret: `gh secret set RELEASE_YANK_KEY --env release-yank < /cloudhal-yank-2026a.pem`. Then move the `.pem` file to offline storage. 5. **First `keys.json`.** List `cloudhal-release-2026a` and `cloudhal-yank-2026a` in `security/keys.source.json`, then run `node scripts/release-keys.ts sign-keys --root-key /cloudhal-root-2026a.key.enc --root-pub /cloudhal-root-2026a.pub.json`. It writes `security/keys.json` and `security/keys.json.sig`, after verifying the exact signed bytes as a consumer would. -6. **Publish the key list.** Commit `security/keys.json`, `security/keys.json.sig` and `security/keys.source.json` in a reviewed pull request. The yank list needs it on `main` (the workflow reads `security/keys.json`). -7. **Protect the `yank-list` branch** (arrives with the second #226 PR). Add a ruleset for the branch `yank-list`: block force pushes, restrict deletions, and restrict updates with GitHub Actions as the only bypass. -8. **First `yanked.json`** (arrives with the second #226 PR). Run the *Yank list* workflow once by hand with `first_run` checked. After that it runs weekly. -9. **Deploy cloudhal.dev once** (arrives with the second #226 PR). Run `pnpm --filter @cloudhal/docs run deploy`, so that `/.well-known/cloudhal/` serves both lists. +6. **Publish the key list.** Commit `security/keys.json`, `security/keys.json.sig` and `security/keys.source.json` in a reviewed pull request. The yank list needs it on `main` (the workflow reads `security/keys.json`). **Merge it only while no release is pending, and do steps 7 to 9 straight after:** from this merge on, every release attaches the trust lists and refuses to publish until cloudhal.dev serves a `yanked.json` that verifies. +7. **Protect the `yank-list` branch, with a deploy key as its only writer.** + - Run `ssh-keygen -t ed25519 -N "" -C yank-list -f /yank-list-deploy`. Add `/yank-list-deploy.pub` as a deploy key of the repository with write access, named `yank-list`. Set the private half as a secret of `release-yank`: `gh secret set YANK_LIST_DEPLOY_KEY --env release-yank < /yank-list-deploy`. Then delete both files. + - In the repository settings, add a ruleset for the branch `yank-list`: block force pushes, restrict deletions, and restrict updates, with the `yank-list` deploy key as the **only** bypass actor (not "GitHub Actions", which would let every workflow's token with `contents: write` push). + - The branch then only ever gains commits, and only from a job that holds the deploy key, which is a job in `release-yank` on `main`. + - **What remains.** Anyone who can run a job in `release-yank` (a reviewed change to a workflow on `main`) or a repository admin (who can edit the ruleset) can push to it. A deploy key with write access can also push to other branches no ruleset protects. Such a push can only deny service: a crafted list fails the next run and the cloudhal.dev pair check. A replayed older, genuinely signed pair would be served to stateless consumers until it expires (at most 30 days); consumers with memory refuse it. A rollback needs a new commit that the branch history shows. +8. **First `yanked.json`.** Run the *Yank list* workflow once by hand with `first_run` checked (Actions › Yank list › Run workflow). It creates the `yank-list` branch with serial 1. After that it runs weekly, on Mondays. Before `security/keys.json` exists, the weekly run skips with a notice. +9. **Deploy cloudhal.dev once.** Run `pnpm --filter @cloudhal/docs run deploy`. This ships the Worker route that serves `/.well-known/cloudhal/yanked.json` from the branch, and the static `keys.json`. Nothing deploys cloudhal.dev automatically yet, so redeploy it after every change to `security/keys.json` as well. ## Rotation (yearly) @@ -112,16 +118,21 @@ This runs once, offline where it says so. Nobody else generates or holds these k ## Revocation (emergency) -1. **Revoke at once with the yank key.** Add `{ keyId, revokedAt, reason }` to `revokedKeys` in `security/yank-list.json`, in a reviewed pull request, then run the *Yank list* workflow by hand. Within minutes every online consumer refuses the key. This does not need the offline root. +1. **Revoke at once with the yank key.** Run the *Yank list* workflow by hand with `revoke` set to the key id (several: comma-separated) and a `reason`. Wait for a running *Yank list* run to finish before you start another: GitHub keeps one pending run per workflow and cancels an older pending one. A failed or cancelled run opens (or comments on) the issue "Yank list run failed"; check its inputs and run it again. Within minutes every online consumer refuses the key. This does not need the offline root. Then record the entry in `security/yank-list.json` in a reviewed pull request. Published entries are carried forward anyway, so the record never decides whether the revocation stays. 2. **Make it permanent with the root, within days.** Set the key to `revoked` in `security/keys.source.json`, re-sign `keys.json` with the root, publish it, and mark the key's row `revoked` above. Until then, the revocation rests on the yank key alone, which a stateless consumer would lose if the yank key were also stolen. 3. **Tell people.** Publish an advisory under [SECURITY.md](../../SECURITY.md). 4. **Recover.** Rotate to a new release key (*Rotation*, steps 2 to 4) and release a new version signed by it. -5. **A stolen yank key.** It can deny service, and for stateless consumers it can undo yanks and revocations. Revoke it in `keys.json` with the root, generate a new yank key (`yank-init`), list it as the only `active` yank key in the same `keys.json`, and replace `RELEASE_YANK_KEY`. The new key's first list carries every yank and revocation that still matters. Consumers with memory start a new memory with the new key. +5. **A stolen yank key, or the yank key's rotation.** A stolen key can deny service, and for stateless consumers it can undo yanks and revocations. + - Generate a new yank key (`yank-init`). In one `keys.json`, set the old key `revoked` (stolen) or `retiring` (rotation), keeping it listed, and list the new key as the only `active` yank key. Re-sign it with the root and publish it as in *First-time setup*, steps 6 and 9. + - Replace `RELEASE_YANK_KEY`, and the key id in `.github/workflows/yank-list.yml`'s Sign step. + - Let the next run sign (or run the *Yank list* workflow by hand). It verifies the old key's published list with the old key as `keys.json` lists it, carries every entry forward, and continues the serial. Consumers with memory move to the new key, because the newer `keys.json` no longer lists the old one as active; they refuse the old key from then on. + - **Pruning, or a stolen key whose list you no longer trust:** record every yank and revocation that still matters in `security/yank-list.json` first, then run the workflow once with `rotate` checked. It carries nothing of the old key's list forward and starts again at serial 1. ## Yanking a version -1. **List it.** Add `{ version, yankedAt, reason }` to `versions` in `security/yank-list.json`, in a reviewed pull request. The list is append-only: an entry is never removed, and `sign-yanked` refuses a list that drops an entry of the currently published one. -2. **Re-sign.** Run the *Yank list* workflow by hand. From then on `cloudhal deploy --release` and every other consumer refuse that version. Installed copies keep running. +1. **Yank it now.** Run the *Yank list* workflow by hand with `yank` set to the version (several: comma-separated) and a `reason`. As with a revocation, wait for a running *Yank list* run to finish first. From then on `cloudhal deploy --release` and every other consumer refuse that version. Installed copies keep running. +2. **Record it.** Add `{ version, yankedAt, reason }` to `versions` in `security/yank-list.json`, in a reviewed pull request. The list is append-only: the workflow carries every published entry forward, and `sign-yanked` refuses a list that drops one. For a yank that can wait, this pull request alone is enough, and the next weekly run publishes it. +4. **After an outage.** If the published list expired (no successful run for 30 days), the next run refuses to carry it forward. Run the workflow once with `accept_expired` checked: the old list is still verified (signature, schema, the key's window as of its issue), then re-signed fresh. 3. **Fix forward.** Release a fixed version. A yank is never undone. ## Verifying a release diff --git a/packages/cli/test/fixtures/release-bundle/workflow-text.ts b/packages/cli/test/fixtures/release-bundle/workflow-text.ts index f3e8b8b6..b9e0c406 100644 --- a/packages/cli/test/fixtures/release-bundle/workflow-text.ts +++ b/packages/cli/test/fixtures/release-bundle/workflow-text.ts @@ -10,6 +10,7 @@ export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), " const read = (f: string) => readFileSync(path.join(ROOT, ".github/workflows", f), "utf8"); export const RELEASE = read("release.yml"); export const BUILD = read("bundle-build.yml"); +export const YANK = read("yank-list.yml"); /** A GitHub Actions expression `${{ inner }}` (written so, not as a JS template). */ export const gha = (inner: string) => ["$", "{{ ", inner, " }}"].join(""); diff --git a/packages/cli/test/release-bundle-publish.test.ts b/packages/cli/test/release-bundle-publish.test.ts index 66d1ed80..ae10c7a2 100644 --- a/packages/cli/test/release-bundle-publish.test.ts +++ b/packages/cli/test/release-bundle-publish.test.ts @@ -57,6 +57,50 @@ describe("publish-ci loadBundle", () => { await expect(verifyAsConsumer(dir, "0.8.1", { [KEY_ID]: k.raw })).rejects.toThrow(); }); + it("applies the key and yank lists as consumers do: a revoked key or a yanked version blocks the publish", async () => { + const k = throwawayKey(); + const dir = await signedDir(k.privateKey); + const keys = { [KEY_ID]: k.raw }; + // Shaped as verifyYanked / verifyKeys return them; mergeTrust reads only these fields. + const yankList = (over: Record) => ({ + document: { versions: [], revokedKeys: [], ...over }, + }); + await expect( + verifyAsConsumer(dir, "0.8.0", keys, { yanked: yankList({}) }), + ).resolves.toBeUndefined(); + await expect( + verifyAsConsumer(dir, "0.8.0", keys, { + yanked: yankList({ + revokedKeys: [{ keyId: KEY_ID, revokedAt: "2026-11-01T00:00:00Z", reason: "Exposed." }], + }), + }), + ).rejects.toMatchObject({ code: "key-revoked" }); + await expect( + verifyAsConsumer(dir, "0.8.0", keys, { + yanked: yankList({ + versions: [{ version: "0.8.0", yankedAt: "2026-11-01T00:00:00Z", reason: "Broken." }], + }), + }), + ).rejects.toMatchObject({ code: "yanked" }); + const revokedInKeyList = { + document: { + keys: [ + { + keyId: KEY_ID, + publicKey: k.raw, + role: "release", + notBefore: "2026-01-01T00:00:00Z", + notAfter: "2099-01-01T00:00:00Z", + status: "revoked", + }, + ], + }, + }; + await expect( + verifyAsConsumer(dir, "0.8.0", keys, { keys: revokedInKeyList }), + ).rejects.toMatchObject({ code: "key-revoked" }); + }); + it("reads the five assets in upload order with their digests, and the statement", async () => { const k = throwawayKey(); const dir = await signedDir(k.privateKey); diff --git a/packages/cli/test/release-keys.test.ts b/packages/cli/test/release-keys.test.ts index 2be698bd..6c0b4609 100644 --- a/packages/cli/test/release-keys.test.ts +++ b/packages/cli/test/release-keys.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { checkAppendOnly, main, + mergeYankList, type PublicKeyFile, rootInit, signKeys, @@ -323,6 +324,38 @@ describe("sign-yanked", () => { expect(() => checkAppendOnly(empty, undefined)).not.toThrow(); }); + it("carries every published entry forward and adds an emergency yank or revocation", () => { + const published = { + versions: [{ version: "0.9.3", yankedAt: "2026-11-19T00:00:00Z", reason: "a" }], + revokedKeys: [], + }; + const merged = mergeYankList(empty, published, { yank: "0.9.4", reason: "Data loss." }, NOW); + expect(merged.versions.map((v) => v.version)).toEqual(["0.9.3", "0.9.4"]); + expect(merged.versions[1]).toEqual({ + version: "0.9.4", + yankedAt: iso(NOW), + reason: "Data loss.", + }); + const revoked = mergeYankList( + empty, + undefined, + { revoke: "cloudhal-release-2026a", reason: "Exposed." }, + NOW, + ); + expect(revoked.revokedKeys.map((k) => k.keyId)).toEqual(["cloudhal-release-2026a"]); + // A yank already listed keeps its first date and reason. + expect( + mergeYankList(empty, published, { yank: "0.9.3", reason: "again" }, NOW).versions, + ).toEqual(published.versions); + expect(() => mergeYankList(empty, undefined, { yank: "0.9.4" }, NOW)).toThrow(/needs a reason/); + expect(() => + mergeYankList(empty, undefined, { yank: "v0.9.4; rm -rf /", reason: "x" }, NOW), + ).toThrow(/not a version/); + expect(() => mergeYankList(empty, undefined, { revoke: "Bad Key", reason: "x" }, NOW)).toThrow( + /not a key id/, + ); + }); + it("numbers each list one above the published one (rollback protection)", async () => { const s = await setup(); const base = { @@ -335,13 +368,131 @@ describe("sign-yanked", () => { now: NOW, }; const first = await signYanked({ ...base, published: undefined }); - const firstDoc = JSON.parse(new TextDecoder().decode(first.document)); - expect(firstDoc.serial).toBe(1); - const next = await signYanked({ ...base, published: firstDoc }); + expect(JSON.parse(new TextDecoder().decode(first.document)).serial).toBe(1); + const next = await signYanked({ ...base, published: first }); expect(JSON.parse(new TextDecoder().decode(next.document)).serial).toBe(2); - await expect(signYanked({ ...base, published: { ...empty } as never })).rejects.toThrow( - /no serial/, - ); + }); + + describe("the published list is verified before anything of it is carried forward", () => { + const yanks = { + versions: [{ version: "0.9.3", yankedAt: "2026-11-19T00:00:00Z", reason: "Broken." }], + revokedKeys: [], + }; + async function published() { + const s = await setup(); + const base = { + keyId: "cloudhal-yank-2026a", + keyPem: s.pem, + keyList: s.keyList, + roots: s.rootsOf, + source: empty, + out: tmp(), + now: NOW, + }; + // Published by an earlier run with an emergency yank that is not in the source yet. + const pair = await signYanked({ + ...base, + published: undefined, + extra: { yank: "0.9.3", reason: "Broken." }, + }); + return { s, base, pair }; + } + + it("carries a list that verifies forward", async () => { + const { base, pair } = await published(); + const next = await signYanked({ ...base, published: pair }); + const doc = JSON.parse(new TextDecoder().decode(next.document)); + expect(doc.versions.map((v: { version: string }) => v.version)).toEqual(["0.9.3"]); + }); + + it("refuses a crafted list pushed to the branch: wrong signature, edited bytes, another signer", async () => { + const { base, pair } = await published(); + const out = tmp(); + // Someone with contents: write drops the yank and keeps the old signature. + const edited = new TextEncoder().encode( + new TextDecoder().decode(pair.document).replace('"0.9.3"', '"0.9.9"'), + ); + await expect( + signYanked({ ...base, out, published: { document: edited, signature: pair.signature } }), + ).rejects.toThrow(/published yank list does not verify.*nothing signed/); + const forged = new Uint8Array(pair.signature); + forged[0] = (forged[0] as number) ^ 1; + await expect( + signYanked({ ...base, out, published: { document: pair.document, signature: forged } }), + ).rejects.toThrow(/does not verify/); + const crafted = new TextEncoder().encode( + JSON.stringify({ ...yanks, serial: 9, signing: { keyId: "cloudhal-yank-2099a" } }), + ); + await expect( + signYanked({ ...base, out, published: { document: crafted, signature: forged } }), + ).rejects.toThrow( + /cloudhal-yank-2099a, which keys.json does not list as a yank key.*--rotate/, + ); + expect(readdirSync(out)).toEqual([]); + }); + + it("refuses an expired published list unless the operator accepts it (still verified)", async () => { + const { base, pair } = await published(); + const later = { ...base, now: NOW + 31 * DAY }; + await expect(signYanked({ ...later, published: pair })).rejects.toThrow(/stale-yank-list/); + const next = await signYanked({ ...later, published: pair, acceptExpired: true }); + expect(JSON.parse(new TextDecoder().decode(next.document)).serial).toBe(2); + const forged = new Uint8Array(pair.signature); + forged[1] = (forged[1] as number) ^ 1; + await expect( + signYanked({ + ...later, + published: { document: pair.document, signature: forged }, + acceptExpired: true, + }), + ).rejects.toThrow(/does not verify/); + }); + + it("--rotate starts the new key's list at serial 1 from the source alone", async () => { + const { base, pair } = await published(); + // The same key cannot "rotate" (that would prune its own list). + await expect(signYanked({ ...base, published: pair, rotate: true })).rejects.toThrow( + /already signed by cloudhal-yank-2026a/, + ); + await expect(signYanked({ ...base, published: undefined, rotate: true })).rejects.toThrow( + /needs the published list/, + ); + const oldKeyList = new TextEncoder().encode( + new TextDecoder() + .decode(pair.document) + .replaceAll("cloudhal-yank-2026a", "cloudhal-yank-2025a"), + ); + const fresh = await signYanked({ + ...base, + source: yanks, + published: { document: oldKeyList, signature: pair.signature }, + rotate: true, + }); + const doc = JSON.parse(new TextDecoder().decode(fresh.document)); + expect(doc.serial).toBe(1); + expect(doc.versions.map((v: { version: string }) => v.version)).toEqual(["0.9.3"]); + }); + + it("takes several comma-separated emergency entries, each validated", async () => { + const merged = mergeYankList( + empty, + undefined, + { yank: "0.9.4, 0.9.5", revoke: "cloudhal-release-2026a", reason: "Data loss." }, + NOW, + ); + expect(merged.versions.map((v) => v.version)).toEqual(["0.9.4", "0.9.5"]); + expect(merged.revokedKeys.map((k) => k.keyId)).toEqual(["cloudhal-release-2026a"]); + expect(() => + mergeYankList(empty, undefined, { yank: "0.9.4,,0.9.5", reason: "x" }, NOW), + ).toThrow(/empty item/); + expect(() => + mergeYankList(empty, undefined, { yank: "0.9.4,-rf", reason: "x" }, NOW), + ).toThrow(/not a version: -rf/); + const many = Array.from({ length: 21 }, (_, i) => `0.9.${i}`).join(","); + expect(() => mergeYankList(empty, undefined, { yank: many, reason: "x" }, NOW)).toThrow( + /at most 20/, + ); + }); }); it("the CLI reads the key from RELEASE_YANK_KEY, never prints it, and needs --published or --first-run", async () => { @@ -434,14 +585,18 @@ describe("a yank-key rotation, end to end", () => { keyPem: readFileSync(path.join(c.dir, "cloudhal-yank-2026b.pem"), "utf8"), keyList: keysT2, roots: roots(c.root), - published: JSON.parse(new TextDecoder().decode(old.document)), + published: old, out: tmp(), now: NOW, }; + // The old key's list is carried forward even when the source does not list its entries yet + // (an emergency yank, say); a crafted one fails the old key's signature. + const fresh = await signYanked({ ...base, source: { versions: [], revokedKeys: [] } }); + const forged = new Uint8Array(old.signature); + forged[0] = (forged[0] as number) ^ 1; await expect( - signYanked({ ...base, source: { versions: [], revokedKeys: [] } }), - ).rejects.toThrow(/drops 0.9.3/); - const fresh = await signYanked({ ...base, source: yanks }); + signYanked({ ...base, source: yanks, published: { ...old, signature: forged } }), + ).rejects.toThrow(/signed by the replaced key cloudhal-yank-2026a\) does not verify/); const doc = JSON.parse(new TextDecoder().decode(fresh.document)); expect(doc.signing.keyId).toBe("cloudhal-yank-2026b"); expect(doc.serial).toBe(2); diff --git a/packages/cli/test/release-trust-assets.test.ts b/packages/cli/test/release-trust-assets.test.ts new file mode 100644 index 00000000..63f651b2 --- /dev/null +++ b/packages/cli/test/release-trust-assets.test.ts @@ -0,0 +1,151 @@ +/** + * scripts/release-trust-assets.ts (RFC-0046 §1, §9): the key list and yank list a release + * attaches, verified first; nothing before the root ceremony; a published release keeps its own. + */ +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { rootInit, signKeys, signYanked, yankInit } from "../../../scripts/release-keys.ts"; +import { trustAssets } from "../../../scripts/release-trust-assets.ts"; + +const dirs: string[] = []; +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); +const tmp = () => { + const d = mkdtempSync(path.join(os.tmpdir(), "trust-assets-")); + dirs.push(d); + return d; +}; +const NOW = Math.floor(Date.now() / 1000) * 1000; +const iso = (ms: number) => new Date(ms).toISOString().replace(".000Z", "Z"); +const quiet = () => {}; + +/** A repo root with a signed security/keys.json, and the matching signed yank list bytes. */ +async function repo() { + const keysDir = tmp(); + const root = rootInit({ + keyId: "cloudhal-root-2026a", + out: keysDir, + passphrase: "a long passphrase", + log: quiet, + }); + const yank = yankInit({ keyId: "cloudhal-yank-2026a", out: keysDir, log: quiet }); + const repoRoot = tmp(); + mkdirSync(path.join(repoRoot, "security")); + const keyList = { + keys: [ + { + keyId: "cloudhal-yank-2026a", + publicKey: yank.publicKey, + role: "yank", + notBefore: iso(NOW - 86_400_000), + notAfter: iso(NOW + 300 * 86_400_000), + status: "active", + }, + ], + }; + const signedKeys = await signKeys({ + rootKey: readFileSync(path.join(keysDir, "cloudhal-root-2026a.key.enc"), "utf8"), + passphrase: "a long passphrase", + rootPub: root, + source: keyList, + out: path.join(repoRoot, "security"), + now: NOW, + }); + const roots = { [root.keyId]: { publicKey: root.publicKey } }; + const yanked = await signYanked({ + keyId: "cloudhal-yank-2026a", + keyPem: readFileSync(path.join(keysDir, "cloudhal-yank-2026a.pem"), "utf8"), + keyList: signedKeys, + roots, + source: { versions: [], revokedKeys: [] }, + published: undefined, + out: tmp(), + now: NOW, + }); + return { repoRoot, roots, yanked }; +} + +const serve = (y: { document: Uint8Array; signature: Uint8Array }) => async (url: string) => + url.endsWith("yanked.json") + ? new Response(y.document) + : url.endsWith("yanked.json.sig") + ? new Response(y.signature) + : new Response("no", { status: 404 }); + +describe("trustAssets", () => { + it("attaches nothing before the root ceremony (no security/keys.json)", async () => { + expect(await trustAssets({ root: tmp(), release: null, log: quiet })).toEqual({ assets: [] }); + }); + + it("attaches the verified key list and the verified yank list from cloudhal.dev", async () => { + const r = await repo(); + const { assets, keys, yanked } = await trustAssets({ + root: r.repoRoot, + release: null, + roots: r.roots, + fetchImpl: serve(r.yanked), + now: new Date(NOW), + log: quiet, + }); + expect(assets.map((a) => a.name)).toEqual([ + "keys.json", + "keys.json.sig", + "yanked.json", + "yanked.json.sig", + ]); + expect(assets.every((a) => a.sha256.length === 64 && a.bytes > 0)).toBe(true); + expect(readFileSync((assets[2] as { file: string }).file)).toEqual( + Buffer.from(r.yanked.document), + ); + // The verified yank list, for the consumer check of the bundle. + expect((yanked as { document: { serial: number } }).document.serial).toBe(1); + // Both verified lists go to publish-ci's consumer check (mergeTrust). + expect((keys as { document: { keys: unknown[] } }).document.keys).toHaveLength(1); + }); + + it("refuses an unverifiable key list or yank list, and an unreachable one", async () => { + const r = await repo(); + const base = { root: r.repoRoot, release: null, now: new Date(NOW), log: quiet }; + // Pinned roots (none yet) do not sign this list. + await expect(trustAssets({ ...base, fetchImpl: serve(r.yanked) })).rejects.toMatchObject({ + code: "key-list-invalid", + }); + const bad = { document: r.yanked.document, signature: new Uint8Array(64) }; + await expect( + trustAssets({ ...base, roots: r.roots, fetchImpl: serve(bad) }), + ).rejects.toMatchObject({ + code: "yank-list-invalid", + }); + const down = async () => new Response("down", { status: 503 }); + await expect(trustAssets({ ...base, roots: r.roots, fetchImpl: down })).rejects.toThrow(/503/); + }); + + it("a published release keeps the trust assets it carries (never refetched)", async () => { + let fetched = 0; + const { assets } = await trustAssets({ + root: tmp(), + release: { + draft: false, + prerelease: false, + assets: [ + { name: "manifest.json", digest: "sha256:aa", size: 1 }, + { name: "keys.json", digest: `sha256:${"b".repeat(64)}`, size: 10 }, + { name: "yanked.json", digest: `sha256:${"c".repeat(64)}`, size: 20 }, + ], + }, + fetchImpl: async () => { + fetched++; + return new Response(""); + }, + log: quiet, + }); + expect(fetched).toBe(0); + expect(assets).toEqual([ + { name: "keys.json", file: "", bytes: 10, sha256: "b".repeat(64) }, + { name: "yanked.json", file: "", bytes: 20, sha256: "c".repeat(64) }, + ]); + }); +}); diff --git a/packages/cli/test/yank-list-workflow.test.ts b/packages/cli/test/yank-list-workflow.test.ts new file mode 100644 index 00000000..d9b50f18 --- /dev/null +++ b/packages/cli/test/yank-list-workflow.test.ts @@ -0,0 +1,80 @@ +/** + * .github/workflows/yank-list.yml (RFC-0046 §9, #226): the weekly and on-demand re-signing of + * yanked.json, with the least it needs: pinned actions, read-only tokens (the push uses the + * environment's deploy key), each key in one step, one install (the verifier, no lifecycle + * scripts), inputs only through the environment, no force-push, a skip before the root ceremony, + * and a visible failure. + */ +import { describe, expect, it } from "vitest"; +import { code, gha, job, OPEN, runs, step, YANK } from "./fixtures/release-bundle/workflow-text.ts"; + +describe("the Yank list workflow", () => { + const sign = job(YANK, "sign"); + + it("runs weekly and on demand with yank, revoke, reason, first_run, rotate and accept_expired", () => { + expect(YANK).toMatch(/schedule:\n\s+- cron: "\d+ \d+ \* \* \d"/); + for (const input of ["yank:", "revoke:", "reason:", "first_run:", "rotate:", "accept_expired:"]) + expect(code(YANK)).toContain(` ${input}`); + }); + + it("skips, with a notice, outside the repository and before security/keys.json exists", () => { + const check = job(YANK, "check"); + expect(check).toContain("if: github.repository == 'cloudhal-dev/cloudhal'"); + expect(check).toContain("[ -f security/keys.json ] && [ -f security/keys.json.sig ]"); + expect(check).toContain("::notice::"); + expect(code(sign)).toContain("needs: check"); + expect(code(sign)).toContain("if: needs.check.outputs.ready == 'true'"); + // The environment (and so the key) is referenced only once the check passed. + expect(code(check)).not.toContain("environment:"); + }); + + it("makes a failed or cancelled run visible: a job summary and an issue", () => { + expect(step(sign, "Summary")).toContain("if: always()"); + const notify = job(YANK, "notify"); + expect(notify).toContain("needs.sign.result == 'cancelled'"); + expect(code(notify)).toMatch(/permissions:\n\s+issues: write( #[^\n]*)?\n\s+steps:/); + expect(code(notify)).not.toContain("secrets."); + }); + + it("pins every action to a 40-hex commit and grants no job contents: write", () => { + for (const l of YANK.split("\n").filter((x) => /^\s*(- )?uses: /.test(x))) + expect(l.split("uses: ")[1]).toMatch(/^[\w.-]+\/[\w./-]+@[0-9a-f]{40} # v\d+(\.\d+)*$/); + expect(code(YANK)).toContain("\npermissions: {}\n"); + expect(code(sign)).toMatch(/permissions:\n\s+contents: read( #[^\n]*)?\n\s+steps:/); + // Top level, check (read), sign (write), notify (issues). + expect(code(YANK).match(/permissions:/g)).toHaveLength(4); + expect(code(YANK)).not.toContain("contents: write"); + expect(code(sign)).toContain("environment: release-yank"); + }); + + it("hands the yank key to the Sign step only, installs only the verifier, never force-pushes", () => { + expect(code(YANK).match(/secrets\.RELEASE_YANK_KEY/g)).toHaveLength(1); + expect(step(sign, "Sign")).toContain(`RELEASE_YANK_KEY: ${gha("secrets.RELEASE_YANK_KEY")}`); + // The deploy key (the ruleset's only bypass) reaches the Publish step only, over pinned SSH. + expect(code(YANK).match(/secrets\.YANK_LIST_DEPLOY_KEY/g)).toHaveLength(1); + const publish = step(sign, "Publish to the yank-list branch"); + expect(publish).toContain(`YANK_LIST_DEPLOY_KEY: ${gha("secrets.YANK_LIST_DEPLOY_KEY")}`); + expect(publish).toContain("StrictHostKeyChecking=yes"); + expect(code(sign)).not.toContain("github.token"); + // Exactly one install: the verifier's pinned dependencies, no lifecycle scripts, before the key. + expect(code(YANK).match(/pnpm install|npm ci|npm install/g)).toHaveLength(1); + expect(code(YANK)).toContain( + "run: pnpm install --frozen-lockfile --ignore-scripts --filter @cloudhal/release-verify", + ); + expect(code(sign).indexOf("--ignore-scripts")).toBeLessThan( + code(sign).indexOf("RELEASE_YANK_KEY"), + ); + expect(code(YANK)).not.toMatch(/--force|push -f|\+yank-list/); + expect(runs(step(sign, "Sign")).join("\n")).toContain("node scripts/release-keys.ts"); + }); + + it("passes inputs only through env, never into run scripts", () => { + for (const script of runs(code(sign))) expect(script).not.toContain(OPEN); + expect(step(sign, "Sign")).toContain(`YANK: ${gha("inputs.yank")}`); + expect(step(sign, "Sign")).toContain(`REASON: ${gha("inputs.reason")}`); + // --name=value: a value starting with "-" is never read as a flag. + for (const flag of ["yank", "revoke", "reason"]) + expect(step(sign, "Sign")).toContain(`args+=("--${flag}=$${flag.toUpperCase()}")`); + for (const script of runs(code(job(YANK, "notify")))) expect(script).not.toContain(OPEN); + }); +}); diff --git a/scripts/publish-ci.ts b/scripts/publish-ci.ts index b91ee8cb..7367ba19 100644 --- a/scripts/publish-ci.ts +++ b/scripts/publish-ci.ts @@ -57,6 +57,7 @@ import { runPublish, tagFor, } from "./release-publish-plan.ts"; +import { trustAssets } from "./release-trust-assets.ts"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const REPO = process.env.GITHUB_REPOSITORY ?? "cloudhal-dev/cloudhal"; @@ -160,21 +161,30 @@ export async function verifyAsConsumer( dir: string, version: string, publicKeys: Readonly>, - yanked?: unknown, + /** The verified key and yank lists (`trustAssets`), applied as consumers apply them. */ + trust: { readonly keys?: unknown; readonly yanked?: unknown } = {}, ): Promise { const rv = await import("../packages/release-verify/src/index.ts"); + type Lists = NonNullable[1]>; const read = (name: string) => readFileSync(path.join(dir, name)); // The pinned record (with its window) when the key is the pinned one; a dry run's throwaway key // has no window. - const keys = Object.fromEntries( + const pinned = Object.fromEntries( Object.entries(publicKeys).map(([id, publicKey]) => { - const pinned = Object.hasOwn(rv.PINNED_KEYS, id) ? rv.PINNED_KEYS[id] : undefined; + const record = Object.hasOwn(rv.PINNED_KEYS, id) ? rv.PINNED_KEYS[id] : undefined; return [ id, - pinned?.publicKey === publicKey ? pinned : { publicKey, status: "active" as const }, + record?.publicKey === publicKey ? record : { publicKey, status: "active" as const }, ]; }), ); + const lists = { + ...(trust.keys ? { keys: trust.keys as Lists["keys"] } : {}), + ...(trust.yanked ? { yanked: trust.yanked as Lists["yanked"] } : {}), + } as Lists; + // Exactly what a consumer does: revocations and statuses of both lists reduce the pinned keys. + const keys = rv.mergeTrust(pinned, lists); + const yanked = lists.yanked; const release = await rv.verifyRelease({ request: { version }, statement: read("release-statement.json"), @@ -183,9 +193,7 @@ export async function verifyAsConsumer( tarball: () => read(`cloudhal-release-${version}.tar.gz`), planVersion: Number.MAX_SAFE_INTEGER, keys, - ...(yanked - ? { yanked: yanked as NonNullable[0]["yanked"]> } - : {}), + ...(yanked ? { yanked } : {}), }); for await (const _ of release.entries()) { // every entry verified @@ -355,16 +363,22 @@ export async function main(argv: readonly string[]): Promise { dryRunPublicKey: process.env.RELEASE_DRYRUN_PUBLIC_KEY, }) : {}; - // Before anything is created or uploaded: every consumer's own verifier accepts this bundle. - if (bundleDir) await verifyAsConsumer(bundleDir, version, publicKeys); + const releases = port.releases(tag); + const loaded = bundleDir ? loadBundle(bundleDir, version, publicKeys) : null; + // RFC-0046 §9: the verified key list and yank list ride along as a snapshot (none before the + // root ceremony; a resumed run of a published release reuses what it carries). + const trust = loaded ? await trustAssets({ root: ROOT, release: releases[0] ?? null }) : null; + // Before anything is created or uploaded: every consumer's own verifier accepts this bundle, + // the yank list included once there is one. + if (bundleDir) await verifyAsConsumer(bundleDir, version, publicKeys, trust ?? {}); const steps = await runPublish(port, { version, head: sh("git", ["rev-parse", "HEAD"], true).trim(), dryRun, ...(runNumber ? { runNumber } : {}), - bundle: bundleDir ? loadBundle(bundleDir, version, publicKeys) : null, + bundle: loaded ? { ...loaded, assets: [...loaded.assets, ...(trust?.assets ?? [])] } : null, tagCommit: dryRun ? null : remoteTagCommit(tag), - releases: port.releases(tag), + releases, packages: pkgs.map((p) => p.name), npmMissing: missing.map((p) => p.name), tags: sh("git", ["tag", "--list", "v*"], true).split("\n").filter(Boolean), diff --git a/scripts/release-keys.ts b/scripts/release-keys.ts index af047f1a..8ea76073 100644 --- a/scripts/release-keys.ts +++ b/scripts/release-keys.ts @@ -11,10 +11,15 @@ * [--source security/keys.source.json] [--out security] [--months 12] [--passphrase-file ] * keys.json + keys.json.sig, signed by the root, expiring after --months (at most 13). * node scripts/release-keys.ts sign-yanked --key-id [--key-file ] [--list security/yank-list.json] - * [--keys security/keys.json] [--out ] [--published ] [--first-run] + * [--keys security/keys.json] [--out ] [--published ] [--first-run | --rotate] + * [--accept-expired] [--yank= | --revoke=] [--reason=] * yanked.json + yanked.json.sig, expiring in 30 days. The key comes from --key-file or the * RELEASE_YANK_KEY environment variable. It refuses a key that is not the active yank key - * of keys.json, and a list that drops an entry of the published one (append-only). + * of keys.json. The published list (`` and `.sig`) must verify as a consumer + * would before anything of it is carried forward. The list signed is every published entry + * (append-only), the source, and the emergency `--yank`/`--revoke` entries (comma-separated); + * its serial is one above the published one. `--rotate`: the first list of a new yank key, + * nothing of the old key's list carried forward, serial 1 (runbook, Rotation). */ import { createPrivateKey, @@ -30,6 +35,24 @@ import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { canonicalJson, sha256 } from "./release-bundle/manifest.ts"; +import { + checkAppendOnly, + mergeYankList, + type PublishedPair, + readPublished, + verifyPublished, + type YankExtra, + type YankSource, +} from "./release-keys/yank-list.ts"; + +export { + checkAppendOnly, + MAX_EXTRA, + mergeYankList, + parseExtra, + type YankExtra, + type YankSource, +} from "./release-keys/yank-list.ts"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const SPKI_ED25519 = Buffer.from("302a300506032b6570032100", "hex"); @@ -218,33 +241,10 @@ export async function signKeys(o: { return signed; } -interface YankSource { - versions: { version: string; yankedAt: string; reason: string }[]; - revokedKeys: { keyId: string; revokedAt: string; reason: string }[]; -} - -/** The published list: its entries, its signer, and the serial the next one must exceed. */ -interface PublishedYankList extends YankSource { - serial: number; - signing?: { keyId?: string }; -} - -/** Refuses a source that drops an entry of the published list (the list is append-only). */ -export function checkAppendOnly(source: YankSource, published: YankSource | undefined): void { - if (!published) return; - const versions = new Set(source.versions.map((v) => v.version)); - for (const v of published.versions) - if (!versions.has(v.version)) - throw new Error(`the yank list drops ${v.version}; a yank is never undone`); - const keys = new Set(source.revokedKeys.map((k) => k.keyId)); - for (const k of published.revokedKeys) - if (!keys.has(k.keyId)) - throw new Error( - `the yank list drops the revoked key ${k.keyId}; a revocation is never undone`, - ); -} - -/** `yanked.json` + `yanked.json.sig`, signed by the yank key that `keys` lists as active. */ +/** + * `yanked.json` + `yanked.json.sig`, signed by the yank key that `keys` lists as active. The + * published list is verified before anything of it is carried forward (release-keys/yank-list.ts). + */ export async function signYanked(o: { keyId: string; keyPem: string; @@ -252,12 +252,16 @@ export async function signYanked(o: { keyList: { document: Uint8Array; signature: Uint8Array }; roots?: Readonly>; source: YankSource; - published: PublishedYankList | undefined; + /** The published `yanked.json` and `.sig` (none for the very first list). */ + published: PublishedPair | undefined; + /** The first run after a yank-key rotation: the old key's list is not carried forward. */ + rotate?: boolean; + /** Carry forward a published list that expired during an outage (verified as of its issue). */ + acceptExpired?: boolean; + extra?: YankExtra; out: string; now: number; }): Promise<{ document: Uint8Array; signature: Uint8Array }> { - if (o.published && !(Number.isSafeInteger(o.published.serial) && o.published.serial >= 1)) - throw new Error("the published yank list has no serial"); const rv = await verifier(); const keys = await rv.verifyKeys({ ...o.keyList, @@ -280,38 +284,38 @@ export async function signYanked(o: { throw new Error(`the yank key given is not ${o.keyId} of keys.json`); if (!Array.isArray(o.source.versions) || !Array.isArray(o.source.revokedKeys)) throw new Error("the yank list source needs versions and revokedKeys"); - checkAppendOnly(o.source, o.published); + const published = await verifyPublished({ + rv, + keys, + keyId: o.keyId, + published: o.published, + now: o.now, + ...(o.rotate ? { rotate: true } : {}), + ...(o.acceptExpired ? { acceptExpired: true } : {}), + }); + const list = mergeYankList(o.source, published?.list, o.extra ?? {}, o.now); + checkAppendOnly(list, published?.list); const issued = Math.floor(o.now / 1000) * 1000; const doc = { schemaVersion: 1, format: "cloudhal.release-yank-list@1", // One more than the published list's, so a consumer that keeps state refuses a rollback. - serial: (o.published?.serial ?? 0) + 1, + serial: (published?.serial ?? 0) + 1, issuedAt: iso(issued), expires: iso(issued + 30 * DAY_MS), signing: { keyId: o.keyId, algorithm: "Ed25519", signature: "yanked.json.sig" }, - versions: o.source.versions, - revokedKeys: o.source.revokedKeys, + versions: list.versions, + revokedKeys: list.revokedKeys, }; const signed = signDocument(doc, key, publicKeyOf(listed.publicKey)); - // The published list is the memory this one must keep (serial, every entry), as consumers do. - // After a rotation it was signed by the old yank key under an earlier keys.json than this one; - // which one is not known here, so the memory names none (the epoch): the current keys.json is - // then the newer one, as it is for every consumer that kept that earlier one. Its entries are - // still kept, by checkAppendOnly above. - const publishedBy = o.published?.signing?.keyId ?? o.keyId; - const memory = o.published - ? { - keyId: publishedBy, - serial: o.published.serial, - versions: o.published.versions.map((v) => v.version), - revokedKeys: o.published.revokedKeys.map((k) => k.keyId), - keysIssuedAt: publishedBy === o.keyId ? keys.document.issuedAt : "1970-01-01T00:00:00Z", - retiredYankKeys: [], - } - : undefined; + // The verified published list is the memory this one must keep, as consumers do. await selfCheck("yank list", () => - rv.verifyYanked({ ...signed, keys, now: new Date(issued), ...(memory ? { memory } : {}) }), + rv.verifyYanked({ + ...signed, + keys, + now: new Date(issued), + ...(published ? { memory: published.memory } : {}), + }), ); mkdirSync(o.out, { recursive: true }); writeFileSync(path.join(o.out, "yanked.json"), signed.document); @@ -321,25 +325,6 @@ export async function signYanked(o: { const readJson = (file: string): T => JSON.parse(readFileSync(file, "utf8")) as T; -/** The published yank list at `from` (a file or an https URL), or undefined when there is none yet. */ -async function published( - from: string | undefined, - firstRun: boolean, -): Promise { - if (!from) { - if (firstRun) return undefined; - throw new Error("--published is required (or --first-run for the very first list)"); - } - if (from.startsWith("https://")) { - const res = await fetch(from); - if (res.status === 404 && firstRun) return undefined; - if (!res.ok) throw new Error(`could not read the published yank list (${res.status})`); - return (await res.json()) as PublishedYankList; - } - if (!existsSync(from) && firstRun) return undefined; - return readJson(from); -} - /** A passphrase from a file, or typed twice on the terminal without echo. */ async function passphrase(file: string | undefined, twice: boolean): Promise { if (file) return readFileSync(file, "utf8").replace(/\r?\n$/, ""); @@ -362,6 +347,46 @@ async function passphrase(file: string | undefined, twice: boolean): Promise>, + keyId: string, + log: Log, +): Promise { + const str = (name: string) => (typeof v[name] === "string" ? (v[name] as string) : undefined); + const security = path.join(ROOT, "security"); + const keyPem = str("key-file") + ? readFileSync(str("key-file") as string, "utf8") + : (process.env.RELEASE_YANK_KEY ?? ""); + delete process.env.RELEASE_YANK_KEY; + const out = str("out") ?? path.join(ROOT, ".build/yank-list"); + const keysFile = str("keys") ?? path.join(security, "keys.json"); + await signYanked({ + keyId, + keyPem, + keyList: { document: readFileSync(keysFile), signature: readFileSync(`${keysFile}.sig`) }, + // Default: the pinned root. --root-pub only before the root is pinned (or in tests). + ...(str("root-pub") + ? (() => { + const pub = readJson(str("root-pub") as string); + return { roots: { [pub.keyId]: { publicKey: pub.publicKey } } }; + })() + : {}), + source: readJson(str("list") ?? path.join(security, "yank-list.json")), + published: await readPublished(str("published"), v["first-run"] === true), + ...(v.rotate === true ? { rotate: true } : {}), + ...(v["accept-expired"] === true ? { acceptExpired: true } : {}), + extra: { + ...(str("yank") ? { yank: str("yank") as string } : {}), + ...(str("revoke") ? { revoke: str("revoke") as string } : {}), + ...(str("reason") ? { reason: str("reason") as string } : {}), + }, + out, + now: Date.now(), + }); + log(`signed ${path.join(out, "yanked.json")} and yanked.json.sig`); +} + export async function main(argv: readonly string[], log: Log = console.log): Promise { const [command, ...rest] = argv; const { values: v } = parseArgs({ @@ -379,6 +404,11 @@ export async function main(argv: readonly string[], log: Log = console.log): Pro keys: { type: "string" }, published: { type: "string" }, "first-run": { type: "boolean" }, + rotate: { type: "boolean" }, + "accept-expired": { type: "boolean" }, + yank: { type: "string" }, + revoke: { type: "string" }, + reason: { type: "string" }, }, }); const need = (name: keyof typeof v) => { @@ -406,29 +436,7 @@ export async function main(argv: readonly string[], log: Log = console.log): Pro }); log(`signed ${path.join(out, "keys.json")} and keys.json.sig`); } else if (command === "sign-yanked") { - const keyPem = v["key-file"] - ? readFileSync(v["key-file"], "utf8") - : (process.env.RELEASE_YANK_KEY ?? ""); - delete process.env.RELEASE_YANK_KEY; - const out = v.out ?? path.join(ROOT, ".build/yank-list"); - const keysFile = v.keys ?? path.join(security, "keys.json"); - await signYanked({ - keyId: need("key-id"), - keyPem, - keyList: { document: readFileSync(keysFile), signature: readFileSync(`${keysFile}.sig`) }, - // Default: the pinned root. --root-pub only before the root is pinned (or in tests). - ...(v["root-pub"] - ? (() => { - const pub = readJson(v["root-pub"] as string); - return { roots: { [pub.keyId]: { publicKey: pub.publicKey } } }; - })() - : {}), - source: readJson(v.list ?? path.join(security, "yank-list.json")), - published: await published(v.published, v["first-run"] === true), - out, - now: Date.now(), - }); - log(`signed ${path.join(out, "yanked.json")} and yanked.json.sig`); + await signYankedCli(v, need("key-id"), log); } else { throw new Error( "usage: node scripts/release-keys.ts root-init|yank-init|sign-keys|sign-yanked …", diff --git a/scripts/release-keys/yank-list.ts b/scripts/release-keys/yank-list.ts new file mode 100644 index 00000000..39f55019 --- /dev/null +++ b/scripts/release-keys/yank-list.ts @@ -0,0 +1,259 @@ +/** + * What `sign-yanked` signs (RFC-0046 §9, #226): the committed source, the published list carried + * forward, and emergency entries from the workflow's inputs. + * + * The published list comes from the `yank-list` branch, which every Actions job with + * `contents: write` could push to. So nothing of it is carried forward until it verifies exactly + * as a consumer would verify it: its signature by the active yank key of the pinned-root + * `keys.json`, its schema, its freshness. A list that fails is refused loudly, never re-signed. + * After a yank-key rotation the published list is signed by the old key: it is verified with that + * key as the newer `keys.json` still lists it (revoked or retiring), as of its own `issuedAt`, and + * every entry is carried forward with the serial continuing. Only `--rotate` prunes: the new key's + * list then starts from the committed source alone, at serial 1. + */ +import { existsSync, readFileSync } from "node:fs"; +import type { VerifiedKeyList, YankMemory } from "../../packages/release-verify/src/index.ts"; + +type Verifier = typeof import("../../packages/release-verify/src/index.ts"); + +export interface YankSource { + versions: { version: string; yankedAt: string; reason: string }[]; + revokedKeys: { keyId: string; revokedAt: string; reason: string }[]; +} + +/** The published `yanked.json` and its signature, as read (not yet verified). */ +export interface PublishedPair { + readonly document: Uint8Array; + readonly signature: Uint8Array; +} + +const KEY_ID = /^[a-z0-9][a-z0-9-]{2,63}$/; +const VERSION = + /^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})(-[0-9A-Za-z.-]{1,64})?$/; +/** Entries one dispatch may add (versions and key ids together). */ +export const MAX_EXTRA = 20; + +/** Emergency yanks or revocations (the workflow's inputs: comma-separated), stamped `now`. */ +export interface YankExtra { + readonly yank?: string; + readonly revoke?: string; + readonly reason?: string; +} + +const iso = (ms: number) => + new Date(Math.floor(ms / 1000) * 1000).toISOString().replace(".000Z", "Z"); + +/** `a, b,c` → `["a", "b", "c"]`; empty items are an error, not skipped. */ +function items(value: string | undefined, what: string): string[] { + if (value === undefined || value.trim() === "") return []; + if (value.length > 2000) throw new Error(`the ${what} list is too long`); + const list = value.split(",").map((s) => s.trim()); + if (list.some((s) => s === "")) throw new Error(`the ${what} list has an empty item`); + return [...new Set(list)]; +} + +/** The emergency entries, once each is well-formed, and their shared reason. */ +export function parseExtra(extra: YankExtra): { + versions: string[]; + keyIds: string[]; + reason: string; +} { + const versions = items(extra.yank, "yank"); + const keyIds = items(extra.revoke, "revoke"); + const reason = extra.reason?.trim() ?? ""; + if ((versions.length || keyIds.length) && !reason) + throw new Error("an emergency yank or revocation needs a reason"); + if (reason.length > 500) throw new Error("the reason is longer than 500 characters"); + if (versions.length + keyIds.length > MAX_EXTRA) + throw new Error(`at most ${MAX_EXTRA} emergency entries per run`); + for (const v of versions) if (!VERSION.test(v)) throw new Error(`not a version: ${v}`); + for (const k of keyIds) if (!KEY_ID.test(k)) throw new Error(`not a key id: ${k}`); + return { versions, keyIds, reason }; +} + +/** Refuses a list that drops an entry of the published one (the list is append-only). */ +export function checkAppendOnly(source: YankSource, published: YankSource | undefined): void { + if (!published) return; + const versions = new Set(source.versions.map((v) => v.version)); + for (const v of published.versions) + if (!versions.has(v.version)) + throw new Error(`the yank list drops ${v.version}; a yank is never undone`); + const keys = new Set(source.revokedKeys.map((k) => k.keyId)); + for (const k of published.revokedKeys) + if (!keys.has(k.keyId)) + throw new Error( + `the yank list drops the revoked key ${k.keyId}; a revocation is never undone`, + ); +} + +/** + * The list to sign: every entry of the (verified) published list, the committed source, and the + * emergency entries, each once, in that order. An entry already listed keeps its date and reason. + */ +export function mergeYankList( + source: YankSource, + published: YankSource | undefined, + extra: YankExtra, + now: number, +): YankSource { + const add = parseExtra(extra); + const versions = new Map(); + const keys = new Map(); + for (const list of [published, source]) + for (const v of list?.versions ?? []) if (!versions.has(v.version)) versions.set(v.version, v); + for (const list of [published, source]) + for (const k of list?.revokedKeys ?? []) if (!keys.has(k.keyId)) keys.set(k.keyId, k); + for (const version of add.versions) + if (!versions.has(version)) + versions.set(version, { version, yankedAt: iso(now), reason: add.reason }); + for (const keyId of add.keyIds) + if (!keys.has(keyId)) keys.set(keyId, { keyId, revokedAt: iso(now), reason: add.reason }); + return { versions: [...versions.values()], revokedKeys: [...keys.values()] }; +} + +/** The verified published list: its entries, serial and the memory the next list must keep. */ +export interface VerifiedPublished { + readonly list: YankSource; + readonly serial: number; + readonly memory: YankMemory; +} + +const signerOf = (bytes: Uint8Array): string | undefined => { + try { + const doc = JSON.parse(new TextDecoder().decode(bytes)) as { signing?: { keyId?: unknown } }; + return typeof doc?.signing?.keyId === "string" ? doc.signing.keyId : undefined; + } catch { + return undefined; + } +}; + +/** + * The published list, verified as a consumer would (see the file comment), or undefined for the + * first list and after a rotation. `acceptExpired` lets an operator carry forward a list that + * expired during an outage: it is then verified as of its own `issuedAt` (signature, schema and + * the signer's window), never unverified. + */ +export async function verifyPublished(o: { + rv: Verifier; + keys: VerifiedKeyList; + keyId: string; + published: PublishedPair | undefined; + now: number; + rotate?: boolean; + acceptExpired?: boolean; +}): Promise { + if (!o.published) { + if (o.rotate) throw new Error("--rotate needs the published list it replaces"); + return undefined; + } + const signer = signerOf(o.published.document); + if (o.rotate) { + if (signer === o.keyId) + throw new Error(`--rotate: the published list is already signed by ${o.keyId}`); + return undefined; + } + if (signer !== o.keyId) return verifyRotated(o, o.published, signer); + const pair = o.published; + const check = (at: number) => o.rv.verifyYanked({ ...pair, keys: o.keys, now: new Date(at) }); + let verified: Awaited>; + try { + verified = await check(o.now); + } catch (e) { + const code = (e as { code?: string }).code; + if (!(code === "stale-yank-list" && o.acceptExpired)) + throw new Error( + `the published yank list does not verify (${code ?? "error"}: ${(e as Error).message}); nothing carried forward, nothing signed`, + ); + verified = await check(Date.parse(issuedAtOf(pair))).catch((again: Error) => { + throw new Error(`the published yank list does not verify: ${again.message}`); + }); + } + const d = verified.document; + return { + list: { versions: d.versions, revokedKeys: d.revokedKeys }, + serial: d.serial, + memory: o.rv.rememberYanks(verified, o.keys), + }; +} + +/** No `keys.json` is known for a list the old yank key signed: the epoch, so the current one is newer. */ +const UNKNOWN_KEY_LIST = "1970-01-01T00:00:00Z"; + +/** + * The last list of the yank key a rotation replaced (see the file comment). The current + * `keys.json` must still list that key with role `yank`; its list is verified with it as of the + * list's `issuedAt`. The memory names no `keys.json` (`UNKNOWN_KEY_LIST`): the one that list was + * valid under is not known here, and the current one replaces the old key, as it does for every + * consumer. + */ +async function verifyRotated( + o: { rv: Verifier; keys: VerifiedKeyList; keyId: string }, + pair: PublishedPair, + signer: string | undefined, +): Promise { + const old = o.keys.document.keys.find((k) => k.keyId === signer && k.role === "yank"); + if (!old) + throw new Error( + `the published yank list is signed by ${signer ?? "no key"}, which keys.json does not list as a yank key: keep the old yank key listed (revoked or retiring) to carry its list forward, or prune with --rotate`, + ); + const asSigned = { + ...o.keys, + document: { + ...o.keys.document, + keys: o.keys.document.keys.map((k) => + k.keyId === old.keyId ? { ...k, status: "active" as const } : k, + ), + }, + }; + let verified: Awaited>; + try { + verified = await o.rv.verifyYanked({ + ...pair, + keys: asSigned, + now: new Date(Date.parse(issuedAtOf(pair))), + }); + } catch (e) { + throw new Error( + `the published yank list (signed by the replaced key ${old.keyId}) does not verify: ${(e as Error).message}; nothing carried forward, nothing signed`, + ); + } + const d = verified.document; + return { + list: { versions: d.versions, revokedKeys: d.revokedKeys }, + serial: d.serial, + memory: { ...o.rv.rememberYanks(verified, o.keys), keysIssuedAt: UNKNOWN_KEY_LIST }, + }; +} + +const issuedAtOf = (pair: PublishedPair): string => + String((JSON.parse(new TextDecoder().decode(pair.document)) as { issuedAt?: unknown }).issuedAt); + +/** The published pair at `from` (a file or an https URL, `.sig` next to it), if there is one. */ +export async function readPublished( + from: string | undefined, + firstRun: boolean, +): Promise { + if (!from) { + if (firstRun) return undefined; + throw new Error("--published is required (or --first-run for the very first list)"); + } + if (from.startsWith("https://")) { + const get = async (url: string) => { + const res = await fetch(url); + if (res.status === 404 && firstRun) return undefined; + if (!res.ok) throw new Error(`could not read ${url} (${res.status})`); + return new Uint8Array(await res.arrayBuffer()); + }; + const document = await get(from); + if (!document) return undefined; + const signature = await get(`${from}.sig`); + if (!signature) throw new Error(`${from} has no .sig next to it`); + return { document, signature }; + } + if (!existsSync(from)) { + if (firstRun) return undefined; + throw new Error(`there is no published yank list at ${from}`); + } + if (firstRun) throw new Error(`--first-run, but a published yank list exists at ${from}`); + return { document: readFileSync(from), signature: readFileSync(`${from}.sig`) }; +} diff --git a/scripts/release-trust-assets.ts b/scripts/release-trust-assets.ts new file mode 100644 index 00000000..4fa8c95d --- /dev/null +++ b/scripts/release-trust-assets.ts @@ -0,0 +1,102 @@ +/** + * The key list and the yank list as release assets (RFC-0046 §1, §9): every release attaches the + * copies current when it is made, verified first, as a snapshot next to the bundle. Consumers + * prefer the canonical URLs; these copies serve `--from` and history. + * + * - Before the root ceremony there is no `security/keys.json`: nothing is attached. + * - `keys.json` comes from `security/` and must verify with the pinned root; `yanked.json` comes + * from cloudhal.dev and must verify against it. Either failing refuses the release. + * - A release already published is immutable: a resumed run takes the trust assets the release + * carries (they were verified when attached) instead of fetching newer copies. + */ +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { LocalAsset, ReleaseView } from "./release-publish-plan.ts"; + +export const TRUST_ASSET_NAMES = ["keys.json", "keys.json.sig", "yanked.json", "yanked.json.sig"]; + +const asset = (name: string, file: string): LocalAsset => { + const bytes = readFileSync(file); + return { + name, + file, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; +}; + +export interface TrustOptions { + readonly root: string; + /** The release as it exists now (a draft or none: fetch; published: reuse). */ + readonly release: ReleaseView | null; + readonly fetchImpl?: (url: string) => Promise; + readonly now?: Date; + readonly log?: (line: string) => void; + /** Tests only: roots other than the pinned ones. */ + readonly roots?: Readonly>; +} + +export interface TrustAssets { + readonly assets: LocalAsset[]; + /** The verified key list, for the consumer check of the bundle (none for a published release). */ + readonly keys?: unknown; + /** The verified yank list, likewise. */ + readonly yanked?: unknown; +} + +/** The trust assets to attach (see the file comment), and the two lists they verified. */ +export async function trustAssets(o: TrustOptions): Promise { + const log = o.log ?? ((l: string) => console.log(`publish-ci: ${l}`)); + if (o.release && !o.release.draft) { + // Immutable: expect exactly what it carries (names + digests; never uploaded again). + const assets = o.release.assets + .filter((a) => TRUST_ASSET_NAMES.includes(a.name)) + .map((a) => ({ + name: a.name, + file: "", + bytes: a.size, + sha256: (a.digest ?? "").replace(/^sha256:/, ""), + })); + return { assets }; + } + const keysFile = path.join(o.root, "security/keys.json"); + const keysSig = path.join(o.root, "security/keys.json.sig"); + if (!existsSync(keysFile) || !existsSync(keysSig)) { + log("no security/keys.json yet (before the root ceremony): no trust assets attached"); + return { assets: [] }; + } + // Loaded here only: the `plan` job runs this script's caller without a pnpm install. + const rv = await import("../packages/release-verify/src/index.ts"); + const now = o.now ?? new Date(); + const keys = await rv.verifyKeys({ + document: readFileSync(keysFile), + signature: readFileSync(keysSig), + now, + ...(o.roots ? { roots: o.roots } : {}), + }); + const get = o.fetchImpl ?? ((url: string) => fetch(url)); + const read = async (name: string) => { + const res = await get(rv.TRUST_BASE_URL + name); + if (!res.ok) throw new Error(`could not read ${name} from cloudhal.dev (${res.status})`); + return new Uint8Array(await res.arrayBuffer()); + }; + const yanked = await read("yanked.json"); + const yankedSig = await read("yanked.json.sig"); + const verified = await rv.verifyYanked({ document: yanked, signature: yankedSig, keys, now }); + const dir = mkdtempSync(path.join(os.tmpdir(), "cloudhal-trust-")); + writeFileSync(path.join(dir, "yanked.json"), yanked); + writeFileSync(path.join(dir, "yanked.json.sig"), yankedSig); + log(`attaching keys.json and yanked.json (key list ${keys.sha256.slice(0, 12)})`); + return { + assets: [ + asset("keys.json", keysFile), + asset("keys.json.sig", keysSig), + asset("yanked.json", path.join(dir, "yanked.json")), + asset("yanked.json.sig", path.join(dir, "yanked.json.sig")), + ], + keys, + yanked: verified, + }; +}