ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
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, ""));
|
||||
}
|
||||
Reference in New Issue
Block a user