// Run with `bun test`. import assert from "node:assert/strict"; import { test } from "node:test"; import { base32Decode, base32Encode, hashRecoveryCode, hotp, matchTotp, newRecoveryCodes, newTotpSecret, otpauthUri, totpStep } from "./totp"; // The RFC 6238 test key for SHA-1, and its 8-digit codes from Appendix B. const RFC_KEY = Buffer.from("12345678901234567890"); const RFC_SECRET = base32Encode(RFC_KEY); test("base32 round-trips and matches RFC 4648", () => { assert.equal(base32Encode(Buffer.from("foobar")), "MZXW6YTBOI"); assert.equal(base32Decode("MZXW6YTBOI").toString(), "foobar"); assert.equal(base32Decode("mzxw 6ytb-oi======").toString(), "foobar"); const secret = newTotpSecret(); assert.equal(secret.length, 32); assert.equal(base32Encode(base32Decode(secret)), secret); assert.throws(() => base32Decode("not base32!")); }); test("HOTP and TOTP match the RFC test vectors", () => { // RFC 4226 Appendix D assert.deepEqual([0, 1, 2, 9].map((c) => hotp(RFC_KEY, c)), ["755224", "287082", "359152", "520489"]); // RFC 6238 Appendix B (SHA-1) const vectors: [number, string][] = [ [59, "94287082"], [1111111109, "07081804"], [1234567890, "89005924"], [2000000000, "69279037"], [20000000000, "65353130"], ]; for (const [seconds, code] of vectors) assert.equal(hotp(RFC_KEY, totpStep(seconds * 1000), 8), code, String(seconds)); }); test("codes count for their own time step and the ones next to it", () => { const now = 1_111_111_109_000; const step = totpStep(now); assert.equal(matchTotp(RFC_SECRET, hotp(RFC_KEY, step), now), step); assert.equal(matchTotp(RFC_SECRET, hotp(RFC_KEY, step - 1), now), step - 1); assert.equal(matchTotp(RFC_SECRET, hotp(RFC_KEY, step + 1), now), step + 1); assert.equal(matchTotp(RFC_SECRET, hotp(RFC_KEY, step - 2), now), null); assert.equal(matchTotp(RFC_SECRET, "12345", now), null); assert.equal(matchTotp(RFC_SECRET, "abcdef", now), null); }); test("otpauth URIs name the issuer and account", () => { const uri = new URL(otpauthUri("JBSWY3DPEHPK3PXP", "Vectrum", "alice")); assert.equal(uri.protocol, "otpauth:"); assert.equal(uri.host, "totp"); assert.equal(decodeURIComponent(uri.pathname), "/Vectrum:alice"); assert.equal(uri.searchParams.get("secret"), "JBSWY3DPEHPK3PXP"); assert.equal(uri.searchParams.get("issuer"), "Vectrum"); }); test("recovery codes are unique, and their hashes ignore case, spaces and hyphens", () => { const codes = newRecoveryCodes(); assert.equal(codes.length, 10); assert.equal(new Set(codes).size, 10); for (const code of codes) assert.match(code, /^[a-z2-7]{5}-[a-z2-7]{5}$/); assert.equal(hashRecoveryCode(codes[0]), hashRecoveryCode(` ${codes[0].toUpperCase().replace("-", "")} `)); assert.notEqual(hashRecoveryCode(codes[0]), hashRecoveryCode(codes[1])); });