ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
/* 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);
|
||||
Reference in New Issue
Block a user