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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pin-root-2026a.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 21 additions & 1 deletion docs/security/release-signing-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/test/deploy-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,9 @@ const codeOf = async (p: Promise<unknown>) => {
};

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/),
});
Expand Down
10 changes: 7 additions & 3 deletions packages/release-verify/src/trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ export type RootKeys = Readonly<Record<string, { readonly publicKey: string }>>;

/**
* 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";
Expand Down
56 changes: 55 additions & 1 deletion packages/release-verify/test/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
releaseAssetNames,
releaseAssetUrls,
unsafePathReason,
verifyKeys,
} from "../src/index.ts";
import { fixture } from "./helpers.ts";

Expand Down Expand Up @@ -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(
Expand All @@ -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", () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/release-verify/test/trust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions security/keys.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions security/keys.json.sig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
È^ì¾o;µ¬áÂ?_™ÉQ%OÞ×±Ì?GÄ›ñÔøß}×Ãî{È+ŽuGE2º[JÅß`SƁ”
8 changes: 8 additions & 0 deletions security/keys.source.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Loading