From 5baacb4187e3931a990c7b166d24fa84c23cc8a0 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:03:51 +0300 Subject: [PATCH] feat(release): pin the root and publish the first keys.json (#226) The owner's root ceremony is done (2026-11-15). - PINNED_ROOTS pins cloudhal-root-2026a (Ed25519, /rtDvlSlCYV402NiA/7OOFk+ryBv4DIWmkqwRyrVzqw=), frozen. - security/keys.json, keys.json.sig and keys.source.json are the ceremony's files, byte for byte (never re-signed here): issuedAt 2026-11-15T07:00:45Z, the release key cloudhal-release-2026a and the yank key cloudhal-yank-2026a. - The key doc gains the root row (with hex, SHA-256 and PEM) and the yank key's row; tests keep both equal to PINNED_ROOTS and to keys.json. - A test verifies the committed keys.json with PINNED_ROOTS and refuses a flipped byte; the no-root refusals inject roots: {}. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/pin-root-2026a.md | 5 ++ docs/security/release-signing-keys.md | 22 +++++++- packages/cli/test/deploy-release.test.ts | 5 +- packages/release-verify/src/trust.ts | 10 ++-- packages/release-verify/test/package.test.ts | 56 +++++++++++++++++++- packages/release-verify/test/trust.test.ts | 4 +- security/keys.json | 29 ++++++++++ security/keys.json.sig | 1 + security/keys.source.json | 8 +++ 9 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 .changeset/pin-root-2026a.md create mode 100644 security/keys.json create mode 100644 security/keys.json.sig diff --git a/.changeset/pin-root-2026a.md b/.changeset/pin-root-2026a.md new file mode 100644 index 00000000..ad2c1af6 --- /dev/null +++ b/.changeset/pin-root-2026a.md @@ -0,0 +1,5 @@ +--- +"@cloudhal/release-verify": patch +--- + +`PINNED_ROOTS` now pins the offline root `cloudhal-root-2026a` (Ed25519, from the owner's root ceremony on 2026-11-15). `verifyKeys` verifies the published `keys.json` with it by default. The first `keys.json` lists the release key `cloudhal-release-2026a` and the yank key `cloudhal-yank-2026a`, and is committed as `security/keys.json`. A CLI built with this version can run `cloudhal deploy --release` once cloudhal.dev serves the lists. diff --git a/docs/security/release-signing-keys.md b/docs/security/release-signing-keys.md index c65ff85b..d0ddd554 100644 --- a/docs/security/release-signing-keys.md +++ b/docs/security/release-signing-keys.md @@ -40,8 +40,21 @@ The root signs only `keys.json`, at the yearly rotation or in an emergency (RFC- | Key id | Algorithm | Public key (raw 32 bytes, base64) | Since | |---|---|---|---| +| `cloudhal-root-2026a` | Ed25519 | `/rtDvlSlCYV402NiA/7OOFk+ryBv4DIWmkqwRyrVzqw=` | 2026-11-15 | -None yet. The owner's root ceremony adds the first row (see *First-time setup* below). +Other forms of `cloudhal-root-2026a`, for cross-checking: + +- Raw public key, hex: `febb43be54a5098578d3636203fece38593eaf206fe032169a4ab0472ad5ceac` +- SHA-256 of the raw public key, hex: `16d77b0a2ab4643802ca6e37000c39863f0f0611b133141e22784b38bac9d4a2` +- SPKI PEM: + + ``` + -----BEGIN PUBLIC KEY----- + MCowBQYDK2VwAyEA/rtDvlSlCYV402NiA/7OOFk+ryBv4DIWmkqwRyrVzqw= + -----END PUBLIC KEY----- + ``` + +The first `keys.json` was signed by it on 2026-11-15 (`issuedAt` 2026-11-15T07:00:45Z, expiring 2027-11-15T07:00:45Z) and is committed as `security/keys.json` with `security/keys.json.sig`. **Custody.** - The root is generated offline by `node scripts/release-keys.ts root-init`. The private key is only ever written encrypted with a passphrase, and the script never prints it. @@ -52,6 +65,13 @@ None yet. The owner's root ceremony adds the first row (see *First-time setup* b ## The yank key and the two lists - **The yank key.** `cloudhal-yank-2026a` has role `yank` in `keys.json`. It signs only `yanked.json`. + + | Key id | Algorithm | Public key (raw 32 bytes, base64) | Status | Since | Not after | + |---|---|---|---|---|---| + | `cloudhal-yank-2026a` | Ed25519 | `kCnR6ndVCEVQ+EoNDFsP9tKuScP6xB95emcQCzZsNG4=` | active | 2026-11-15 | 2027-11-12 | + + Raw public key, hex: `9029d1ea7755084550f84a0d0c5b0ff6d2ae49c3fac41f797a67100b366c346e`; SHA-256 of it: `a4be5c2588264f4d824705d85bf96cd4b214ab553c52e66364e71d18c0e9f390`. A test keeps this row equal to the committed `keys.json`. + - It lives as the secret `RELEASE_YANK_KEY` of the GitHub environment `release-yank`, whose branch policy allows `main` only. - It can only reduce trust: it yanks versions and revokes release key ids, and it can never add or re-enable a key. - A stolen yank key can yank good versions and revoke good keys (denial of service). diff --git a/packages/cli/test/deploy-release.test.ts b/packages/cli/test/deploy-release.test.ts index 91a52acc..243388c3 100644 --- a/packages/cli/test/deploy-release.test.ts +++ b/packages/cli/test/deploy-release.test.ts @@ -84,10 +84,9 @@ const codeOf = async (p: Promise) => { }; describe("deploy --release: trust", () => { - it("refuses before the root ceremony, naming it, and fetches nothing", async () => { + it("refuses with no root pinned (a CLI built before the ceremony), naming it, and fetches nothing", async () => { const s = await setup(); - const { trustRoots: _roots, ...options } = s.options; - await expect(deploy(options)).rejects.toMatchObject({ + await expect(deploy({ ...s.options, trustRoots: {} })).rejects.toMatchObject({ code: "trust-unavailable", message: expect.stringMatching(/root ceremony.*First-time setup/), }); diff --git a/packages/release-verify/src/trust.ts b/packages/release-verify/src/trust.ts index e9d7ce6d..66b10497 100644 --- a/packages/release-verify/src/trust.ts +++ b/packages/release-verify/src/trust.ts @@ -24,10 +24,14 @@ export type RootKeys = Readonly>; /** * The offline root's public key (docs/security/release-signing-keys.md, kept equal by - * test/package.test.ts). Empty until the owner's root ceremony: until then `verifyKeys` refuses - * every list unless a caller passes `roots`. + * test/package.test.ts), pinned at the owner's root ceremony on 2026-11-15. It signs only + * `keys.json`; a rotation of the root is a new pinned entry in a reviewed release. */ -export const PINNED_ROOTS: RootKeys = Object.freeze({}); +export const PINNED_ROOTS: RootKeys = Object.freeze({ + "cloudhal-root-2026a": Object.freeze({ + publicKey: "/rtDvlSlCYV402NiA/7OOFk+ryBv4DIWmkqwRyrVzqw=", + }), +}); export const KEYS_FILE = "keys.json"; export const YANKED_FILE = "yanked.json"; diff --git a/packages/release-verify/test/package.test.ts b/packages/release-verify/test/package.test.ts index 742214a7..7df44b0e 100644 --- a/packages/release-verify/test/package.test.ts +++ b/packages/release-verify/test/package.test.ts @@ -20,6 +20,7 @@ import { releaseAssetNames, releaseAssetUrls, unsafePathReason, + verifyKeys, } from "../src/index.ts"; import { fixture } from "./helpers.ts"; @@ -53,7 +54,7 @@ describe("the package and its sources of truth", () => { expect(Object.isFrozen(PINNED_KEYS)).toBe(true); }); - it("PINNED_ROOTS is the key doc's root keys (none until the root ceremony)", () => { + it("PINNED_ROOTS is the key doc's root keys, row for row", () => { // | `cloudhal-root-…` | Ed25519 | `public key` | since | const rows = keyDoc().filter((r) => (r[1] as string).startsWith("`cloudhal-root-")); const fromDoc = Object.fromEntries( @@ -63,7 +64,60 @@ describe("the package and its sources of truth", () => { ]), ); expect(fromDoc).toEqual(PINNED_ROOTS); + expect(Object.keys(PINNED_ROOTS)).toEqual(["cloudhal-root-2026a"]); expect(Object.isFrozen(PINNED_ROOTS)).toBe(true); + for (const root of Object.values(PINNED_ROOTS)) expect(Object.isFrozen(root)).toBe(true); + }); + + it("the committed security/keys.json verifies with PINNED_ROOTS, byte for byte as published", async () => { + const document = readFileSync(path.join(ROOT, "security/keys.json")); + const signature = readFileSync(path.join(ROOT, "security/keys.json.sig")); + // Inside its validity; the yearly re-signing (runbook, Rotation step 5) replaces both files. + const now = new Date("2026-12-01T00:00:00Z"); + const list = await verifyKeys({ document, signature, roots: PINNED_ROOTS, now }); + expect(list.document.signing.keyId).toBe("cloudhal-root-2026a"); + expect(list.document.issuedAt).toBe("2026-11-15T07:00:45Z"); + // The same list the source holds (the source is what the next re-signing starts from). + const source = JSON.parse(readFileSync(path.join(ROOT, "security/keys.source.json"), "utf8")); + expect(list.document.keys).toEqual(source.keys); + // A flipped byte, or the signature of another document, is refused. + const flipped = new Uint8Array(document); + flipped[flipped.indexOf(0x32)] = 0x33; // the first "2" of a date + await expect( + verifyKeys({ document: flipped, signature, roots: PINNED_ROOTS, now }), + ).rejects.toMatchObject({ + code: "key-list-invalid", + }); + }); + + it("the key doc's yank key row is the committed keys.json's yank key", () => { + const doc = readFileSync(path.join(ROOT, "docs/security/release-signing-keys.md"), "utf8"); + // | `cloudhal-yank-…` | Ed25519 | `public key` | status | since | not after | (inside a list item) + const rows = doc + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.startsWith("| `cloudhal-yank-")) + .map((l) => l.split("|").map((c) => c.trim())); + const keys = JSON.parse(readFileSync(path.join(ROOT, "security/keys.json"), "utf8")) as { + keys: { + keyId: string; + publicKey: string; + role: string; + status: string; + notBefore: string; + notAfter: string; + }[]; + }; + const yank = keys.keys.filter((k) => k.role === "yank"); + expect( + rows.map((r) => ({ + keyId: (r[1] as string).slice(1, -1), + publicKey: (r[3] as string).slice(1, -1), + status: r[4], + notBefore: day(r[5] as string), + notAfter: day(r[6] as string), + })), + ).toEqual(yank.map(({ role: _role, ...k }) => k)); }); it("LIMITS and the asset names equal the producer's", () => { diff --git a/packages/release-verify/test/trust.test.ts b/packages/release-verify/test/trust.test.ts index 33c92d04..3f219d1c 100644 --- a/packages/release-verify/test/trust.test.ts +++ b/packages/release-verify/test/trust.test.ts @@ -101,7 +101,9 @@ describe("verifyKeys", () => { it("refuses an unpinned root, a wrong signature, tampered bytes and no roots at all", async () => { const s = signed(keysDoc(), ROOT.privateKey); - expect(await codeOf(verifyKeys({ ...s, now: NOW }))).toBe("key-list-invalid"); // PINNED_ROOTS is empty + // No roots at all (a CLI built before the ceremony), and the pinned root, which did not sign it. + expect(await codeOf(verifyKeys({ ...s, roots: {}, now: NOW }))).toBe("key-list-invalid"); + expect(await codeOf(verifyKeys({ ...s, now: NOW }))).toBe("key-list-invalid"); const other = signed(keysDoc(), keyPair().privateKey); expect(await codeOf(verifyKeys({ ...other, roots: ROOTS, now: NOW }))).toBe("key-list-invalid"); const tampered = new Uint8Array(s.document); diff --git a/security/keys.json b/security/keys.json new file mode 100644 index 00000000..cc24268f --- /dev/null +++ b/security/keys.json @@ -0,0 +1,29 @@ +{ + "expires": "2027-11-15T07:00:45Z", + "format": "cloudhal.release-keys@1", + "issuedAt": "2026-11-15T07:00:45Z", + "keys": [ + { + "keyId": "cloudhal-release-2026a", + "notAfter": "2027-11-12T00:00:00Z", + "notBefore": "2026-11-12T00:00:00Z", + "publicKey": "7az3pPc4oT0AOfRcdfOZZQkM6XAGHo2jXEdDZ1qEVR8=", + "role": "release", + "status": "active" + }, + { + "keyId": "cloudhal-yank-2026a", + "notAfter": "2027-11-12T00:00:00Z", + "notBefore": "2026-11-15T00:00:00Z", + "publicKey": "kCnR6ndVCEVQ+EoNDFsP9tKuScP6xB95emcQCzZsNG4=", + "role": "yank", + "status": "active" + } + ], + "schemaVersion": 1, + "signing": { + "algorithm": "Ed25519", + "keyId": "cloudhal-root-2026a", + "signature": "keys.json.sig" + } +} diff --git a/security/keys.json.sig b/security/keys.json.sig new file mode 100644 index 00000000..98fb557a --- /dev/null +++ b/security/keys.json.sig @@ -0,0 +1 @@ +È^ì¾o;µ¬áÂ?_™ÉQ%OÞ×±Ì?GÄ›ñÔøß}×Ãî{È+ŽuGE2º[J�Åß`SÆ�” \ No newline at end of file diff --git a/security/keys.source.json b/security/keys.source.json index 4a5ac19b..e2a477ad 100644 --- a/security/keys.source.json +++ b/security/keys.source.json @@ -7,6 +7,14 @@ "notBefore": "2026-11-12T00:00:00Z", "notAfter": "2027-11-12T00:00:00Z", "status": "active" + }, + { + "keyId": "cloudhal-yank-2026a", + "publicKey": "kCnR6ndVCEVQ+EoNDFsP9tKuScP6xB95emcQCzZsNG4=", + "role": "yank", + "notBefore": "2026-11-15T00:00:00Z", + "notAfter": "2027-11-12T00:00:00Z", + "status": "active" } ] }