mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
135d5baf28
commit
ae0b8d604a
4 files changed
+44
-9
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user