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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11472-a-sealed-value-names-the-key-that-sealed → main 1 commit fork
9 files
changed +267
−32
docs/ORIGINS.md +12 −2
| @@ -1056,8 +1056,18 @@ carries token material, a `state`, or a `code`. | ||
| 1056 | 1056 | - **The guard's window is a day.** An account that gains push rights to a |
| 1057 | 1057 | protected repository keeps its credential until the next refresh or the |
| 1058 | 1058 | 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. | |
| 1061 | 1071 | - **DNS rebinding is not closed.** The SSRF policy checks address _literals_; |
| 1062 | 1072 | a hostname that resolves into a private range still gets through, because |
| 1063 | 1073 | pinning the resolved address needs a connect hook Bun's `fetch` does not |
src/kernel/secret.ts +130 −24
| @@ -10,11 +10,26 @@ | ||
| 10 | 10 | * self-hosted forge has nowhere else to put it. Losing the key file costs |
| 11 | 11 | * you the tokens (reconnect the origin), never the repositories. |
| 12 | 12 | * |
| 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 | * | |
| 14 | 24 | * Nothing here ever logs, throws, or otherwise reveals a plaintext. |
| 15 | 25 | */ |
| 16 | 26 | |
| 17 | import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; | |
| 27 | import { | |
| 28 | createCipheriv, | |
| 29 | createDecipheriv, | |
| 30 | createHash, | |
| 31 | randomBytes, | |
| 32 | } from "node:crypto"; | |
| 18 | 33 | import { |
| 19 | 34 | chmodSync, |
| 20 | 35 | mkdirSync, |
| @@ -29,15 +44,77 @@ import { err, ok, type Result } from "./result.ts"; | ||
| 29 | 44 | const KEY_BYTES = 32; // AES-256 |
| 30 | 45 | const IV_BYTES = 12; // GCM standard nonce |
| 31 | 46 | 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 | } | |
| 33 | 62 | |
| 34 | 63 | /** Keys are read once per path: this is on the hot path of every sync tick. */ |
| 35 | 64 | const keyCache = new Map<string, Buffer>(); |
| 65 | const previousCache = new Map<string, NamedKey | null>(); | |
| 36 | 66 | |
| 37 | 67 | export function secretKeyPath(dataDir: string): string { |
| 38 | 68 | return join(dataDir, "secret.key"); |
| 39 | 69 | } |
| 40 | 70 | |
| 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 | ||
| 41 | 118 | /** |
| 42 | 119 | * The data directory's key, created on first use. Written with the mode set |
| 43 | 120 | * at creation AND re-applied, because umask can widen the initial mode. |
| @@ -50,26 +127,17 @@ export function loadKey(dataDir: string): Buffer { | ||
| 50 | 127 | let key: Buffer; |
| 51 | 128 | try { |
| 52 | 129 | 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); | |
| 64 | 132 | } catch (error) { |
| 65 | 133 | if (ForgeError.isInstance(error)) throw error; |
| 66 | key = rotate(path); | |
| 134 | key = mint(path); | |
| 67 | 135 | } |
| 68 | 136 | keyCache.set(path, key); |
| 69 | 137 | return key; |
| 70 | 138 | } |
| 71 | 139 | |
| 72 | function rotate(path: string): Buffer { | |
| 140 | function mint(path: string): Buffer { | |
| 73 | 141 | const key = randomBytes(KEY_BYTES); |
| 74 | 142 | mkdirSync(dirname(path), { recursive: true }); |
| 75 | 143 | writeFileSync(path, key, { mode: 0o600 }); |
| @@ -85,7 +153,8 @@ function rotate(path: string): Buffer { | ||
| 85 | 153 | */ |
| 86 | 154 | export function seal(dataDir: string, plain: string, aad: string): string { |
| 87 | 155 | 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, { | |
| 89 | 158 | authTagLength: TAG_BYTES, |
| 90 | 159 | }); |
| 91 | 160 | cipher.setAAD(Buffer.from(aad, "utf8")); |
| @@ -94,7 +163,7 @@ export function seal(dataDir: string, plain: string, aad: string): string { | ||
| 94 | 163 | cipher.final(), |
| 95 | 164 | cipher.getAuthTag(), |
| 96 | 165 | ]); |
| 97 | return `${VERSION}:${iv.toString("base64url")}:${body.toString("base64url")}`; | |
| 166 | return `${VERSION}:${keyId(key)}:${iv.toString("base64url")}:${body.toString("base64url")}`; | |
| 98 | 167 | } |
| 99 | 168 | |
| 100 | 169 | export type OpenError = "bad_format" | "bad_key"; |
| @@ -110,16 +179,52 @@ export function open( | ||
| 110 | 179 | aad: string, |
| 111 | 180 | ): Result<string, OpenError> { |
| 112 | 181 | 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"); | |
| 116 | 207 | if (iv.length !== IV_BYTES || body.length <= TAG_BYTES) |
| 117 | 208 | return err("bad_format"); |
| 118 | 209 | |
| 119 | 210 | const tag = body.subarray(body.length - TAG_BYTES); |
| 120 | 211 | 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 { | |
| 121 | 226 | try { |
| 122 | const decipher = createDecipheriv("aes-256-gcm", loadKey(dataDir), iv, { | |
| 227 | const decipher = createDecipheriv("aes-256-gcm", key, iv, { | |
| 123 | 228 | authTagLength: TAG_BYTES, |
| 124 | 229 | }); |
| 125 | 230 | decipher.setAAD(Buffer.from(aad, "utf8")); |
| @@ -128,15 +233,16 @@ export function open( | ||
| 128 | 233 | decipher.update(ciphertext), |
| 129 | 234 | decipher.final(), |
| 130 | 235 | ]); |
| 131 | return ok(plain.toString("utf8")); | |
| 236 | return plain.toString("utf8"); | |
| 132 | 237 | } catch { |
| 133 | 238 | // A failed tag check is the only outcome here, and it says nothing about |
| 134 | 239 | // the plaintext — exactly the property we want. |
| 135 | return err("bad_key"); | |
| 240 | return null; | |
| 136 | 241 | } |
| 137 | 242 | } |
| 138 | 243 | |
| 139 | 244 | /** Test seam: drop the cached key so a rewritten key file is picked up. */ |
| 140 | 245 | export function forgetKey(dataDir: string): void { |
| 141 | 246 | keyCache.delete(secretKeyPath(dataDir)); |
| 247 | previousCache.delete(previousKeyPath(dataDir)); | |
| 142 | 248 | } |
test/app-env.test.ts +1 −1
| @@ -118,7 +118,7 @@ describe("an app's environment", () => { | ||
| 118 | 118 | expect(JSON.stringify(set.value)).not.toContain("horse"); |
| 119 | 119 | |
| 120 | 120 | const sealed = stored(hello.id, "ADMIN_PASSWORD"); |
| 121 | expect(sealed).toStartWith("v1:"); | |
| 121 | expect(sealed).toStartWith("v2:"); | |
| 122 | 122 | expect(sealed).not.toContain("horse"); |
| 123 | 123 | expect(Buffer.from(sealed!, "utf8").toString("base64")).not.toContain( |
| 124 | 124 | 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 | ||
| 646 | 646 | const row = app.db |
| 647 | 647 | .query<{ value_enc: string }, []>("SELECT value_enc FROM app_env") |
| 648 | 648 | .get(); |
| 649 | expect(row?.value_enc).toStartWith("v1:"); | |
| 649 | expect(row?.value_enc).toStartWith("v2:"); | |
| 650 | 650 | expect(row?.value_enc).not.toContain(secret); |
| 651 | 651 | const event = app.db |
| 652 | 652 | .query< |
test/oidc.test.ts +1 −1
| @@ -91,7 +91,7 @@ describe("oidc keys", () => { | ||
| 91 | 91 | const rows = db.query<{ private_jwk: string }, []>("SELECT private_jwk FROM oidc_key").all(); |
| 92 | 92 | expect(rows.length).toBe(1); |
| 93 | 93 | for (const r of rows) { |
| 94 | expect(r.private_jwk.startsWith("v1:")).toBe(true); | |
| 94 | expect(r.private_jwk.startsWith("v2:")).toBe(true); | |
| 95 | 95 | expect(r.private_jwk).not.toContain('"d"'); |
| 96 | 96 | expect(() => JSON.parse(r.private_jwk)).toThrow(); |
| 97 | 97 | } |
test/origins.test.ts +1 −1
| @@ -179,7 +179,7 @@ describe("origins: connecting, mirroring, writing through", () => { | ||
| 179 | 179 | |
| 180 | 180 | test("a sealed token is unreadable, and bound to its own row", () => { |
| 181 | 181 | const sealed = seal(dataDir, "s3cret-token", "origin-a"); |
| 182 | expect(sealed).toStartWith("v1:"); | |
| 182 | expect(sealed).toStartWith("v2:"); | |
| 183 | 183 | expect(sealed).not.toContain("s3cret-token"); |
| 184 | 184 | expect(open(dataDir, sealed, "origin-a")).toEqual({ |
| 185 | 185 | ok: true, |
test/prompts.test.ts +1 −1
| @@ -596,7 +596,7 @@ describe("prompts from Instagram", () => { | ||
| 596 | 596 | >("SELECT token_enc FROM prompt_source WHERE id = ?1") |
| 597 | 597 | .get(sourceId)!; |
| 598 | 598 | 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); | |
| 600 | 600 | expect(openPromptToken(app.db, dataDir, sourceId)).toEqual({ |
| 601 | 601 | ok: true, |
| 602 | 602 | 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", () => { | ||
| 93 | 93 | const set = setSecret(app.db, dataDir, "forge", "", "CF_TOKEN", "s3cret-1"); |
| 94 | 94 | expect(set.ok).toBe(true); |
| 95 | 95 | const sealed = stored("CF_TOKEN", "forge")!; |
| 96 | expect(sealed).toStartWith("v1:"); | |
| 96 | expect(sealed).toStartWith("v2:"); | |
| 97 | 97 | expect(sealed).not.toContain("s3cret-1"); |
| 98 | 98 | const right = open(dataDir, sealed, "secret:forge:CF_TOKEN"); |
| 99 | 99 | expect(right.ok && right.value).toBe("s3cret-1"); |