Initial Commit
This commit is contained in:
108
packages/security-stack/security/otp/src/hotp/hotp.test.ts
Normal file
108
packages/security-stack/security/otp/src/hotp/hotp.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user