Initial Commit
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# `hotp/encode-counter`
|
||||
|
||||
## Purpose
|
||||
|
||||
Encode an HOTP counter as the 8-byte big-endian block required by RFC 4226
|
||||
§5.1. `bigint` is the canonical input type because `Number` loses precision
|
||||
past `2^53` and the RFC permits counters up to `2^64 − 1`. Shared by HOTP
|
||||
itself, by OCRA's optional `C` directive, and by OCRA's `T` timestamp block
|
||||
(which is also written as an 8-byte big-endian integer per RFC 6287 §5.1).
|
||||
|
||||
## Features
|
||||
|
||||
- BigInt input — no precision loss across the full 0..2^64-1 range.
|
||||
- Range guard: negative counters and counters past `2^64-1` raise
|
||||
`OtpError(OTP.INVALID_OPTIONS)`.
|
||||
- Pure: returns a freshly-allocated `Uint8Array(8)` per call.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { encodeCounter } from "@suaveplan/otp";
|
||||
|
||||
const block = encodeCounter(1n);
|
||||
// block === Uint8Array([0, 0, 0, 0, 0, 0, 0, 1])
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { encodeCounter } from "@suaveplan/otp";
|
||||
|
||||
// Counter past Number.MAX_SAFE_INTEGER round-trips losslessly.
|
||||
const block = encodeCounter((1n << 53n) + 1n);
|
||||
// `block` is exactly 8 bytes; the high bytes carry the counter's high bits.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeCounter(counter: bigint): Uint8Array` — returns an 8-byte
|
||||
big-endian block. Negative input raises `OtpError(OTP.INVALID_OPTIONS)`;
|
||||
values >= 2^64 raise the same code.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function loops from byte index 7 back to 0 shifting `counter` right by 8
|
||||
bits each step. The mask `0xffn` keeps the operation inside the `bigint`
|
||||
domain so no implicit `Number` conversion can lose precision. The function
|
||||
is the single source of truth for big-endian counter framing — both HOTP and
|
||||
OCRA route through it so any future endianness change propagates atomically.
|
||||
|
||||
The upper-bound check `counter > MAX_U64` catches the niche case where a
|
||||
caller passes a programmatically-generated counter past the RFC 4226
|
||||
limit. Without the check the encoder would silently truncate the high
|
||||
bits and produce two distinct counters that hash to the same byte block,
|
||||
which would silently break the per-counter uniqueness invariant HOTP
|
||||
relies on. The lower-bound check on negative counters covers the
|
||||
symmetric case for callers who derive counters via `BigInt()` arithmetic
|
||||
that may go negative under bug conditions.
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { encodeCounter } from "./encode-counter.js";
|
||||
|
||||
describe("encodeCounter — RFC 4226 §5.1 8-byte big-endian", () => {
|
||||
it("encodes 0 to all-zero block", { timeout: 5000 }, () => {
|
||||
expect(Array.from(encodeCounter(0n))).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it("encodes 1 to ...0x01", { timeout: 5000 }, () => {
|
||||
expect(Array.from(encodeCounter(1n))).toEqual([0, 0, 0, 0, 0, 0, 0, 1]);
|
||||
});
|
||||
|
||||
it("encodes 2^32 - 1 to 0x00..0xFF FF FF FF", { timeout: 5000 }, () => {
|
||||
expect(Array.from(encodeCounter((1n << 32n) - 1n))).toEqual([
|
||||
0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff,
|
||||
]);
|
||||
});
|
||||
|
||||
it("encodes 2^64 - 1 to all-0xFF block", { timeout: 5000 }, () => {
|
||||
expect(Array.from(encodeCounter((1n << 64n) - 1n))).toEqual([
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects negative counter", { timeout: 5000 }, () => {
|
||||
expect(() => encodeCounter(-1n)).toThrow(/INVALID_OPTIONS/);
|
||||
});
|
||||
|
||||
it("rejects counter > 2^64 - 1", { timeout: 5000 }, () => {
|
||||
expect(() => encodeCounter(1n << 64n)).toThrow(/INVALID_OPTIONS/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { OTP_CODES, otpError } from "../errors/codes.js";
|
||||
|
||||
const MAX_U64 = (1n << 64n) - 1n;
|
||||
|
||||
/**
|
||||
* Encode an HOTP counter as the 8-byte big-endian block required by RFC 4226
|
||||
* §5.1. `bigint` is the canonical input type — `Number` loses precision past
|
||||
* `2^53` and the RFC permits counters up to `2^64 - 1`. Negative counters and
|
||||
* counters past `2^64 - 1` raise `OtpError(OTP.INVALID_OPTIONS)`.
|
||||
*/
|
||||
export function encodeCounter(counter: bigint): Uint8Array {
|
||||
if (counter < 0n) {
|
||||
throw otpError(
|
||||
OTP_CODES.INVALID_OPTIONS,
|
||||
"HOTP counter must be non-negative (RFC 4226 §5.1)"
|
||||
);
|
||||
}
|
||||
if (counter > MAX_U64) {
|
||||
throw otpError(
|
||||
OTP_CODES.INVALID_OPTIONS,
|
||||
"HOTP counter exceeds 2^64-1 (RFC 4226 §5.1 mandates an 8-byte big-endian block)"
|
||||
);
|
||||
}
|
||||
const out = new Uint8Array(8);
|
||||
let v = counter;
|
||||
for (let i = 7; i >= 0; i -= 1) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
56
packages/security-stack/security/otp/src/hotp/hmac.md
Normal file
56
packages/security-stack/security/otp/src/hotp/hmac.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# `hotp/hmac`
|
||||
|
||||
## Purpose
|
||||
|
||||
Algorithm-routing HMAC façade for the OTP family. SHA-256 and SHA-512 route
|
||||
through `@suaveplan/crypto-hash` (the canonical platform HMAC); SHA-1 routes
|
||||
through `@noble/hashes/sha1` directly because crypto-hash deliberately does
|
||||
not expose SHA-1 — it is mandated only by legacy OTP RFCs and must not be
|
||||
presented as a general-purpose option elsewhere in the platform.
|
||||
|
||||
## Features
|
||||
|
||||
- Three algorithms: SHA-1, SHA-256, SHA-512.
|
||||
- SHA-1 isolated to the OTP family — no leakage into the wider crypto API.
|
||||
- Returns a freshly-allocated `Uint8Array` of the algorithm's digest size.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { hmacFor } from "@suaveplan/otp";
|
||||
|
||||
const tag = hmacFor("SHA1", key, message);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { hmacFor } from "@suaveplan/otp";
|
||||
|
||||
// Build the OCRA HMAC input directly — useful for KAT-driven test fixtures.
|
||||
const tag = hmacFor("SHA256", key, suiteAsciiPlusZeroPlusDataInput);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `hmacFor(algorithm: HotpAlgorithm, key: Uint8Array, data: Uint8Array): Uint8Array`
|
||||
— returns the HMAC tag.
|
||||
- `HotpAlgorithm = "SHA1" | "SHA256" | "SHA512"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Routing inside this module rather than at the call site keeps the SHA-1
|
||||
exception local to OTP. The crypto-hash boundary uses dashed names
|
||||
(`"SHA-256"`); this module accepts the OTP-RFC-style undashed names
|
||||
(`"SHA256"`) and translates internally. Adding a new algorithm here is the
|
||||
only change required — `hotp.ts`, `totp.ts`, and `ocra.ts` all consume this
|
||||
single dispatch point.
|
||||
|
||||
The SHA-1 routing decision is intentional: `@suaveplan/crypto-hash` is the
|
||||
canonical platform façade for hashing across the project, and exposing
|
||||
SHA-1 there would invite consumers to use it for non-OTP purposes. By
|
||||
isolating the SHA-1 dependency to the OTP module, the rest of the platform
|
||||
keeps a no-SHA-1 posture without breaking RFC 4226 / 6238 / 6287
|
||||
compliance. The noble HMAC primitive used here is the same implementation
|
||||
crypto-hash routes through under the hood for SHA-256 / SHA-512, so the
|
||||
two paths behave identically apart from the chosen hash.
|
||||
26
packages/security-stack/security/otp/src/hotp/hmac.ts
Normal file
26
packages/security-stack/security/otp/src/hotp/hmac.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { hmac as nobleHmac } from "@noble/hashes/hmac";
|
||||
import { sha1 } from "@noble/hashes/sha1";
|
||||
import { hmac as suaveplanHmac } from "@suaveplan/crypto-hash";
|
||||
import type { HotpAlgorithm } from "./hotp.types.js";
|
||||
|
||||
/**
|
||||
* Compute HMAC over `data` keyed by `key` using one of the algorithms RFC 6238
|
||||
* §1.2 permits for OTP. SHA-256 and SHA-512 route through `@suaveplan/crypto-hash`
|
||||
* (the canonical HMAC façade); SHA-1 routes through `@noble/hashes/sha1`
|
||||
* directly because crypto-hash does not expose SHA-1 (it is RFC-mandated for
|
||||
* OTP backwards-compat ONLY and must not be presented as a general-purpose
|
||||
* choice elsewhere in the platform).
|
||||
*/
|
||||
export function hmacFor(
|
||||
algorithm: HotpAlgorithm,
|
||||
key: Uint8Array,
|
||||
data: Uint8Array
|
||||
): Uint8Array {
|
||||
if (algorithm === "SHA1") {
|
||||
return nobleHmac(sha1, key, data);
|
||||
}
|
||||
if (algorithm === "SHA256") {
|
||||
return suaveplanHmac("SHA-256", key, data);
|
||||
}
|
||||
return suaveplanHmac("SHA-512", key, data);
|
||||
}
|
||||
79
packages/security-stack/security/otp/src/hotp/hotp.md
Normal file
79
packages/security-stack/security/otp/src/hotp/hotp.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# `hotp/hotp`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - hotp.schema — Zod boundary schema for `HotpOptions`…
|
||||
- [hotp.schema](./hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
|
||||
- hotp.schema — Zod boundary schema for `HotpOptions`…
|
||||
|
||||
- [hotp.schema](./hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
|
||||
|
||||
- hotp.schema — Zod boundary schema for `HotpOptions`…
|
||||
|
||||
- [hotp.schema](./hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
|
||||
|
||||
- hotp.schema — Zod boundary schema for `HotpOptions`…
|
||||
|
||||
- [hotp.schema](./hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
|
||||
- [hotp.schema](./hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
|
||||
|
||||
## Purpose
|
||||
|
||||
RFC 4226 HOTP — HMAC-based one-time password. Computes
|
||||
`value = DT(HMAC-H(K, C)) mod 10^digits` and returns a `digits`-length
|
||||
zero-padded decimal string. Counter is `bigint` (RFC 4226 §5.1 mandates an
|
||||
8-byte big-endian counter; `Number` loses precision past `2^53`).
|
||||
|
||||
## Features
|
||||
|
||||
- RFC 4226 dynamic truncation per §5.3 step 2.
|
||||
- SHA-1 default; SHA-256 / SHA-512 widening per RFC 6238 §1.2.
|
||||
- 6 / 7 / 8-digit output.
|
||||
- Zod-validated options at the boundary; `INVALID_DIGITS` mapped via the
|
||||
zod-map for fields that fail enum/literal-union checks.
|
||||
- Empty-secret rejection with `OTP.INVALID_SECRET`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { hotp } from "@suaveplan/otp";
|
||||
|
||||
const secret = new TextEncoder().encode("12345678901234567890");
|
||||
const code = hotp(secret, 0n);
|
||||
// code === "755224" (RFC 4226 Appendix D vector)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { hotp } from "@suaveplan/otp";
|
||||
|
||||
const code = hotp(secret, (1n << 53n) + 1n, {
|
||||
algorithm: "SHA512",
|
||||
digits: 8,
|
||||
});
|
||||
// 8-digit SHA-512 HOTP at a counter past Number.MAX_SAFE_INTEGER
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `hotp(secret: Uint8Array, counter: bigint, opts?: HotpOptions): string`
|
||||
— returns the `digits`-length decimal code.
|
||||
- `HotpOptions` — `{ algorithm?: "SHA1" | "SHA256" | "SHA512"; digits?: 6 | 7 | 8 }`.
|
||||
- Throws `OtpError(OTP.INVALID_OPTIONS | OTP.INVALID_DIGITS)` on Zod
|
||||
failures and `OtpError(OTP.INVALID_SECRET)` on empty `secret`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Counter encoding lives in `encode-counter.ts` and is shared with OCRA's
|
||||
`C` directive. Dynamic truncation extracts a 31-bit value from the HMAC by
|
||||
masking the high bit to avoid sign-extension surprises across HMAC
|
||||
implementations. Modular reduction uses a precomputed `POW10` table indexed
|
||||
by `digits` rather than `Math.pow` so the modulus is an exact integer.
|
||||
|
||||
The Zod schema rejects unknown keys at the strict-mode boundary so callers
|
||||
who construct options from JSON or external configuration cannot smuggle
|
||||
typo'd fields past the API. Field-level Zod failures flow through
|
||||
`mapZodToOtp` so a bad `digits` value surfaces as `OTP.INVALID_DIGITS`
|
||||
rather than the generic `OTP.INVALID_OPTIONS`. The empty-secret guard is
|
||||
runtime-only because Zod's bytes type does not constrain length.
|
||||
61
packages/security-stack/security/otp/src/hotp/hotp.schema.md
Normal file
61
packages/security-stack/security/otp/src/hotp/hotp.schema.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# `hotp/hotp.schema`
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown
|
||||
keys (so silent typos in caller configuration surface immediately) and
|
||||
narrows `algorithm` and `digits` to their literal-union values. Mismatches
|
||||
flow through `mapZodToOtp` so the boundary error carries a specific code
|
||||
(`INVALID_DIGITS` for the `digits` field, `INVALID_OPTIONS` otherwise).
|
||||
|
||||
## Features
|
||||
|
||||
- `algorithm` enum: `"SHA1" | "SHA256" | "SHA512"`.
|
||||
- `digits` literal union: `6 | 7 | 8`.
|
||||
- Strict-mode object — unknown keys are rejected.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HotpOptionsSchema } from "@suaveplan/otp";
|
||||
|
||||
HotpOptionsSchema.parse({ algorithm: "SHA1", digits: 6 });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { HotpOptionsSchema } from "@suaveplan/otp";
|
||||
|
||||
// Programmatic option builder — feed parsed result directly into hotp().
|
||||
const opts = HotpOptionsSchema.parse(JSON.parse(userJson));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HotpOptionsSchema` — Zod object schema with `.strict()`.
|
||||
- `HotpOptionsInput` — type alias for the parsed shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The strict-mode check is the second-line defence behind TypeScript's compile
|
||||
checks: callers using `as never` or constructing options from JSON can still
|
||||
slip values past the type system. Pinning the schema here keeps the runtime
|
||||
contract aligned with the type-level contract.
|
||||
|
||||
The schema is intentionally narrow — it covers only the surface that
|
||||
HOTP itself needs. Callers building their own option bags around HOTP
|
||||
(e.g. a vault item type that wraps HOTP with extra metadata) should
|
||||
extend this schema using Zod's `.extend()` or compose it via a discriminated
|
||||
union, rather than relaxing the strict-mode setting here. Keeping the base
|
||||
schema tight prevents accidental option-shape drift across the OTP family
|
||||
and means every caller benefits from the same validation guarantees.
|
||||
|
||||
The `algorithm` enum covers exactly the three hashes RFC 4226 / 6238 §1.2
|
||||
allow for OTP. SHA-3 family hashes are not part of any OTP RFC and would
|
||||
be rejected at this boundary even though `@noble/hashes` provides them.
|
||||
|
||||
The `digits` literal union mirrors RFC 4226 §5.3 (`6`) plus the RFC 6238
|
||||
§1.2 widening (`7`, `8`). RFC 6287 OCRA additionally permits digits=4,
|
||||
which is handled in the OCRA suite parser rather than here because the
|
||||
HOTP primitive does not need to expose it directly.
|
||||
15
packages/security-stack/security/otp/src/hotp/hotp.schema.ts
Normal file
15
packages/security-stack/security/otp/src/hotp/hotp.schema.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod boundary for `HotpOptions`. Strict-mode rejection of unknown keys keeps
|
||||
* silent typos from masquerading as valid configuration; mismatches surface as
|
||||
* `OtpError(OTP.INVALID_OPTIONS)` at the call boundary.
|
||||
*/
|
||||
export const HotpOptionsSchema = z
|
||||
.object({
|
||||
algorithm: z.enum(["SHA1", "SHA256", "SHA512"]).optional(),
|
||||
digits: z.union([z.literal(6), z.literal(7), z.literal(8)]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type HotpOptionsInput = z.infer<typeof HotpOptionsSchema>;
|
||||
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/);
|
||||
});
|
||||
});
|
||||
52
packages/security-stack/security/otp/src/hotp/hotp.ts
Normal file
52
packages/security-stack/security/otp/src/hotp/hotp.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { OTP_CODES, otpError } from "../errors/codes.js";
|
||||
import { rethrowAsOtp } from "../errors/zod-map.js";
|
||||
import { recordOtpError } from "../observability/metrics.js";
|
||||
import { encodeCounter } from "./encode-counter.js";
|
||||
import { hmacFor } from "./hmac.js";
|
||||
import { HotpOptionsSchema } from "./hotp.schema.js";
|
||||
import type { HotpAlgorithm, HotpDigits, HotpOptions } from "./hotp.types.js";
|
||||
|
||||
const POW10: readonly number[] = [
|
||||
1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000,
|
||||
];
|
||||
|
||||
/**
|
||||
* RFC 4226 HOTP. Computes `value = DT(HMAC-H(K, C)) mod 10^digits` and returns
|
||||
* a `digits`-length zero-padded decimal string. `H` defaults to SHA-1 per
|
||||
* §5.1; RFC 6238 §1.2 widens the choice to SHA-256 / SHA-512. Counter is a
|
||||
* `bigint` and is encoded as an 8-byte big-endian block per §5.1.
|
||||
*/
|
||||
export function hotp(
|
||||
secret: Uint8Array,
|
||||
counter: bigint,
|
||||
opts: HotpOptions = {}
|
||||
): string {
|
||||
let parsed: ReturnType<typeof HotpOptionsSchema.parse>;
|
||||
try {
|
||||
parsed = HotpOptionsSchema.parse(opts);
|
||||
} catch (cause) {
|
||||
recordOtpError(OTP_CODES.INVALID_OPTIONS);
|
||||
rethrowAsOtp(cause, "invalid HOTP options");
|
||||
}
|
||||
if (secret.length === 0) {
|
||||
recordOtpError(OTP_CODES.INVALID_SECRET);
|
||||
throw otpError(
|
||||
OTP_CODES.INVALID_SECRET,
|
||||
"HOTP secret must not be empty"
|
||||
);
|
||||
}
|
||||
const algorithm: HotpAlgorithm = parsed.algorithm ?? "SHA1";
|
||||
const digits: HotpDigits = parsed.digits ?? 6;
|
||||
const counterBlock = encodeCounter(counter);
|
||||
const hs = hmacFor(algorithm, secret, counterBlock);
|
||||
// RFC 4226 §5.3 step 2: dynamic truncation
|
||||
const offset = (hs[hs.length - 1] as number) & 0x0f;
|
||||
const binCode =
|
||||
(((hs[offset] as number) & 0x7f) << 24) |
|
||||
(((hs[offset + 1] as number) & 0xff) << 16) |
|
||||
(((hs[offset + 2] as number) & 0xff) << 8) |
|
||||
((hs[offset + 3] as number) & 0xff);
|
||||
const modulus = POW10[digits] as number;
|
||||
const value = binCode % modulus;
|
||||
return value.toString(10).padStart(digits, "0");
|
||||
}
|
||||
13
packages/security-stack/security/otp/src/hotp/hotp.types.ts
Normal file
13
packages/security-stack/security/otp/src/hotp/hotp.types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/** RFC 6238 §1.2 widens HOTP to SHA-256 / SHA-512. */
|
||||
export type HotpAlgorithm = "SHA1" | "SHA256" | "SHA512";
|
||||
|
||||
/** RFC 4226 §5.3 final paragraph permits 6 digits; RFC 6238 §1.2 widens to 7/8. */
|
||||
export type HotpDigits = 6 | 7 | 8;
|
||||
|
||||
/** Options accepted by `hotp`. All fields optional with RFC defaults. */
|
||||
export interface HotpOptions {
|
||||
/** Defaults to `"SHA1"` per RFC 4226 §5.1. */
|
||||
algorithm?: HotpAlgorithm;
|
||||
/** Defaults to `6` per RFC 4226 §5.3. */
|
||||
digits?: HotpDigits;
|
||||
}
|
||||
9
packages/security-stack/security/otp/src/hotp/index.ts
Normal file
9
packages/security-stack/security/otp/src/hotp/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { encodeCounter } from "./encode-counter.js";
|
||||
export { hmacFor } from "./hmac.js";
|
||||
export { hotp } from "./hotp.js";
|
||||
export { HotpOptionsSchema } from "./hotp.schema.js";
|
||||
export type {
|
||||
HotpAlgorithm,
|
||||
HotpDigits,
|
||||
HotpOptions,
|
||||
} from "./hotp.types.js";
|
||||
Reference in New Issue
Block a user