From ae0b8d604a87a9f13f2b2f7344bfcd8002dbb426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 19:09:27 +0100 Subject: [PATCH] fix(recovery): fingerprint a chosen secret with scrypt, not a fast hash API_TOKEN_HMAC_KEY can be a phrase, and its fingerprint is shown on the admin page and written into dump manifests in the bucket; a fast hash there lets a guessed phrase be checked offline. Text secrets now use a scrypt fingerprint; the random encryption keys keep theirs. The kit's check of a pasted token key compares keyed digests that never leave the process. --- src/lib/database-backup/dump-core.mjs | 26 ++++++++++++++++++-- src/lib/recovery/__tests__/kit-rules.test.ts | 5 +++- src/lib/recovery/kit-rules.ts | 10 +++++--- src/lib/recovery/kit.ts | 12 ++++++--- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/lib/database-backup/dump-core.mjs b/src/lib/database-backup/dump-core.mjs index 9cb48e4c0..740921876 100644 --- a/src/lib/database-backup/dump-core.mjs +++ b/src/lib/database-backup/dump-core.mjs @@ -26,7 +26,7 @@ * writing still restores to exactly the counts it claims. */ import { spawn } from "node:child_process"; -import { createDecipheriv, createHash } from "node:crypto"; +import { createDecipheriv, createHash, scryptSync } from "node:crypto"; import { accessSync, closeSync, @@ -452,6 +452,28 @@ export function fingerprintBytes(bytes) { return createHash("sha256").update(bytes).digest("hex").slice(0, 12); } +/** + * The public fingerprint of a secret an operator chose as text, such as + * `API_TOKEN_HMAC_KEY`. Unlike the encryption keys, which are 32 random bytes, + * such a value may be a phrase, and a fast hash of it shown on the admin page + * or written into a manifest in the bucket would let a guessed phrase be + * checked offline. scrypt makes every guess cost tens of milliseconds; the + * fixed salt keeps one fingerprint per value, which is what a fingerprint is + * for. `src/lib/recovery/kit-rules.ts` uses this same function. + * + * @param {string} value + */ +export function fingerprintSecretText(value) { + return scryptSync(value, "healthlog/secret-fingerprint/v1", 16, { + N: 2 ** 15, + r: 8, + p: 1, + maxmem: 64 * 1024 * 1024, + }) + .toString("hex") + .slice(0, 12); +} + /** * @typedef {{ * encryption: { @@ -520,7 +542,7 @@ export function keyFingerprints(env = process.env) { backupEncryptionPreviousKeys: previous, // Used as a string by the app (`createHmac("sha256", key)`), so its // fingerprint is over the string's bytes. - apiTokenHmacKey: hmac ? fingerprintBytes(Buffer.from(hmac, "utf8")) : null, + apiTokenHmacKey: hmac ? fingerprintSecretText(hmac) : null, }; } diff --git a/src/lib/recovery/__tests__/kit-rules.test.ts b/src/lib/recovery/__tests__/kit-rules.test.ts index 19636e3c3..f58267ffc 100644 --- a/src/lib/recovery/__tests__/kit-rules.test.ts +++ b/src/lib/recovery/__tests__/kit-rules.test.ts @@ -23,6 +23,7 @@ import { tokenKitKeys, type KitEntry, } from "@/lib/recovery/kit-rules"; +import { fingerprintSecretText } from "@/lib/database-backup/dump-core.mjs"; const NOW = new Date("2026-10-25T12:00:00Z"); const DAY = 86_400_000; @@ -163,10 +164,12 @@ describe("which keys the kit lists", () => { it("fingerprints the token key as the text the operator set", () => { const value = "x".repeat(40); const [token] = tokenKitKeys(value); - expect(token.fingerprint).toBe( + // Not a fast hash: a phrase must not be checkable against the kit. + expect(token.fingerprint).not.toBe( createHash("sha256").update(value).digest("hex").slice(0, 12), ); expect(token.fingerprint).toBe(fingerprintText(value)); + expect(token.fingerprint).toBe(fingerprintSecretText(value)); expect(JSON.stringify(token)).not.toContain(value); }); }); diff --git a/src/lib/recovery/kit-rules.ts b/src/lib/recovery/kit-rules.ts index a59b50882..f33a90e73 100644 --- a/src/lib/recovery/kit-rules.ts +++ b/src/lib/recovery/kit-rules.ts @@ -28,12 +28,12 @@ * hashed as the text the operator set, so `printf %s "$API_TOKEN_HMAC_KEY" | * sha256sum` reproduces it. */ -import { createHash } from "node:crypto"; import { z } from "zod/v4"; import { envValue } from "@/lib/env"; import { decodeBackupKey, offhostKeyId } from "@/lib/jobs/offhost-key"; import type { BackupKeyNeeds } from "@/lib/recovery/backup-key-needs"; +import { fingerprintSecretText } from "@/lib/database-backup/dump-core.mjs"; /** Where the operator sets the keys; picks the tab the kit opens on. */ export const KIT_PLATFORMS = [ @@ -127,9 +127,13 @@ export function resolvePlatformHint( : "compose"; } -/** The fingerprint rule for a key that is text rather than decoded bytes. */ +/** + * The fingerprint rule for a key that is text rather than decoded bytes: the + * slow one the dump manifest uses (`fingerprintSecretText`), so a phrase + * cannot be guessed from what the kit shows. + */ export function fingerprintText(value: string): string { - return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 12); + return fingerprintSecretText(value); } const kitEntrySchema = z.object({ diff --git a/src/lib/recovery/kit.ts b/src/lib/recovery/kit.ts index 194b4c898..0141858e9 100644 --- a/src/lib/recovery/kit.ts +++ b/src/lib/recovery/kit.ts @@ -3,7 +3,7 @@ * stored confirmation covers, recording a confirmation, and "Check my copy". * The rules themselves are pure and live in `kit-rules.ts`. */ -import { createHash, timingSafeEqual } from "node:crypto"; +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { prisma } from "@/lib/db"; import { envValue } from "@/lib/env"; @@ -30,6 +30,9 @@ import { type RecoveryKit, } from "@/lib/recovery/kit-rules"; +/** Keys the constant-time comparison below; never leaves the process. */ +const COMPARE_KEY = randomBytes(32); + /** Every key the kit lists on this server right now, without a verdict. */ export async function listKitKeys( now: Date = new Date(), @@ -165,8 +168,11 @@ export function verifyKitKey(input: { case "token": { const configured = envValue("API_TOKEN_HMAC_KEY"); if (!configured || input.id !== KIT_KEY_SOURCES.token) return false; - // Digests, so the comparison is constant time whatever the lengths. - const digest = (s: string) => createHash("sha256").update(s).digest(); + // Keyed digests under a key of this process, so the comparison is + // constant time whatever the lengths and nothing derived from the + // secret outlives the call. + const digest = (s: string) => + createHmac("sha256", COMPARE_KEY).update(s).digest(); return timingSafeEqual(digest(value), digest(configured)); } }