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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 19:09:27 +01:00
1 parent 135d5baf28
commit ae0b8d604a
4 files changed
+44 -9

No files matched your search

+24 -2
View File
@@ -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,
};
}
+4 -1
View File
@@ -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);
});
});
+7 -3
View File
@@ -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({
+9 -3
View File
@@ -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));
}
}