Public: anyone can read this; the crew can change it.

A sealed value names the key that sealed it, so secret.key can rotate and open still reads the old one #11488

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/11472-a-sealed-value-names-the-key-that-sealed → main 1 commit fork

  • Merged. torque/11472-a-sealed-value-names-the-key-that-sealed was merged into main as 2badb00.

Review

Sign in to review this change.
9 files changed +267 −32
docs/ORIGINS.md +12 −2
@@ -1056,8 +1056,18 @@ carries token material, a `state`, or a `code`.
10561056 - **The guard's window is a day.** An account that gains push rights to a
10571057 protected repository keeps its credential until the next refresh or the
10581058 next daily sweep, whichever is first.
1059 - **Key rotation is not built.** The sealed blob carries a `v1` version tag
1060 so it can be, but losing `secret.key` today means reconnecting the origins.
1059 - **Key rotation keeps one key behind.** A sealed value is
1060 `v2:<keyid>:<iv>:<ciphertext>`, keyid the first 8 hex of SHA-256 of the key
1061 that sealed it. To rotate, move `secret.key` to `secret.key.old` (keep it
1062 0600 — the forge refuses either file wider than that) and restart: a fresh
1063 `secret.key` is minted, new values seal under it, and `open` uses whichever
1064 key a value names. Values from before the format change (`v1:<iv>:<ct>`)
1065 name no key and try the current key, then the old one. Nothing re-seals old
1066 values yet, so `secret.key.old` must stay until every token has been
1067 rewritten (reconnecting an origin does it); delete it and those values come
1068 back `bad_key`, as losing `secret.key` did before. A snapshot carries only
1069 `secret.key`, and the HMAC keys derived from it (passports, visitor links)
1070 change with it.
10611071 - **DNS rebinding is not closed.** The SSRF policy checks address _literals_;
10621072 a hostname that resolves into a private range still gets through, because
10631073 pinning the resolved address needs a connect hook Bun's `fetch` does not
src/kernel/secret.ts +130 −24
@@ -10,11 +10,26 @@
1010 * self-hosted forge has nowhere else to put it. Losing the key file costs
1111 * you the tokens (reconnect the origin), never the repositories.
1212 *
13 * Sealed values are opaque strings: `v1:<iv>:<ciphertext+tag>`, base64url.
13 * Sealed values are opaque strings, base64url:
14 * `v2:<keyid>:<iv>:<ciphertext+tag>` — written today; keyid is the first
15 * 8 hex of SHA-256(key), so a value names the key that sealed it;
16 * `v1:<iv>:<ciphertext+tag>` — written before rotation existed; still read.
17 *
18 * Rotation: the data directory may hold a previous key beside the current
19 * one (`secret.key.old`, also 0600). `open` uses whichever key a v2 value
20 * names; a v1 value tries the current key, then the previous one. Moving
21 * `secret.key` to `secret.key.old` and restarting mints a fresh current key
22 * while everything sealed under the old one keeps opening.
23 *
1424 * Nothing here ever logs, throws, or otherwise reveals a plaintext.
1525 */
1626
17 import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
27 import {
28 createCipheriv,
29 createDecipheriv,
30 createHash,
31 randomBytes,
32 } from "node:crypto";
1833 import {
1934 chmodSync,
2035 mkdirSync,
@@ -29,15 +44,77 @@ import { err, ok, type Result } from "./result.ts";
2944 const KEY_BYTES = 32; // AES-256
3045 const IV_BYTES = 12; // GCM standard nonce
3146 const TAG_BYTES = 16;
32 const VERSION = "v1";
47 const VERSION = "v2";
48 const LEGACY = "v1";
49 const KEY_ID = /^[0-9a-f]{8}$/;
50
51 /** A key and the id a sealed value names it by. */
52 export interface NamedKey {
53 readonly id: string;
54 readonly key: Buffer;
55 }
56
57 /** The keys `open` may use: the current one seals, the previous only opens. */
58 export interface KeyRing {
59 readonly current: NamedKey;
60 readonly previous: NamedKey | null;
61 }
3362
3463 /** Keys are read once per path: this is on the hot path of every sync tick. */
3564 const keyCache = new Map<string, Buffer>();
65 const previousCache = new Map<string, NamedKey | null>();
3666
3767 export function secretKeyPath(dataDir: string): string {
3868 return join(dataDir, "secret.key");
3969 }
4070
71 export function previousKeyPath(dataDir: string): string {
72 return join(dataDir, "secret.key.old");
73 }
74
75 /** First 8 hex of SHA-256(key): names a key without revealing it. */
76 export function keyId(key: Buffer): string {
77 return createHash("sha256").update(key).digest("hex").slice(0, 8);
78 }
79
80 // Fail closed rather than silently tightening it: a key file the rest of
81 // the machine can read has already been readable for however long it was
82 // wrong, and quietly chmod-ing it would hide that from the operator.
83 function refuseIfOpen(path: string): void {
84 const mode = statSync(path).mode & 0o777;
85 if ((mode & 0o077) !== 0) {
86 defect(
87 "secret.key_too_open",
88 `${path} is mode ${mode.toString(8)}; it must be 0600 — fix the permissions and restart`,
89 );
90 }
91 }
92
93 /**
94 * The previous key, when the operator kept one. Never created: an absent or
95 * malformed `secret.key.old` simply means there is nothing older to open.
96 */
97 function loadPrevious(dataDir: string): NamedKey | null {
98 const path = previousKeyPath(dataDir);
99 if (previousCache.has(path)) return previousCache.get(path)!;
100 let previous: NamedKey | null = null;
101 try {
102 const raw = readFileSync(path);
103 refuseIfOpen(path);
104 if (raw.length === KEY_BYTES) previous = { id: keyId(raw), key: raw };
105 } catch (error) {
106 if (ForgeError.isInstance(error)) throw error;
107 }
108 previousCache.set(path, previous);
109 return previous;
110 }
111
112 /** The current key and, when kept, the previous one. */
113 export function loadKeys(dataDir: string): KeyRing {
114 const key = loadKey(dataDir);
115 return { current: { id: keyId(key), key }, previous: loadPrevious(dataDir) };
116 }
117
41118 /**
42119 * The data directory's key, created on first use. Written with the mode set
43120 * at creation AND re-applied, because umask can widen the initial mode.
@@ -50,26 +127,17 @@ export function loadKey(dataDir: string): Buffer {
50127 let key: Buffer;
51128 try {
52129 const raw = readFileSync(path);
53 // Fail closed rather than silently tightening it: a key file the rest of
54 // the machine can read has already been readable for however long it was
55 // wrong, and quietly chmod-ing it would hide that from the operator.
56 const mode = statSync(path).mode & 0o777;
57 if ((mode & 0o077) !== 0) {
58 defect(
59 "secret.key_too_open",
60 `${path} is mode ${mode.toString(8)}; it must be 0600 — fix the permissions and restart`,
61 );
62 }
63 key = raw.length === KEY_BYTES ? raw : rotate(path);
130 refuseIfOpen(path);
131 key = raw.length === KEY_BYTES ? raw : mint(path);
64132 } catch (error) {
65133 if (ForgeError.isInstance(error)) throw error;
66 key = rotate(path);
134 key = mint(path);
67135 }
68136 keyCache.set(path, key);
69137 return key;
70138 }
71139
72 function rotate(path: string): Buffer {
140 function mint(path: string): Buffer {
73141 const key = randomBytes(KEY_BYTES);
74142 mkdirSync(dirname(path), { recursive: true });
75143 writeFileSync(path, key, { mode: 0o600 });
@@ -85,7 +153,8 @@ function rotate(path: string): Buffer {
85153 */
86154 export function seal(dataDir: string, plain: string, aad: string): string {
87155 const iv = randomBytes(IV_BYTES);
88 const cipher = createCipheriv("aes-256-gcm", loadKey(dataDir), iv, {
156 const key = loadKey(dataDir);
157 const cipher = createCipheriv("aes-256-gcm", key, iv, {
89158 authTagLength: TAG_BYTES,
90159 });
91160 cipher.setAAD(Buffer.from(aad, "utf8"));
@@ -94,7 +163,7 @@ export function seal(dataDir: string, plain: string, aad: string): string {
94163 cipher.final(),
95164 cipher.getAuthTag(),
96165 ]);
97 return `${VERSION}:${iv.toString("base64url")}:${body.toString("base64url")}`;
166 return `${VERSION}:${keyId(key)}:${iv.toString("base64url")}:${body.toString("base64url")}`;
98167 }
99168
100169 export type OpenError = "bad_format" | "bad_key";
@@ -110,16 +179,52 @@ export function open(
110179 aad: string,
111180 ): Result<string, OpenError> {
112181 const parts = sealed.split(":");
113 if (parts.length !== 3 || parts[0] !== VERSION) return err("bad_format");
114 const iv = Buffer.from(parts[1]!, "base64url");
115 const body = Buffer.from(parts[2]!, "base64url");
182 let candidates: Buffer[];
183 let ivText: string;
184 let bodyText: string;
185 if (parts.length === 3 && parts[0] === LEGACY) {
186 // A v1 value does not say which key sealed it: the current one first,
187 // then the previous, so a rotation does not strand values from before.
188 const ring = loadKeys(dataDir);
189 candidates = ring.previous
190 ? [ring.current.key, ring.previous.key]
191 : [ring.current.key];
192 ivText = parts[1]!;
193 bodyText = parts[2]!;
194 } else if (parts.length === 4 && parts[0] === VERSION && KEY_ID.test(parts[1]!)) {
195 const ring = loadKeys(dataDir);
196 const named = [ring.current, ring.previous].find((k) => k?.id === parts[1]);
197 // The value names a key this directory no longer holds.
198 if (!named) return err("bad_key");
199 candidates = [named.key];
200 ivText = parts[2]!;
201 bodyText = parts[3]!;
202 } else {
203 return err("bad_format");
204 }
205 const iv = Buffer.from(ivText, "base64url");
206 const body = Buffer.from(bodyText, "base64url");
116207 if (iv.length !== IV_BYTES || body.length <= TAG_BYTES)
117208 return err("bad_format");
118209
119210 const tag = body.subarray(body.length - TAG_BYTES);
120211 const ciphertext = body.subarray(0, body.length - TAG_BYTES);
212 for (const key of candidates) {
213 const plain = decrypt(key, iv, ciphertext, tag, aad);
214 if (plain !== null) return ok(plain);
215 }
216 return err("bad_key");
217 }
218
219 function decrypt(
220 key: Buffer,
221 iv: Buffer,
222 ciphertext: Buffer,
223 tag: Buffer,
224 aad: string,
225 ): string | null {
121226 try {
122 const decipher = createDecipheriv("aes-256-gcm", loadKey(dataDir), iv, {
227 const decipher = createDecipheriv("aes-256-gcm", key, iv, {
123228 authTagLength: TAG_BYTES,
124229 });
125230 decipher.setAAD(Buffer.from(aad, "utf8"));
@@ -128,15 +233,16 @@ export function open(
128233 decipher.update(ciphertext),
129234 decipher.final(),
130235 ]);
131 return ok(plain.toString("utf8"));
236 return plain.toString("utf8");
132237 } catch {
133238 // A failed tag check is the only outcome here, and it says nothing about
134239 // the plaintext — exactly the property we want.
135 return err("bad_key");
240 return null;
136241 }
137242 }
138243
139244 /** Test seam: drop the cached key so a rewritten key file is picked up. */
140245 export function forgetKey(dataDir: string): void {
141246 keyCache.delete(secretKeyPath(dataDir));
247 previousCache.delete(previousKeyPath(dataDir));
142248 }
test/app-env.test.ts +1 −1
@@ -118,7 +118,7 @@ describe("an app's environment", () => {
118118 expect(JSON.stringify(set.value)).not.toContain("horse");
119119
120120 const sealed = stored(hello.id, "ADMIN_PASSWORD");
121 expect(sealed).toStartWith("v1:");
121 expect(sealed).toStartWith("v2:");
122122 expect(sealed).not.toContain("horse");
123123 expect(Buffer.from(sealed!, "utf8").toString("base64")).not.toContain(
124124 Buffer.from(secret).toString("base64"),
test/apps-api.test.ts +1 −1
@@ -646,7 +646,7 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build it\\",\\"che
646646 const row = app.db
647647 .query<{ value_enc: string }, []>("SELECT value_enc FROM app_env")
648648 .get();
649 expect(row?.value_enc).toStartWith("v1:");
649 expect(row?.value_enc).toStartWith("v2:");
650650 expect(row?.value_enc).not.toContain(secret);
651651 const event = app.db
652652 .query<
test/oidc.test.ts +1 −1
@@ -91,7 +91,7 @@ describe("oidc keys", () => {
9191 const rows = db.query<{ private_jwk: string }, []>("SELECT private_jwk FROM oidc_key").all();
9292 expect(rows.length).toBe(1);
9393 for (const r of rows) {
94 expect(r.private_jwk.startsWith("v1:")).toBe(true);
94 expect(r.private_jwk.startsWith("v2:")).toBe(true);
9595 expect(r.private_jwk).not.toContain('"d"');
9696 expect(() => JSON.parse(r.private_jwk)).toThrow();
9797 }
test/origins.test.ts +1 −1
@@ -179,7 +179,7 @@ describe("origins: connecting, mirroring, writing through", () => {
179179
180180 test("a sealed token is unreadable, and bound to its own row", () => {
181181 const sealed = seal(dataDir, "s3cret-token", "origin-a");
182 expect(sealed).toStartWith("v1:");
182 expect(sealed).toStartWith("v2:");
183183 expect(sealed).not.toContain("s3cret-token");
184184 expect(open(dataDir, sealed, "origin-a")).toEqual({
185185 ok: true,
test/prompts.test.ts +1 −1
@@ -596,7 +596,7 @@ describe("prompts from Instagram", () => {
596596 >("SELECT token_enc FROM prompt_source WHERE id = ?1")
597597 .get(sourceId)!;
598598 expect(row.token_enc).not.toContain(TOKEN);
599 expect(row.token_enc.startsWith("v1:")).toBe(true);
599 expect(row.token_enc.startsWith("v2:")).toBe(true);
600600 expect(openPromptToken(app.db, dataDir, sourceId)).toEqual({
601601 ok: true,
602602 value: TOKEN,
test/secret-rotation.test.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * Key rotation: a v2 value names the key that sealed it (first 8 hex of
3 * SHA-256(key)), so `secret.key` can be replaced while the old key is kept as
4 * `secret.key.old`, and every value sealed before still opens.
5 */
6
7 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8 import { createCipheriv, createHash, randomBytes } from "node:crypto";
9 import { chmodSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import {
13 forgetKey,
14 keyId,
15 loadKey,
16 loadKeys,
17 open,
18 previousKeyPath,
19 seal,
20 secretKeyPath,
21 } from "../src/kernel/secret.ts";
22
23 /** A v1 value exactly as the old `seal` wrote it: `v1:<iv>:<ct+tag>`. */
24 function sealV1(key: Buffer, plain: string, aad: string): string {
25 const iv = randomBytes(12);
26 const c = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
27 c.setAAD(Buffer.from(aad, "utf8"));
28 const body = Buffer.concat([c.update(plain, "utf8"), c.final(), c.getAuthTag()]);
29 return `v1:${iv.toString("base64url")}:${body.toString("base64url")}`;
30 }
31
32 /** The operator's rotation: keep the current key as old, mint a new one. */
33 function rotate(dir: string): void {
34 renameSync(secretKeyPath(dir), previousKeyPath(dir));
35 forgetKey(dir);
36 }
37
38 describe("secret key rotation", () => {
39 let dir = "";
40 beforeEach(() => {
41 dir = mkdtempSync(join(tmpdir(), "forge-secret-rotation-"));
42 });
43 afterEach(() => {
44 forgetKey(dir);
45 rmSync(dir, { recursive: true, force: true });
46 });
47
48 test("seal writes v2:<keyid>:<iv>:<ct>, keyid the first 8 hex of SHA-256(key)", () => {
49 const key = loadKey(dir);
50 const sealed = seal(dir, "tok-1", "row-1");
51 const parts = sealed.split(":");
52 expect(parts.length).toBe(4);
53 expect(parts[0]).toBe("v2");
54 // Hand calculation: hash the key bytes and take 8 hex characters.
55 expect(parts[1]).toBe(createHash("sha256").update(key).digest("hex").slice(0, 8));
56 expect(keyId(key)).toBe(parts[1]!);
57 expect(sealed).not.toContain("tok-1");
58 });
59
60 test("a value sealed under A opens after B is current with A kept old, and is bad_key once A is gone", () => {
61 const a = loadKey(dir);
62 const sealed = seal(dir, "tok-A", "row-1");
63 rotate(dir);
64 const ring = loadKeys(dir);
65 expect(ring.previous?.id).toBe(keyId(a));
66 expect(ring.current.id).not.toBe(keyId(a));
67 const opened = open(dir, sealed, "row-1");
68 expect(opened.ok && opened.value).toBe("tok-A");
69 // New values go under B.
70 expect(seal(dir, "tok-B", "row-2").split(":")[1]).toBe(ring.current.id);
71 // The AAD still binds under the previous key.
72 expect(open(dir, sealed, "row-2")).toMatchObject({ ok: false, error: "bad_key" });
73
74 rmSync(previousKeyPath(dir));
75 forgetKey(dir);
76 const gone = open(dir, sealed, "row-1");
77 expect(gone.ok).toBe(false);
78 if (!gone.ok) expect(gone.error).toBe("bad_key");
79 });
80
81 test("a v1 value from before still opens, under the current key and after a rotation", () => {
82 const a = loadKey(dir);
83 const legacy = sealV1(a, "tok-v1", "row-1");
84 const now = open(dir, legacy, "row-1");
85 expect(now.ok && now.value).toBe("tok-v1");
86 rotate(dir);
87 const later = open(dir, legacy, "row-1");
88 expect(later.ok && later.value).toBe("tok-v1");
89 rmSync(previousKeyPath(dir));
90 forgetKey(dir);
91 const gone = open(dir, legacy, "row-1");
92 expect(!gone.ok && gone.error).toBe("bad_key");
93 });
94
95 test("a malformed value is bad_format, and an unknown key id is bad_key", () => {
96 loadKey(dir);
97 const sealed = seal(dir, "tok", "row");
98 const [, , iv, body] = sealed.split(":");
99 expect(open(dir, `v2:XYZ:${iv}:${body}`, "row")).toMatchObject({ ok: false, error: "bad_format" });
100 expect(open(dir, `v2:${iv}:${body}`, "row")).toMatchObject({ ok: false, error: "bad_format" });
101 expect(open(dir, `v3:00000000:${iv}:${body}`, "row")).toMatchObject({ ok: false, error: "bad_format" });
102 expect(open(dir, `v2:00000000:${iv}:${body}`, "row")).toMatchObject({ ok: false, error: "bad_key" });
103 });
104
105 test("a previous key readable by others is refused, as the current one is", () => {
106 loadKey(dir);
107 writeFileSync(previousKeyPath(dir), randomBytes(32), { mode: 0o644 });
108 chmodSync(previousKeyPath(dir), 0o644);
109 forgetKey(dir);
110 expect(() => loadKeys(dir)).toThrow(/0600/);
111 });
112
113 test("a previous key of the wrong length is ignored, never used", () => {
114 loadKey(dir);
115 writeFileSync(previousKeyPath(dir), randomBytes(7), { mode: 0o600 });
116 forgetKey(dir);
117 expect(loadKeys(dir).previous).toBeNull();
118 });
119 });
test/secret-store.test.ts +1 −1
@@ -93,7 +93,7 @@ describe("forge-level secrets", () => {
9393 const set = setSecret(app.db, dataDir, "forge", "", "CF_TOKEN", "s3cret-1");
9494 expect(set.ok).toBe(true);
9595 const sealed = stored("CF_TOKEN", "forge")!;
96 expect(sealed).toStartWith("v1:");
96 expect(sealed).toStartWith("v2:");
9797 expect(sealed).not.toContain("s3cret-1");
9898 const right = open(dataDir, sealed, "secret:forge:CF_TOKEN");
9999 expect(right.ok && right.value).toBe("s3cret-1");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs