// 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]));
});