109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { hotp } from "./hotp.js";
|
|
|
|
const ENCODER = new TextEncoder();
|
|
// RFC 4226 Appendix D shared secret: ASCII "12345678901234567890".
|
|
const RFC4226_SECRET = ENCODER.encode("12345678901234567890");
|
|
|
|
// RFC 4226 Appendix D test vectors (counter, expected 6-digit HOTP at SHA-1).
|
|
const RFC4226_VECTORS: ReadonlyArray<[bigint, string]> = [
|
|
[0n, "755224"],
|
|
[1n, "287082"],
|
|
[2n, "359152"],
|
|
[3n, "969429"],
|
|
[4n, "338314"],
|
|
[5n, "254676"],
|
|
[6n, "287922"],
|
|
[7n, "162583"],
|
|
[8n, "399871"],
|
|
[9n, "520489"],
|
|
];
|
|
|
|
// RFC 6238 Appendix B uses a wider 32 / 64-byte secret for SHA-256 / SHA-512.
|
|
const RFC6238_SHA256_SECRET = ENCODER.encode(
|
|
"12345678901234567890123456789012"
|
|
);
|
|
const RFC6238_SHA512_SECRET = ENCODER.encode(
|
|
"1234567890123456789012345678901234567890123456789012345678901234"
|
|
);
|
|
|
|
describe("hotp — RFC 4226 Appendix D vectors", () => {
|
|
for (const [counter, expected] of RFC4226_VECTORS) {
|
|
it(`RFC 4226 vector counter=${counter}`, { timeout: 5000 }, () => {
|
|
expect(hotp(RFC4226_SECRET, counter)).toBe(expected);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("hotp — algorithm widening", () => {
|
|
it("SHA-256 produces 8-digit code distinct from SHA-1", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const a = hotp(RFC6238_SHA256_SECRET, 1n, {
|
|
algorithm: "SHA256",
|
|
digits: 8,
|
|
});
|
|
const b = hotp(RFC4226_SECRET, 1n, {
|
|
algorithm: "SHA1",
|
|
digits: 8,
|
|
});
|
|
expect(a).toHaveLength(8);
|
|
expect(b).toHaveLength(8);
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("SHA-512 produces 8-digit code", { timeout: 5000 }, () => {
|
|
const code = hotp(RFC6238_SHA512_SECRET, 1n, {
|
|
algorithm: "SHA512",
|
|
digits: 8,
|
|
});
|
|
expect(code).toHaveLength(8);
|
|
expect(/^\d{8}$/u.test(code)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("hotp — digits widening", () => {
|
|
it("digits=7 returns 7 chars", { timeout: 5000 }, () => {
|
|
expect(hotp(RFC4226_SECRET, 0n, { digits: 7 })).toHaveLength(7);
|
|
});
|
|
it("digits=8 returns 8 chars", { timeout: 5000 }, () => {
|
|
expect(hotp(RFC4226_SECRET, 0n, { digits: 8 })).toHaveLength(8);
|
|
});
|
|
});
|
|
|
|
describe("hotp — bigint counter beyond Number.MAX_SAFE_INTEGER", () => {
|
|
it("counter=2^53+1 round-trips through encode-counter", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const counter = (1n << 53n) + 1n;
|
|
const code = hotp(RFC4226_SECRET, counter);
|
|
expect(/^\d{6}$/u.test(code)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("hotp — option / secret validation", () => {
|
|
it("rejects empty secret", { timeout: 5000 }, () => {
|
|
expect(() => hotp(new Uint8Array(0), 0n)).toThrow(/INVALID_SECRET/);
|
|
});
|
|
|
|
it("rejects unknown algorithm via Zod boundary", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() =>
|
|
hotp(RFC4226_SECRET, 0n, {
|
|
algorithm: "MD5" as unknown as "SHA1",
|
|
})
|
|
).toThrow(/INVALID_OPTIONS/);
|
|
});
|
|
|
|
it("rejects digits=5 via Zod boundary (mapped to INVALID_DIGITS)", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() =>
|
|
hotp(RFC4226_SECRET, 0n, {
|
|
digits: 5 as unknown as 6,
|
|
})
|
|
).toThrow(/INVALID_DIGITS/);
|
|
});
|
|
});
|