Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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.

View File

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

View File

@@ -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;
}

View 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.

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

View 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.

View 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.

View 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>;

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

View 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");
}

View 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;
}

View 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";