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,
};
}