Files
suaveplan/packages/security-stack/security/otp/src/hotp/hotp.test.ts
2026-05-27 23:47:53 +01:00

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