Files
threadcount-community/lib/ops/totp.ts
T
ThreadCount 344b1701dd ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:54:35 +10:00

45 lines
2.1 KiB
TypeScript

import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
import { totpVerify } from "../totp";
/* An operator's second factor, stored under the console's own key.
*
* lib/totp.ts encrypts a coordinator's TOTP secret with a key derived from SESSION_SECRET, and
* that is the right blast radius there: rotating the product's secret invalidates customer
* sessions and customer second factors together. It is the wrong blast radius here. An
* operator's secret is kept under a key derived from OPS_SESSION_SECRET, so rotating either
* secret touches only its own side. The cipher, the format and the verification arithmetic are
* the product's own — nothing new is invented, only the key underneath it. */
function key(): Buffer {
const s = process.env.OPS_SESSION_SECRET;
if (!s) throw new Error("OPS_SESSION_SECRET is required to store an operator's TOTP secret");
return createHash("sha256").update(`ops-totp:${s}`).digest();
}
export function encryptOpsSecret(plain: string): string {
const iv = randomBytes(12);
const c = createCipheriv("aes-256-gcm", key(), iv);
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${enc.toString("base64url")}`;
}
export function decryptOpsSecret(stored: string): string | null {
try {
const [v, iv, tag, enc] = stored.split(".");
if (v !== "v1") return null;
const d = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url"));
d.setAuthTag(Buffer.from(tag, "base64url"));
return Buffer.concat([d.update(Buffer.from(enc, "base64url")), d.final()]).toString("utf8");
} catch {
return null;
}
}
/** True only when the operator has a second factor enrolled AND the code matches it. */
export function verifyOperatorCode(op: { totpSecret: string; totpEnabledAt: Date | null }, code: string): boolean {
if (!op.totpEnabledAt) return false;
const secret = decryptOpsSecret(op.totpSecret);
if (!secret) return false;
return totpVerify(secret, String(code || "").replace(/\s+/g, ""));
}