Files
threadcount-community/scripts/check-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

77 lines
3.9 KiB
TypeScript

/* Check the TOTP implementation against RFC 6238's published test vectors.
*
* This is the reason it was worth hand-rolling: the algorithm has an official answer sheet, so the
* code can be *proved* right rather than trusted. The seeds and expected codes below are taken
* from RFC 6238 Appendix B.
*
* npx tsx scripts/check-totp.ts
*/
import { base32Decode, base32Encode, hotp, totp, totpVerify, newTotpSecret, encryptSecret, decryptSecret, newRecoveryCodes, hashRecoveryCode } from "../lib/totp";
let pass = 0, fail = 0;
const ok = (n: string) => { pass++; console.log(" ✓ " + n); };
const bad = (n: string, got: unknown, want: unknown) => { fail++; console.log(` ✗ ${n} :: got ${got}, want ${want}`); };
const eq = (n: string, got: unknown, want: unknown) => (String(got) === String(want) ? ok(n) : bad(n, got, want));
// RFC 6238 Appendix B. The ASCII seeds are the shared secrets; times are seconds since the epoch.
const SEED_SHA1 = Buffer.from("12345678901234567890", "ascii");
const SEED_SHA256 = Buffer.from("12345678901234567890123456789012", "ascii");
const SEED_SHA512 = Buffer.from("1234567890123456789012345678901234567890123456789012345678901234", "ascii");
const VECTORS: [number, string, string, Buffer, "sha1" | "sha256" | "sha512"][] = [
[59, "94287082", "SHA1", SEED_SHA1, "sha1"],
[59, "46119246", "SHA256", SEED_SHA256, "sha256"],
[59, "90693936", "SHA512", SEED_SHA512, "sha512"],
[1111111109, "07081804", "SHA1", SEED_SHA1, "sha1"],
[1111111111, "14050471", "SHA1", SEED_SHA1, "sha1"],
[1234567890, "89005924", "SHA1", SEED_SHA1, "sha1"],
[2000000000, "69279037", "SHA1", SEED_SHA1, "sha1"],
[20000000000, "65353130", "SHA1", SEED_SHA1, "sha1"],
];
console.log("== RFC 6238 test vectors (8 digits)");
for (const [t, expected, name, seed, algo] of VECTORS) {
eq(`t=${t} ${name}`, totp(seed, t * 1000, 8, algo), expected);
}
console.log("== base32 round-trips");
for (const s of ["", "A", "hello world", "12345678901234567890"]) {
const enc = base32Encode(Buffer.from(s, "utf8"));
eq(`"${s}"`, base32Decode(enc).toString("utf8"), s);
}
console.log("== the 8-byte counter survives past 2^32");
// t=20000000000 above already exercises this; assert the halves explicitly too.
eq("large counter differs from its low 32 bits", hotp(SEED_SHA1, 2 ** 32 + 7) !== hotp(SEED_SHA1, 7), true);
console.log("== verification window");
const secret = newTotpSecret();
const now = Date.now();
const raw = base32Decode(secret);
eq("the current code verifies", totpVerify(secret, totp(raw, now), now), true);
eq("the previous step verifies", totpVerify(secret, totp(raw, now - 30_000), now), true);
eq("the next step verifies", totpVerify(secret, totp(raw, now + 30_000), now), true);
eq("two steps back is refused", totpVerify(secret, totp(raw, now - 90_000), now), false);
eq("two steps forward is refused", totpVerify(secret, totp(raw, now + 90_000), now), false);
eq("a wrong code is refused", totpVerify(secret, "000000", now) && totp(raw, now) !== "000000", false);
eq("a short code is refused", totpVerify(secret, "123", now), false);
eq("an empty code is refused", totpVerify(secret, "", now), false);
console.log("== secret storage");
process.env.SESSION_SECRET ||= "test-secret-for-check-totp";
const enc = encryptSecret(secret);
eq("ciphertext is not the secret", enc.includes(secret), false);
eq("round-trips", decryptSecret(enc), secret);
eq("a tampered tag is rejected", decryptSecret(enc.slice(0, -4) + "AAAA"), null);
eq("rubbish is rejected", decryptSecret("nonsense"), null);
console.log("== recovery codes");
const codes = newRecoveryCodes();
eq("ten codes", codes.length, 10);
eq("all distinct", new Set(codes).size, 10);
eq("hash ignores case and dashes", hashRecoveryCode(codes[0].toLowerCase()), hashRecoveryCode(codes[0]));
eq("different codes hash differently", hashRecoveryCode(codes[0]) === hashRecoveryCode(codes[1]), false);
console.log(`\nPASS=${pass} FAIL=${fail}`);
process.exit(fail ? 1 : 0);