ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
/* Time-based one-time passwords (RFC 6238), and the encryption that keeps the secrets from being
* useful in a database dump.
*
* Hand-rolled rather than pulled from a package because TOTP is small, exactly specified, and
* testable against the RFC's own vectors — scripts/check-totp.ts does precisely that. A dependency
* here would be more code, not less, and one that has to be trusted rather than checked.
*
* The secret is stored encrypted. A one-time-password secret sitting in plaintext is a second
* factor that a single leaked pg_dump quietly removes for every account at once. */
const STEP = 30; // seconds per code, per the RFC and every authenticator app
const DIGITS = 6;
/** Accept the neighbouring windows: phone clocks drift, and a code typed as it rolls over is not
* an attack. One step either way is the usual compromise — 90 seconds of validity in total. */
const SKEW = 1;
/* ---------- base32, because that is what authenticator apps consume ---------- */
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function base32Encode(buf: Buffer): string {
let bits = 0, value = 0, out = "";
for (const byte of buf) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; }
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
export function base32Decode(s: string): Buffer {
const clean = s.toUpperCase().replace(/[^A-Z2-7]/g, "");
let bits = 0, value = 0;
const out: number[] = [];
for (const c of clean) {
const idx = B32.indexOf(c);
if (idx < 0) continue;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; }
}
return Buffer.from(out);
}
/* ---------- the algorithm ---------- */
/** HOTP: HMAC of the counter, then the RFC's dynamic truncation. */
export function hotp(secret: Buffer, counter: number, digits = DIGITS, algo: "sha1" | "sha256" | "sha512" = "sha1"): string {
const buf = Buffer.alloc(8);
// Counters exceed 32 bits eventually; write as two halves rather than lose the top bits.
buf.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
buf.writeUInt32BE(counter >>> 0, 4);
const mac = createHmac(algo, secret).update(buf).digest();
const offset = mac[mac.length - 1] & 0x0f;
const bin = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3];
return String(bin % 10 ** digits).padStart(digits, "0");
}
export function totp(secret: Buffer, at = Date.now(), digits = DIGITS, algo: "sha1" | "sha256" | "sha512" = "sha1"): string {
return hotp(secret, Math.floor(at / 1000 / STEP), digits, algo);
}
/** True if `code` is valid now or within one step either side. Constant-time per candidate. */
export function totpVerify(secretB32: string, code: string, at = Date.now()): boolean {
const cleaned = (code || "").replace(/\D/g, "");
if (cleaned.length !== DIGITS) return false;
const secret = base32Decode(secretB32);
if (!secret.length) return false;
const counter = Math.floor(at / 1000 / STEP);
const given = Buffer.from(cleaned, "utf8");
let match = false;
for (let w = -SKEW; w <= SKEW; w++) {
const expect = Buffer.from(hotp(secret, counter + w), "utf8");
// No early exit: every window is compared so the time taken says nothing about which matched.
if (expect.length === given.length && timingSafeEqual(expect, given)) match = true;
}
return match;
}
/** 20 bytes, the RFC's recommendation for SHA-1. */
export function newTotpSecret(): string {
return base32Encode(randomBytes(20));
}
/** The URI an authenticator app expects behind a QR code. */
export function otpauthUrl(secretB32: string, account: string, issuer = "ThreadCount"): string {
const label = encodeURIComponent(`${issuer}:${account}`);
const q = new URLSearchParams({ secret: secretB32, issuer, algorithm: "SHA1", digits: String(DIGITS), period: String(STEP) });
return `otpauth://totp/${label}?${q.toString()}`;
}
/* ---------- storage ---------- */
/** Key derived from SESSION_SECRET, so there is no new secret to manage or lose. Rotating
* SESSION_SECRET invalidates stored TOTP secrets as well as sessions — which is the correct
* blast radius for that action, and is why recovery codes exist. */
function key(): Buffer {
const s = process.env.SESSION_SECRET;
if (!s) throw new Error("SESSION_SECRET is required to store a TOTP secret");
return createHash("sha256").update(`totp:${s}`).digest();
}
export function encryptSecret(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 decryptSecret(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;
}
}
/* ---------- recovery codes ---------- */
/** Ten codes, shown once. Without these, a lost phone means a locked-out admin and — because
* deleting the last admin deletes the facility — potentially a lost facility. */
export function newRecoveryCodes(n = 10): string[] {
return Array.from({ length: n }, () => {
const raw = randomBytes(5).toString("hex").toUpperCase(); // 10 hex characters
return `${raw.slice(0, 5)}-${raw.slice(5)}`;
});
}
export const hashRecoveryCode = (code: string) =>
createHash("sha256").update(code.toUpperCase().replace(/[^A-Z0-9]/g, "")).digest("hex");