From 3893b87220840c228bbec4e4f270295f4d541c73 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:16:55 +0300 Subject: [PATCH 1/2] fix(release): root-init encrypts the root at 600,000 PBKDF2 iterations; custody as decided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit root-init wrote the root as PBES2 with PBKDF2-HMAC-SHA256 at 2,048 iterations, Node's KeyObject.export default, which has no option for more. scripts/release-keys/pkcs8.ts now writes the PBES2 EncryptedPrivateKeyInfo itself (RFC 8018, RFC 5958): PBKDF2-HMAC-SHA256 at 600,000 iterations (OWASP), a 16-byte salt, AES-256-CBC, the same form as `openssl pkcs8 -topk8 -v2 aes-256-cbc -v2prf hmacWithSHA256 -iter 600000`. Node and OpenSSL read it with the passphrase; sign-keys still reads the older 2,048-iteration roots. readPbes2 parses the parameters back from the DER, and a test asserts the written file's iteration count, prf and cipher, and that an old-form root still signs a keys.json the consumer verifies. Docs (owner decision, 2026-11-15): the root's encrypted file has two sealed copies, one in the private cloudhal-dev/key-custody repository (owner only; no Actions, apps or deploy keys; no passphrase, no plaintext key) and one offline; the passphrase is kept apart, in a password manager and on sealed paper. That copy is the one documented exception to "no copy in a repository or a cloud service", which now names the release key, the yank key and passphrases. The plaintext yank key and the yank-list deploy key were deleted once set as release-yank secrets; a lost one is rotated, not restored. The runbook gives the by-hand re-encryption for roots made before this fix, and RFC-0046 §9's custody line carries the same exception. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- docs/rfcs/0046-release-bundles.md | 4 +- docs/security/release-signing-keys.md | 25 +++-- packages/cli/test/release-keys.test.ts | 49 +++++++++ scripts/release-keys.ts | 13 ++- scripts/release-keys/pkcs8.ts | 140 +++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 15 deletions(-) create mode 100644 scripts/release-keys/pkcs8.ts diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index 218e6000..f889d05c 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -206,8 +206,8 @@ Tar format: ustar with PAX `path` records for long names; regular files only (no - The route first shipped with one manual docs deploy. Since #236 the *Docs deploy* workflow deploys cloudhal.dev on every push to `main` that changes what it is built from, `security/**` included (RFC-0037). - 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. - - It has one holder for now, with two sealed copies kept in separate places. A YubiKey (firmware 5.7 or later, for Ed25519) is optional. + - The owner generates it offline with `node scripts/release-keys.ts root-init`. The script writes the private key encrypted with a passphrase (PKCS#8 PBES2, PBKDF2-HMAC-SHA256 at 600,000 iterations, AES-256-CBC), never prints it, and writes the public key next to it. + - It has one holder for now. The *encrypted* file has two sealed copies: one in the private repository `cloudhal-dev/key-custody` (owner only; no Actions, apps or deploy keys; no passphrase and no plaintext key in it), and one kept offline. The passphrase is kept apart, in a password manager and on sealed paper. That repository copy is the one documented exception to keeping keys out of repositories and cloud services; the release key, the yank key and every passphrase stay out of them, apart from the release and yank keys' environment secrets. A YubiKey (firmware 5.7 or later, for Ed25519) is optional. - A second holder is added when there is a second maintainer. - `@cloudhal/release-verify` pins the root's public key, and a repository test keeps it equal to the key doc. - **`keys.json`.** diff --git a/docs/security/release-signing-keys.md b/docs/security/release-signing-keys.md index 5530762f..544f044c 100644 --- a/docs/security/release-signing-keys.md +++ b/docs/security/release-signing-keys.md @@ -57,10 +57,22 @@ Other forms of `cloudhal-root-2026a`, for cross-checking: 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. -- There is one holder for now, with two sealed copies (the encrypted file plus its passphrase) kept in separate places. A YubiKey with firmware 5.7 or later, which can hold Ed25519, is an option. +- The root is generated offline by `node scripts/release-keys.ts root-init`. The private key is only ever written encrypted with a passphrase (PKCS#8 PBES2: PBKDF2-HMAC-SHA256 at 600,000 iterations, AES-256-CBC), and the script never prints it. +- There is one holder for now. The *encrypted* file has two sealed copies: + - one in the private repository `cloudhal-dev/key-custody`, which only the owner can read. It has no Actions, no apps and no deploy keys, and it holds neither the passphrase nor a plaintext key; + - one kept offline. +- The passphrase is kept apart from both copies, in a password manager and on sealed paper. +- That repository copy is the one exception to the rule that no key is ever in a repository or a cloud service (*Custody of the release key*). It is the encrypted root only, never its passphrase. +- A YubiKey with firmware 5.7 or later, which can hold Ed25519, is an option. - A second holder is added when there is a second maintainer. - The root is never a GitHub secret. +- **A root made before 600,000 iterations.** `root-init` used to write the root with Node's default of only 2,048 PBKDF2 iterations. `sign-keys` reads both forms. Re-encrypt such a file once, offline, with the same passphrase or a new one: + + ```bash + openssl pkcs8 -topk8 -v2 aes-256-cbc -v2prf hmacWithSHA256 -iter 600000 -in .key.enc -out .key.enc.new + ``` + + Check it with `openssl asn1parse -in .key.enc.new`: the PBKDF2 `INTEGER` is `0927C0` (600,000) and the prf is `hmacWithSHA256`. Then replace both sealed copies with it and delete the old file. `cloudhal-root-2026a` was re-encrypted this way on 2026-11-15. ## The yank key and the two lists @@ -73,6 +85,7 @@ The first `keys.json` was signed by it on 2026-11-15 (`issuedAt` 2026-11-15T07:0 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. + - That secret is its only copy: the plaintext key was deleted once the secret was set. If it is lost, the yank key is rotated (one `keys.json`, below), never restored. The same holds for the `yank-list` deploy key (*First-time setup*, step 7). - 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). - It can also *undo* yanks and revocations for stateless consumers, such as a Worker deployer, by signing a fresh list without them. @@ -100,7 +113,7 @@ The first `keys.json` was signed by it on 2026-11-15 (`issuedAt` 2026-11-15T07:0 - No other job, workflow or repository receives the key. - Outside GitHub, the maintainer holds the key. - The copies are the PKCS#8 file the key was generated into, kept readable by its owner only, and the maintainer's backups. - - No copy is ever in a repository, a chat, an issue or a cloud service. + - Apart from their environment secrets, no copy of the release key or the yank key, and no passphrase, is ever in a repository, a chat, an issue or a cloud service. The one exception is the root's *encrypted* file, without its passphrase, in the private `cloudhal-dev/key-custody` repository (*Root keys*, Custody). - Once the environment secret is set, and before the first signed release, the maintainer moves the generated file to offline storage. From then on, the environment secret and the offline backups are the only copies. - KMS custody is deferred to phase 3 ("Release signing: KMS-backed signer (RFC-0046 §9 phase 3)"). It is triggered by a second maintainer, or by any exposure of this secret. @@ -108,14 +121,14 @@ The first `keys.json` was signed by it on 2026-11-15 (`issuedAt` 2026-11-15T07:0 This runs once, offline where it says so. Nobody else generates or holds these keys. -1. **Root.** On an offline machine, run `node scripts/release-keys.ts root-init --key-id cloudhal-root-2026a --out `. It asks for a passphrase twice, then writes `/cloudhal-root-2026a.key.enc` (encrypted PKCS#8) and `/cloudhal-root-2026a.pub.json`. Seal two copies of the encrypted file and the passphrase, in separate places. +1. **Root.** On an offline machine, run `node scripts/release-keys.ts root-init --key-id cloudhal-root-2026a --out `. It asks for a passphrase twice, then writes `/cloudhal-root-2026a.key.enc` (encrypted PKCS#8, 600,000 PBKDF2 iterations) and `/cloudhal-root-2026a.pub.json`. Seal two copies of the encrypted file (*Root keys*, Custody) and keep the passphrase apart from them. 2. **Pin the root.** Add its public key to the *Root keys* table above and to `PINNED_ROOTS` in `packages/release-verify/src/trust.ts`, in one reviewed pull request. 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. +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 delete the `.pem` file: the secret is its only copy, and a lost yank key is rotated, not restored. 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`). **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. + - 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. If the secret is lost, make a new deploy key the same way and remove the old one. - 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. diff --git a/packages/cli/test/release-keys.test.ts b/packages/cli/test/release-keys.test.ts index 6c0b4609..8a2bce7d 100644 --- a/packages/cli/test/release-keys.test.ts +++ b/packages/cli/test/release-keys.test.ts @@ -2,6 +2,7 @@ * scripts/release-keys.ts (RFC-0046 §9, #226): the root and yank keys a ceremony makes, and the * lists it signs, checked with the consumer's own verifier. Throwaway keys in temp directories only. */ +import { createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto"; import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -11,6 +12,8 @@ import { main, mergeYankList, type PublicKeyFile, + ROOT_KDF_ITERATIONS, + readPbes2, rootInit, signKeys, signYanked, @@ -99,6 +102,26 @@ describe("root-init and yank-init", () => { expect(said).not.toContain(pem.split("\n")[1] as string); }); + it("encrypt the root with PBKDF2-HMAC-SHA256 at 600,000 iterations and AES-256-CBC", () => { + const { dir, root } = ceremony(); + const enc = rootKeyOf(dir); + expect(ROOT_KDF_ITERATIONS).toBeGreaterThanOrEqual(600_000); + expect(readPbes2(enc)).toEqual({ + iterations: 600_000, + saltBytes: 16, + prf: "hmacWithSHA256", + cipher: "aes-256-cbc", + }); + // Node (OpenSSL) opens it with the passphrase, and it is the root that was announced. + const key = createPrivateKey({ key: enc, passphrase: PASS }); + const raw = createPublicKey(key).export({ format: "der", type: "spki" }).subarray(12); + expect(Buffer.from(raw).toString("base64")).toBe(root.publicKey); + expect(() => createPrivateKey({ key: enc, passphrase: "wrong passphrase!" })).toThrow(); + // Each file has its own salt. + expect(readPbes2(rootKeyOf(ceremony().dir))).toEqual(readPbes2(enc)); + expect(rootKeyOf(ceremony().dir)).not.toBe(enc); + }); + it("refuse to overwrite a key, a short passphrase and a key id without its role", () => { const { dir } = ceremony(); expect(() => yankInit({ keyId: "cloudhal-yank-2026a", out: dir, log: quiet })).toThrow( @@ -131,6 +154,32 @@ describe("sign-keys", () => { expect(readFileSync(path.join(out, "keys.json.sig")).length).toBe(64); }); + it("still opens a root written before the fix (2,048 iterations, Node's default export)", async () => { + const { source } = ceremony(); + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const old = privateKey.export({ + format: "pem", + type: "pkcs8", + cipher: "aes-256-cbc", + passphrase: PASS, + }); + expect(readPbes2(old).iterations).toBe(2048); + const raw = publicKey.export({ format: "der", type: "spki" }).subarray(12); + const oldRoot = { + keyId: "cloudhal-root-2026a", + publicKey: Buffer.from(raw).toString("base64"), + }; + const signed = await signKeys({ + rootKey: old, + passphrase: PASS, + rootPub: oldRoot, + source, + out: tmp(), + now: NOW, + }); + await verifyKeys({ ...signed, roots: roots(oldRoot), now: new Date(NOW) }); + }); + it("refuses a wrong passphrase, another root's key, and more than 13 months", async () => { const { dir, root, source } = ceremony(); const out = tmp(); diff --git a/scripts/release-keys.ts b/scripts/release-keys.ts index 8ea76073..f4c4cbdc 100644 --- a/scripts/release-keys.ts +++ b/scripts/release-keys.ts @@ -4,7 +4,8 @@ * document the way consumers do before it is written. No command ever prints a private key. * * node scripts/release-keys.ts root-init --key-id cloudhal-root-2026a --out [--passphrase-file ] - * An Ed25519 root: /.key.enc (PKCS#8, AES-256 with the passphrase) + .pub.json. + * An Ed25519 root: /.key.enc (PKCS#8, AES-256-CBC under PBKDF2-HMAC-SHA256 with + * 600,000 iterations of the passphrase) + .pub.json. sign-keys also reads older roots. * node scripts/release-keys.ts yank-init --key-id cloudhal-yank-2026a --out * The yank key: /.pem (mode 0600, for the RELEASE_YANK_KEY secret) + .pub.json. * node scripts/release-keys.ts sign-keys --root-key .key.enc --root-pub .pub.json @@ -35,6 +36,7 @@ import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { canonicalJson, sha256 } from "./release-bundle/manifest.ts"; +import { encryptPkcs8 } from "./release-keys/pkcs8.ts"; import { checkAppendOnly, mergeYankList, @@ -45,6 +47,7 @@ import { type YankSource, } from "./release-keys/yank-list.ts"; +export { type Pbes2Params, ROOT_KDF_ITERATIONS, readPbes2 } from "./release-keys/pkcs8.ts"; export { checkAppendOnly, MAX_EXTRA, @@ -115,12 +118,8 @@ export function rootInit(o: { if (o.passphrase.length < 12) throw new Error("the passphrase must be at least 12 characters"); mkdirSync(o.out, { recursive: true }); const { privateKey, publicKey } = generateKeyPairSync("ed25519"); - const enc = privateKey.export({ - format: "pem", - type: "pkcs8", - cipher: "aes-256-cbc", - passphrase: o.passphrase, - }); + // Not `privateKey.export({ cipher })`: that is 2,048 PBKDF2 iterations, with no way to ask for more. + const enc = encryptPkcs8(privateKey, o.passphrase); writeNew(path.join(o.out, `${o.keyId}.key.enc`), enc, 0o600); o.log(`wrote ${o.keyId}.key.enc (encrypted; seal two copies with the passphrase, apart)`); return writePublic(o.out, o.keyId, publicKey, o.log); diff --git a/scripts/release-keys/pkcs8.ts b/scripts/release-keys/pkcs8.ts new file mode 100644 index 00000000..0c3d7ee8 --- /dev/null +++ b/scripts/release-keys/pkcs8.ts @@ -0,0 +1,140 @@ +/** + * The root's encrypted PKCS#8 file (RFC-0046 §9; runbook: docs/security/release-signing-keys.md). + * + * Node's `KeyObject.export({ cipher, passphrase })` writes PBES2 with PBKDF2-HMAC-SHA256 at + * OpenSSL's default of 2,048 iterations and has no option for more. `encryptPkcs8` writes the same + * PBES2 structure itself (RFC 8018 §6.2 and appendix A.2/A.4, RFC 5958 `EncryptedPrivateKeyInfo`) + * with `ROOT_KDF_ITERATIONS`, AES-256-CBC and HMAC-SHA256: the form + * `openssl pkcs8 -topk8 -v2 aes-256-cbc -v2prf hmacWithSHA256 -iter 600000` writes. Node and + * OpenSSL read it back with `createPrivateKey({ key, passphrase })`, as they read the older + * 2,048-iteration files. `readPbes2` reads the parameters of either back, for tests and checks. + */ +import { createCipheriv, type KeyObject, pbkdf2Sync, randomBytes } from "node:crypto"; + +/** PBKDF2 iterations for the root (OWASP's figure for PBKDF2-HMAC-SHA256). */ +export const ROOT_KDF_ITERATIONS = 600_000; + +const OID = { + pbes2: "2a864886f70d01050d", // 1.2.840.113549.1.5.13 + pbkdf2: "2a864886f70d01050c", // 1.2.840.113549.1.5.12 + hmacWithSHA1: "2a864886f70d0207", // 1.2.840.113549.2.7, PBKDF2's default prf + hmacWithSHA256: "2a864886f70d0209", // 1.2.840.113549.2.9 + aes256Cbc: "60864801650304012a", // 2.16.840.1.101.3.4.1.42 +} as const; + +const derLength = (n: number): Buffer => { + if (n < 0x80) return Buffer.from([n]); + if (n < 0x100) return Buffer.from([0x81, n]); + if (n < 0x10000) return Buffer.from([0x82, n >> 8, n & 0xff]); + throw new Error("DER value too long"); +}; +const tlv = (tag: number, body: Buffer): Buffer => + Buffer.concat([Buffer.from([tag]), derLength(body.length), body]); +const seq = (...parts: Buffer[]) => tlv(0x30, Buffer.concat(parts)); +const octets = (b: Buffer) => tlv(0x04, b); +const oid = (hex: string) => tlv(0x06, Buffer.from(hex, "hex")); +const NULL = Buffer.from([0x05, 0x00]); +const integer = (n: number): Buffer => { + const bytes: number[] = []; + for (let v = n; v > 0; v = Math.floor(v / 256)) bytes.unshift(v % 256); + if (bytes.length === 0 || (bytes[0] as number) >= 0x80) bytes.unshift(0); + return tlv(0x02, Buffer.from(bytes)); +}; + +const pem = (der: Uint8Array): string => { + const lines = + Buffer.from(der) + .toString("base64") + .match(/.{1,64}/g) ?? []; + return `-----BEGIN ENCRYPTED PRIVATE KEY-----\n${lines.join("\n")}\n-----END ENCRYPTED PRIVATE KEY-----\n`; +}; + +/** `key` as an encrypted PKCS#8 PEM: PBES2, PBKDF2-HMAC-SHA256 at `ROOT_KDF_ITERATIONS`, AES-256-CBC. */ +export function encryptPkcs8(key: KeyObject, passphrase: string): string { + const salt = randomBytes(16); + const iv = randomBytes(16); + const kek = pbkdf2Sync(passphrase, salt, ROOT_KDF_ITERATIONS, 32, "sha256"); + const cipher = createCipheriv("aes-256-cbc", kek, iv); + const plain = key.export({ format: "der", type: "pkcs8" }); + const encrypted = Buffer.concat([cipher.update(plain), cipher.final()]); + kek.fill(0); + const kdf = seq( + oid(OID.pbkdf2), + seq(octets(salt), integer(ROOT_KDF_ITERATIONS), seq(oid(OID.hmacWithSHA256), NULL)), + ); + const scheme = seq(oid(OID.aes256Cbc), octets(iv)); + return pem(seq(seq(oid(OID.pbes2), seq(kdf, scheme)), octets(encrypted))); +} + +/** One DER element at `at`: its tag, its body, and where the next element starts. */ +function element(der: Uint8Array, at: number): Der & { end: number } { + const tag = der[at]; + let len = der[at + 1]; + if (tag === undefined || len === undefined) throw new Error("truncated DER"); + let start = at + 2; + if (len >= 0x80) { + const n = len & 0x7f; + if (n < 1 || n > 3) throw new Error("unsupported DER length"); + len = 0; + for (let i = 0; i < n; i++) len = len * 256 + (der[start + i] ?? 0); + start += n; + } + if (start + len > der.length) throw new Error("truncated DER"); + return { tag, body: der.subarray(start, start + len), end: start + len }; +} + +/** The children of a constructed DER element. */ +function children(body: Uint8Array): Der[] { + const out: Der[] = []; + for (let at = 0; at < body.length; ) { + const e = element(body, at); + out.push(e); + at = e.end; + } + return out; +} + +type Der = { tag: number; body: Uint8Array }; +const hex = (b: Uint8Array | undefined) => (b ? Buffer.from(b).toString("hex") : undefined); +const NONE = new Uint8Array(0); + +const PRF: Record = { + [OID.hmacWithSHA1]: "hmacWithSHA1", + [OID.hmacWithSHA256]: "hmacWithSHA256", +}; + +export interface Pbes2Params { + readonly iterations: number; + readonly saltBytes: number; + /** `hmacWithSHA1` when the file leaves PBKDF2's default prf implied. */ + readonly prf: string; + readonly cipher: string; +} + +/** The PBES2/PBKDF2 parameters of an `ENCRYPTED PRIVATE KEY` PEM (any writer). */ +export function readPbes2(encryptedPem: string): Pbes2Params { + const b64 = encryptedPem + .split("\n") + .filter((l) => l !== "" && !l.startsWith("-----")) + .join(""); + const [algorithm] = children(element(Buffer.from(b64, "base64"), 0).body); + const [algOid, params] = children(algorithm?.body ?? NONE); + if (hex(algOid?.body) !== OID.pbes2 || !params) throw new Error("not PBES2"); + const [kdf, scheme] = children(params.body); + const [kdfOid, kdfParams] = children(kdf?.body ?? NONE); + if (hex(kdfOid?.body) !== OID.pbkdf2 || !kdfParams) throw new Error("not PBKDF2"); + const fields = children(kdfParams.body); + const salt = fields[0]; + const iterations = fields[1]; + if (salt?.tag !== 0x04 || iterations?.tag !== 0x02 || iterations.body.length > 6) + throw new Error("bad PBKDF2 parameters"); + const prfAlg = fields.find((f, i) => i >= 2 && f.tag === 0x30); + const prfOid = prfAlg ? hex(children(prfAlg.body)[0]?.body) : OID.hmacWithSHA1; + const cipherOid = hex(children(scheme?.body ?? NONE)[0]?.body); + return { + iterations: iterations.body.reduce((n, byte) => n * 256 + byte, 0), + saltBytes: salt.body.length, + prf: PRF[prfOid ?? ""] ?? `oid:${prfOid}`, + cipher: cipherOid === OID.aes256Cbc ? "aes-256-cbc" : `oid:${cipherOid}`, + }; +} From 20384c70cb90bc5c068c3a3f61f16473b7c039db Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:18:09 +0300 Subject: [PATCH 2/2] docs(release): a passphrase may sit only in an end-to-end encrypted password manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custody rule said no passphrase is ever in a cloud service, yet the root passphrase is kept in a password manager, and most sync. Both the key doc and RFC-0046 §9 now say no passphrase is ever in a repository, a chat, an issue, or a cloud service other than an end-to-end encrypted password manager, and the root's passphrase is kept in such a password manager. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- docs/rfcs/0046-release-bundles.md | 2 +- docs/security/release-signing-keys.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index f889d05c..282f9276 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -207,7 +207,7 @@ Tar format: ustar with PAX `path` records for long names; regular files only (no - 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 (PKCS#8 PBES2, PBKDF2-HMAC-SHA256 at 600,000 iterations, AES-256-CBC), never prints it, and writes the public key next to it. - - It has one holder for now. The *encrypted* file has two sealed copies: one in the private repository `cloudhal-dev/key-custody` (owner only; no Actions, apps or deploy keys; no passphrase and no plaintext key in it), and one kept offline. The passphrase is kept apart, in a password manager and on sealed paper. That repository copy is the one documented exception to keeping keys out of repositories and cloud services; the release key, the yank key and every passphrase stay out of them, apart from the release and yank keys' environment secrets. A YubiKey (firmware 5.7 or later, for Ed25519) is optional. + - It has one holder for now. The *encrypted* file has two sealed copies: one in the private repository `cloudhal-dev/key-custody` (owner only; no Actions, apps or deploy keys; no passphrase and no plaintext key in it), and one kept offline. The passphrase is kept apart, in an end-to-end encrypted password manager and on sealed paper. That repository copy is the one documented exception to keeping keys out of repositories and cloud services: the release key and the yank key stay out of them apart from their environment secrets, and no passphrase is ever in a repository, a chat, an issue, or a cloud service other than an end-to-end encrypted password manager. A YubiKey (firmware 5.7 or later, for Ed25519) is optional. - A second holder is added when there is a second maintainer. - `@cloudhal/release-verify` pins the root's public key, and a repository test keeps it equal to the key doc. - **`keys.json`.** diff --git a/docs/security/release-signing-keys.md b/docs/security/release-signing-keys.md index 544f044c..76fbe2b5 100644 --- a/docs/security/release-signing-keys.md +++ b/docs/security/release-signing-keys.md @@ -61,7 +61,7 @@ The first `keys.json` was signed by it on 2026-11-15 (`issuedAt` 2026-11-15T07:0 - There is one holder for now. The *encrypted* file has two sealed copies: - one in the private repository `cloudhal-dev/key-custody`, which only the owner can read. It has no Actions, no apps and no deploy keys, and it holds neither the passphrase nor a plaintext key; - one kept offline. -- The passphrase is kept apart from both copies, in a password manager and on sealed paper. +- The passphrase is kept apart from both copies, in an end-to-end encrypted password manager and on sealed paper. - That repository copy is the one exception to the rule that no key is ever in a repository or a cloud service (*Custody of the release key*). It is the encrypted root only, never its passphrase. - A YubiKey with firmware 5.7 or later, which can hold Ed25519, is an option. - A second holder is added when there is a second maintainer. @@ -113,7 +113,7 @@ The first `keys.json` was signed by it on 2026-11-15 (`issuedAt` 2026-11-15T07:0 - No other job, workflow or repository receives the key. - Outside GitHub, the maintainer holds the key. - The copies are the PKCS#8 file the key was generated into, kept readable by its owner only, and the maintainer's backups. - - Apart from their environment secrets, no copy of the release key or the yank key, and no passphrase, is ever in a repository, a chat, an issue or a cloud service. The one exception is the root's *encrypted* file, without its passphrase, in the private `cloudhal-dev/key-custody` repository (*Root keys*, Custody). + - Apart from their environment secrets, no copy of the release key or the yank key is ever in a repository, a chat, an issue or a cloud service, and no passphrase is ever in a repository, a chat, an issue, or a cloud service other than an end-to-end encrypted password manager. The one exception is the root's *encrypted* file, without its passphrase, in the private `cloudhal-dev/key-custody` repository (*Root keys*, Custody). - Once the environment secret is set, and before the first signed release, the maintainer moves the generated file to offline storage. From then on, the environment secret and the offline backups are the only copies. - KMS custody is deferred to phase 3 ("Release signing: KMS-backed signer (RFC-0046 §9 phase 3)"). It is triggered by a second maintainer, or by any exposure of this secret.