344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
45 lines
2.1 KiB
TypeScript
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, ""));
|
|
}
|