Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
# `@suaveplan/hibp` — Privacy-preserving Have I Been Pwned client
**Package**: `@suaveplan/hibp` (NEW)
**Status**: PROPOSED
**Type**: new-package (greenfield migration into genesis)
**Date**: 2026-05-10
**Path**: `packages/security/hibp/`
**Tier**: universal (zero browser / Node / React deps; injectable `fetch`)
## Why
`@suaveplan/vault` (Vaulted password-manager surface) needs to tell users on every item save and on every unlock whether the password they are storing has appeared in a known breach corpus. The only widely available data source for that is Troy Hunt's [Have I Been Pwned](https://haveibeenpwned.com/) corpus. The two relevant endpoints are the [Pwned Passwords range API](https://haveibeenpwned.com/API/v3#PwnedPasswords) (`GET https://api.pwnedpasswords.com/range/<5-char-prefix>`, no API key required, k-anonymity) and the [authenticated breach / paste API](https://haveibeenpwned.com/API/v3#BreachesForAccount) (`GET https://haveibeenpwned.com/api/v3/breachedaccount/{email}`, `hibp-api-key` header required, 1500 ms client gap enforced).
The privacy contract is non-negotiable: **the full password and the full SHA-1 hash MUST NEVER leave the calling process.** Only the first five hex characters of the SHA-1 hash may travel over the wire, and the `Add-Padding: true` header MUST be set on every range request to defeat response-size correlation attacks (Cloudflare's _Validating Leaked Passwords with k-Anonymity_ post documents the attack model and the padding mitigation). Any client that submits the full hash, leaks suffixes via timing, logs the email, or omits the padding header is a defect.
There is no upstream genesis implementation to migrate; this is a greenfield universal-tier package authored from the canonical `enterprise/hibp.md` plan. The proposal scopes in every feature listed there: the range client, the authenticated breach / paste client, the LRU cache, the pluggable cache adapter, strict-mode response framing, opt-in differential privacy padding, mitata benchmarks, the Zod boundary, the structured-error registry, AbortSignal threading, and the OpenTelemetry surface. Nothing is deferred.
## What changes
### New package: `@suaveplan/hibp`
- ADDED: `packages/security/hibp/` package scaffold (`package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`).
- ADDED: `src/range/range.ts` — `checkPassword(password, opts?): Promise<PwnedResult>`; SHA-1 the input via `@suaveplan/crypto-hash/sha1`, slice to the first 5 hex chars, send `GET https://api.pwnedpasswords.com/range/<prefix>` with `Add-Padding: true`, scan response for the matching 35-char suffix, return `{ pwned, count }`.
- ADDED: `src/range/range-stream-parse.ts` — line-streaming parser over `Response.body` (`ReadableStream<Uint8Array>` → `TextDecoderStream` → newline framing) that emits `{ suffix, count }` records without buffering the full response into a string.
- ADDED: `src/range/range-strict-frame.ts` — strict-mode validator that rejects responses whose lines do not match `/^[0-9A-F]{35}:[0-9]+$/`, raising `HIBP.MALFORMED_RESPONSE` with the offending line index.
- ADDED: `src/range/range-mode.ts` — `RangeMode = "sha1" | "ntlm"` with the NTLM variant appending `?mode=ntlm` to the URL and hashing via the NTLM adapter (caller-supplied if Node-only `crypto.createHash("md4")` is unavailable).
- ADDED: `src/range/constant-time-compare.ts` — `constantTimeEqualsHex(a, b)` verifying the suffix without an early-exit branch (XOR-fold over the full 35-char range).
- ADDED: `src/range/differential-privacy.ts` — `padPrefixes(realPrefix, n, rng)` returning `[realPrefix, ...nRandomSiblings]` for the opt-in `differentialPrivacy: { extraPrefixes: N }` mode.
- ADDED: `src/breach/breach-client.ts` — `getBreachesForAccount(email, opts?)`, `getBreach(name)`, `getAllBreaches(opts?)`, `getPastesForAccount(email)` against the authenticated `https://haveibeenpwned.com/api/v3/*` endpoints.
- ADDED: `src/breach/rate-limit-gate.ts` — `RateLimitGate` (1500 ms minimum gap on breach / paste) backed by `Clock` from `@suaveplan/types`.
- ADDED: `src/breach/retry-after.ts` — `parseRetryAfter(headerValue, now)` returning `{ delayMs }` honouring both delta-seconds and HTTP-date forms; `runWithRetryAfter` drives `@suaveplan/retry`'s `RetryPolicy` capped at 60 s.
- ADDED: `src/cache/cache.ts` — `HibpCacheLike<V>` interface (`get`/`set`/`delete`/`size`).
- ADDED: `src/cache/lru.ts` — `LruHibpCache` (default 10 000 entries, 24 h TTL, keyed by SHA-1 prefix).
- ADDED: `src/cache/null.ts` — `NullHibpCache` (always-miss, used when caller passes `cache: null`).
- ADDED: `src/client/client.ts` — `HibpClient` constructor taking `HibpClientOptions` (api key, cache, fetch, clock, rng, rate-limit gate, observability, strict mode, differential-privacy mode); thin façade composing the modules above.
- ADDED: `src/client/options.schema.ts` — `HibpClientOptionsSchema` (Zod, strict object) — rejects unknown keys; rejects `apiKey === ""`; rejects `extraPrefixes < 0`; raises `HIBP.MALFORMED_RESPONSE` when downstream JSON fails the schema.
- ADDED: `src/schemas/breach.schema.ts` — `BreachSchema` Zod boundary mirroring the [v3 `Breach` shape](https://haveibeenpwned.com/API/v3#Breach) (`Name`, `Title`, `Domain`, `BreachDate`, `AddedDate`, `ModifiedDate`, `PwnCount`, `Description`, `LogoPath`, `DataClasses[]`, `IsVerified`, `IsFabricated`, `IsSensitive`, `IsRetired`, `IsSpamList`, `IsMalware`, `IsSubscriptionFree`, `IsStealerLog`).
- ADDED: `src/schemas/paste.schema.ts` — `PasteSchema` mirroring the [v3 `Paste` shape](https://haveibeenpwned.com/API/v3#PastesForAccount) (`Source`, `Id`, `Title`, `Date`, `EmailCount`).
- ADDED: `src/schemas/pwned-result.schema.ts` — `PwnedResultSchema` (`{ pwned: boolean; count: number }`, `count >= 0`).
- ADDED: `src/errors/codes.ts` — `defineCodes("HIBP", [...])` registry with seven codes: `RATE_LIMITED`, `UNAUTHORIZED`, `NOT_FOUND`, `MALFORMED_RESPONSE`, `NETWORK_ERROR`, `ABORTED`, `PAYLOAD_TOO_LARGE`.
- ADDED: `src/errors/hibp-error.ts` — `HibpError extends SuaveplanError<HibpErrorCode>` with stable JSON form for log redaction.
- ADDED: `src/observability/metrics.ts` — `hibp.range.duration_ms` and `hibp.breach.duration_ms` histograms; `hibp.range.outcome_total{outcome=pwned|clean|error}`, `hibp.errors_total{code}`, `hibp.rate_limit_throttled_total` counters.
- ADDED: `src/privacy/assert-no-leak.ts` — runtime helper used in tests (and optionally in production via `assertOutgoingRequestSafe: true`) that scans every outgoing `Request` URL + body byte-by-byte for the full SHA-1 hash, the full password, and the configured email, throwing `HIBP.MALFORMED_RESPONSE` on hit.
- ADDED: `src/__benchmarks__/range-cold.bench.ts`, `range-warm-cache.bench.ts`, `range-parse-30kib.bench.ts` — mitata benchmarks per the enterprise plan §4.
- ADDED: `src/index.ts` — explicit named re-exports across all five sub-namespaces.
### Subpath exports
- `.` — root barrel
- `./range/range` — `checkPassword` + `RangeMode` + result types
- `./breach/breach-client` — authenticated client + types
- `./cache/cache` — `HibpCacheLike` interface
- `./cache/lru` — `LruHibpCache`
- `./client/client` — `HibpClient` orchestrator + `HibpClientOptions`
- `./errors/codes` — `HIBP_CODES`, `HibpErrorCode`
- `./errors/hibp-error` — `HibpError`
- `./schemas/breach.schema` / `./schemas/paste.schema` / `./schemas/pwned-result.schema`
- `./observability/metrics` — `hibpMetrics`
### Dependencies
- `@suaveplan/crypto-hash workspace:^` — SHA-1 for the range prefix (per enterprise plan §7).
- `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError`.
- `@suaveplan/types workspace:^` — `Clock`, `Rng`, `LoggerLike`, `AbortSignalLike`.
- `@suaveplan/retry workspace:^` — `RetryPolicy` for the 429 backoff loop.
- `@suaveplan/timing workspace:^` — `RateLimitGate` runs against the injectable clock.
- `@opentelemetry/api catalog:observability` — histograms + counters.
- `tslib catalog:build`.
- `zod catalog:forms` — boundary schemas.
No browser-only deps, no Node built-ins (`fetch` and `TextDecoderStream` are universal in modern runtimes; both can be injected for environments that lack them).
## Out of scope
- **Wiring `@suaveplan/vault` to call `HibpClient`** — the vault integration has its own change folder; this proposal ships only the client.
- **Pwned Passwords v3 binary mode** — the documented `Add-Padding`-aware text framing covers every consumer requirement; v3 binary is a follow-up if/when latency profiling demands it.
- **HIBP v2 endpoints** — v3 is the only documented surface; v2 is end-of-life.
- **Persisting the LRU cache to disk** — the default `LruHibpCache` is in-memory. Callers that need persistence inject a `HibpCacheLike` adapter (e.g. backed by `@suaveplan/storage`).
- **Auto-refreshing the breach catalogue** — `getAllBreaches` is on demand. A refresh daemon belongs in a downstream consumer.
- **`subscribe` (paid Pwned Passwords subscription) endpoints** — reserved for a follow-up; out of scope so the universal-tier surface stays free of paid-tier-only fields.
## Source mapping
This change derives from `../suaveplan/enterprise/hibp.md` (greenfield — no upstream code exists). Every requirement also cites the canonical [Have I Been Pwned API v3](https://haveibeenpwned.com/API/v3) anchor it implements.
| Requirement | Source | API anchor / line range | Notes |
|---|---|---|---|
| Range API k-anonymity check | `enterprise/hibp.md` §3 | [API v3 §PwnedPasswords](https://haveibeenpwned.com/API/v3#PwnedPasswords); plan L29-L31 | First 5 hex chars only; full hash never leaves the function |
| Mandatory `Add-Padding: true` header | `enterprise/hibp.md` §3 | [API v3 §PwnedPasswordsPadding](https://haveibeenpwned.com/API/v3#PwnedPasswordsPadding); plan L31 | Mitigates response-size correlation attacks; see Cloudflare k-anonymity post |
| NTLM mode opt-in | `enterprise/hibp.md` §3 | [API v3 §PwnedPasswords](https://haveibeenpwned.com/API/v3#PwnedPasswords) (`?mode=ntlm` query); plan L32 | For Windows-credential workflows |
| Streaming response parser | `enterprise/hibp.md` §3 | [API v3 §PwnedPasswords](https://haveibeenpwned.com/API/v3#PwnedPasswords) response framing; plan L33 | ~30 KiB padded responses must not be string-buffered |
| Constant-time suffix compare | `enterprise/hibp.md` §3 | n/a (defensive coding); plan L34 | No early-exit timing leak |
| Privacy assertion (URL + body byte-by-byte) | `enterprise/hibp.md` §3 | n/a (genesis privacy contract); plan L52-L53 | Tests + opt-in production gate |
| `getBreachesForAccount` | `enterprise/hibp.md` §3 | [API v3 §BreachesForAccount](https://haveibeenpwned.com/API/v3#BreachesForAccount); plan L37 | `truncateResponse=false&includeUnverified=true`; email URL-encoded; never logged |
| `getBreach(name)` | `enterprise/hibp.md` §3 | [API v3 §SingleBreach](https://haveibeenpwned.com/API/v3#SingleBreach); plan L38 | |
| `getAllBreaches` | `enterprise/hibp.md` §3 | [API v3 §AllBreaches](https://haveibeenpwned.com/API/v3#AllBreaches); plan L39 | |
| `getPastesForAccount` | `enterprise/hibp.md` §3 | [API v3 §PastesForAccount](https://haveibeenpwned.com/API/v3#PastesForAccount); plan L40 | |
| `hibp-api-key` header from constructor | `enterprise/hibp.md` §3 | [API v3 §Authorisation](https://haveibeenpwned.com/API/v3#Authorisation); plan L41 | Never read at module scope |
| 1500 ms client-side rate-limit gate | `enterprise/hibp.md` §3 | [API v3 §RateLimiting](https://haveibeenpwned.com/API/v3#RateLimiting); plan L42 | Injectable clock |
| 429 + `Retry-After` honoured | `enterprise/hibp.md` §3 | [API v3 §RateLimiting](https://haveibeenpwned.com/API/v3#RateLimiting); plan L43 | Exponential backoff via `@suaveplan/retry`, capped at 60 s |
| In-memory LRU cache (10 000 / 24 h) | `enterprise/hibp.md` §4 | n/a (genesis enhancement); plan L57 | Keyed by SHA-1 prefix |
| Pluggable `HibpCacheLike` | `enterprise/hibp.md` §4 | n/a (genesis enhancement); plan L58 | redis / IndexedDB / OPFS adapters |
| Strict-mode response framing | `enterprise/hibp.md` §4 | [API v3 §PwnedPasswords](https://haveibeenpwned.com/API/v3#PwnedPasswords) framing; plan L59 | Reject malformed `:count` / non-uppercase hex |
| Differential-privacy mode (extra prefixes) | `enterprise/hibp.md` §4 | n/a (genesis enhancement); plan L60 | Default off (Add-Padding sufficient for most threat models) |
| Error registry — 7 codes | `enterprise/hibp.md` §3 | n/a; plan L49 | `RATE_LIMITED`, `UNAUTHORIZED`, `NOT_FOUND`, `MALFORMED_RESPONSE`, `NETWORK_ERROR`, `ABORTED`, `PAYLOAD_TOO_LARGE` |
| Zod boundary schemas | `enterprise/hibp.md` §3 | [API v3 §Breach](https://haveibeenpwned.com/API/v3#Breach), [API v3 §Paste](https://haveibeenpwned.com/API/v3#PastesForAccount); plan L48 | `BreachSchema`, `PasteSchema`, `PwnedResultSchema`, `HibpClientOptionsSchema` |
| AbortSignal on every public method | `enterprise/hibp.md` §3 | n/a (genesis convention); plan L51 | |
| OTel histograms + counters | `enterprise/hibp.md` §3 | n/a; plan L50 | `hibp.range.duration_ms`, `hibp.breach.duration_ms`, `hibp.range.outcome_total`, `hibp.errors_total`, `hibp.rate_limit_throttled_total` |
| Mitata benchmarks | `enterprise/hibp.md` §4 | n/a; plan L61 | `range_check_cold`, `range_check_warm_cache`, `range_parse_30kib` |
### Requirements with no source line
None — every requirement traces to either `enterprise/hibp.md` or a documented HIBP API v3 anchor. The "genesis enhancement" entries above are rooted in the enterprise plan's §4 ("Feature-rich enhancements") section.
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta naming concrete `it(...)` titles in concrete test files. Test files that will exist when this change ships:
- `src/range/range.test.ts` — k-anonymity prefix slice; `Add-Padding: true` header asserted on every fetch; `NTLM` mode opt-in; pwned vs clean outcomes; AbortSignal threading; OTel histogram emission.
- `src/range/range-stream-parse.test.ts` — multi-chunk decode of a 30 KiB padded response; never collects into a single string; rejects partial UTF-8 boundaries gracefully.
- `src/range/range-strict-frame.test.ts` — strict mode rejects lowercase hex / extra colons / missing colons / non-numeric counts; off-mode passes them.
- `src/range/range-mode.test.ts` — `mode: "ntlm"` appends `?mode=ntlm` and uses NTLM hash adapter; default is SHA-1.
- `src/range/constant-time-compare.test.ts` — equal and unequal pairs both touch every byte of the 35-char range; differential statistical timing test on 10 000 samples shows mean delta ≤ 5 % of mean execution time.
- `src/range/differential-privacy.test.ts` — `padPrefixes(real, 3, rng)` returns 4 entries; `real` is in the result; remaining three are uniformly distributed over `[00000, FFFFF]` minus the real prefix.
- `src/breach/breach-client.test.ts` — every endpoint URL constructed, query params assembled, `hibp-api-key` header set, email URL-encoded, response Zod-validated, AbortSignal threaded.
- `src/breach/rate-limit-gate.test.ts` — second call within 1500 ms blocks until gate releases; injectable clock drives gate; `hibp.rate_limit_throttled_total` counter increments.
- `src/breach/retry-after.test.ts` — delta-seconds and HTTP-date forms; cap at 60 s; integrates with `@suaveplan/retry` `RetryPolicy`.
- `src/cache/cache.test.ts` — interface conformance via type-level assertions.
- `src/cache/lru.test.ts` — eviction at capacity; TTL eviction at 24 h via injectable clock; key normalization to uppercase hex.
- `src/cache/null.test.ts` — always returns `undefined`; `set` is a no-op.
- `src/client/client.test.ts` — composes range + breach + cache + rate-limit + observability; default options; explicit `cache: null` disables cache.
- `src/client/options.schema.test.ts` — every Zod path: empty `apiKey`, negative `extraPrefixes`, unknown keys.
- `src/schemas/breach.schema.test.ts` — round-trip a real HIBP `/breach/Adobe` payload (recorded fixture, not live).
- `src/schemas/paste.schema.test.ts` — round-trip a real `/pasteaccount/test@example.com` payload (fixture).
- `src/schemas/pwned-result.schema.test.ts` — accepts `{ pwned: true, count: 1 }`; rejects negative counts.
- `src/errors/codes.test.ts` — every code value present; no duplicate keys.
- `src/errors/hibp-error.test.ts` — `HibpError` carries `code`, `cause`, JSON serializes deterministically.
- `src/observability/metrics.test.ts` — every histogram + counter recorded by an in-memory `MeterProvider`.
- `src/privacy/assert-no-leak.test.ts` — fabricates an outgoing fetch URL containing the full SHA-1 → throws; URL containing only the prefix → passes; URL containing the full password → throws; body containing email → throws.
- `src/privacy/range-no-leak.spec-conformance.test.ts` — repeats `checkPassword("hunter2")` 1 000 times, intercepting `fetch`; asserts the full SHA-1, the full password, and any character past the 5-hex prefix appears in zero outgoing URLs and zero outgoing bodies.
- `src/privacy/breach-no-log.spec-conformance.test.ts` — pipes a `LoggerLike` through `HibpClient`, calls `getBreachesForAccount("user@example.com")`, asserts the email never appears in any captured log line.
- `src/__benchmarks__/range-cold.bench.ts` / `range-warm-cache.bench.ts` / `range-parse-30kib.bench.ts` — mitata benchmark suite captured to README.
## Migration impact
None — additive only. No existing genesis package depends on `@suaveplan/hibp` yet. Once shipped, `@suaveplan/vault` will adopt it through a separate change.
## Impact
### Affected dependents
`@suaveplan/hibp` is a new universal-tier package with no current dependents. Verified via `bunx turbo ls --filter=...@suaveplan/hibp` (will return only the package itself once scaffolded). Future consumer (`@suaveplan/vault`) will adopt this surface in a separate change as documented in `enterprise/hibp.md` §5 step 9.
No dependents (verified by `bunx turbo ls --filter=...@suaveplan/hibp`).

View File

@@ -0,0 +1,504 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: hibp (enterprise-hibp)
**Package**: `@suaveplan/hibp`
**Status**: PROPOSED — first delta; becomes the canonical spec on archive.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Range API k-anonymity check
The package SHALL export `checkPassword(password, opts?): Promise<PwnedResult>` which SHA-1s the input password, slices the resulting hex digest to the first 5 uppercase characters, and dispatches `GET https://api.pwnedpasswords.com/range/<prefix>` to the [Pwned Passwords range API](https://haveibeenpwned.com/API/v3#PwnedPasswords). The function SHALL return `{ pwned: boolean, count: number }` describing whether the input's full 35-char suffix was present in the response and, if present, its breach count. The full SHA-1 hash and the full password SHALL NEVER be transmitted, logged, persisted, included in any thrown error, or referenced by any thrown error's `cause` chain.
#### Scenario: prefix is sent, suffix is not
- **WHEN** `checkPassword("hunter2")` is called with an injected `fetch` that records every outgoing `Request`
- **THEN** the recorded URL ends with `/range/F3BBB`, the URL contains no character past the 5-hex prefix that matches the SHA-1 of `"hunter2"`, and the request body is empty
#### Scenario: pwned password returns the documented count
- **GIVEN** the range response contains `1B41F11D33EAB4D6E16AAFA77B5BCD4A6BD:42` and the input's suffix matches that line
- **WHEN** `checkPassword(password)` is called
- **THEN** the result equals `{ pwned: true, count: 42 }`
#### Scenario: clean password returns zero count
- **GIVEN** the range response contains no line whose suffix matches the input's
- **WHEN** `checkPassword(password)` is called
- **THEN** the result equals `{ pwned: false, count: 0 }`
**Verified by:** `packages/security/hibp/src/range/range.test.ts::"sends only the 5-hex prefix in the URL"` AND `packages/security/hibp/src/range/range.test.ts::"returns pwned with documented count when suffix matches"` AND `packages/security/hibp/src/range/range.test.ts::"returns clean with zero count when no suffix matches"`
---
### Requirement: Mandatory `Add-Padding: true` header on every range request
The package SHALL set the `Add-Padding: true` request header on every call to `/range/<prefix>` regardless of caller configuration. This MUST NOT be configurable off. The header is required by the [Pwned Passwords padding contract](https://haveibeenpwned.com/API/v3#PwnedPasswordsPadding) to defeat response-size correlation attacks (see Cloudflare _Validating Leaked Passwords with k-Anonymity_).
#### Scenario: header is set on the default range call
- **WHEN** `checkPassword("anything")` is called with an injected `fetch`
- **THEN** the recorded `Request.headers.get("Add-Padding")` equals `"true"`
#### Scenario: header is set on the NTLM range call
- **WHEN** `checkPassword("anything", { mode: "ntlm" })` is called
- **THEN** the recorded `Request.headers.get("Add-Padding")` equals `"true"`
#### Scenario: header is set when differential-privacy mode is on
- **WHEN** `checkPassword("anything", { extraPrefixes: 3 })` is called
- **THEN** every one of the 4 recorded requests has `Add-Padding: true`
**Verified by:** `packages/security/hibp/src/range/range.test.ts::"sets Add-Padding true on default range call"` AND `packages/security/hibp/src/range/range.test.ts::"sets Add-Padding true on NTLM range call"` AND `packages/security/hibp/src/range/range.test.ts::"sets Add-Padding true on every differential-privacy decoy"`
---
### Requirement: NTLM mode opt-in
The package SHALL accept `mode: "sha1" | "ntlm"` on `checkPassword`. When `mode === "ntlm"`, the request URL SHALL append `?mode=ntlm` per the [Pwned Passwords range API](https://haveibeenpwned.com/API/v3#PwnedPasswords) and the password SHALL be NTLM-hashed (MD4 over UTF-16-LE) instead of SHA-1-hashed before slicing the prefix. When `mode` is unset or `"sha1"`, no query string is appended and SHA-1 is used.
#### Scenario: NTLM mode appends the documented query string
- **WHEN** `checkPassword("anything", { mode: "ntlm" })` is called with an injected `fetch`
- **THEN** the recorded URL ends with `/range/<prefix>?mode=ntlm`
#### Scenario: SHA-1 mode does not append a query string
- **WHEN** `checkPassword("anything")` (default mode) is called with an injected `fetch`
- **THEN** the recorded URL contains no `?` character
**Verified by:** `packages/security/hibp/src/range/range-mode.test.ts::"NTLM mode appends ?mode=ntlm"` AND `packages/security/hibp/src/range/range-mode.test.ts::"default SHA-1 mode does not append a query string"`
---
### Requirement: Streaming response parser
The package SHALL parse the `/range/<prefix>` response by streaming over `Response.body` (`ReadableStream<Uint8Array>`) through a `TextDecoderStream` and a newline framer. The parser SHALL NOT call `Response.text()` and SHALL NOT collect the response into a single string buffer. The parser SHALL emit `{ suffix, count }` records incrementally so that a 30 KiB padded response is processed in bounded memory.
#### Scenario: parser does not call Response.text
- **GIVEN** a stub `Response` whose `text()` method records calls
- **WHEN** `checkPassword(password)` consumes the response
- **THEN** `Response.text()` is called zero times
#### Scenario: 30 KiB padded response is decoded across multiple chunks
- **GIVEN** a `ReadableStream` that emits a 30 KiB body across 8 chunks of varying size with at least one chunk boundary mid-line
- **WHEN** the streaming parser processes the stream
- **THEN** every documented line is emitted exactly once, in order, with its `count` numerically equal to the documented value
**Verified by:** `packages/security/hibp/src/range/range-stream-parse.test.ts::"never calls Response.text"` AND `packages/security/hibp/src/range/range-stream-parse.test.ts::"decodes 30 KiB padded body across 8 chunks"`
---
### Requirement: Constant-time suffix compare
The package SHALL compare the input's 35-char suffix against every line in the range response with a constant-time equality check that touches every byte of the 35-char range and SHALL NOT short-circuit on the first mismatching character. The comparator SHALL be available as `constantTimeEqualsHex(a, b)` in `src/range/constant-time-compare.ts`.
#### Scenario: equal inputs touch every byte
- **GIVEN** an instrumented comparator that records the number of byte XORs performed
- **WHEN** two equal 35-char suffixes are compared
- **THEN** exactly 35 byte XORs are recorded and the comparator returns `true`
#### Scenario: differing inputs touch every byte
- **GIVEN** the same instrumented comparator
- **WHEN** two suffixes that differ only at index 0 are compared
- **THEN** exactly 35 byte XORs are recorded (no early exit) and the comparator returns `false`
#### Scenario: differential-timing test on 10 000 samples
- **WHEN** the comparator is exercised against 10 000 randomly chosen suffix pairs (half equal, half differing at random indices)
- **THEN** the absolute difference between the mean execution time of the equal samples and the mean execution time of the unequal samples is no more than 5 % of the overall mean
**Verified by:** `packages/security/hibp/src/range/constant-time-compare.test.ts::"equal inputs touch every byte"` AND `packages/security/hibp/src/range/constant-time-compare.test.ts::"differing inputs touch every byte"` AND `packages/security/hibp/src/range/constant-time-compare.test.ts::"mean execution time delta is within 5 percent over 10000 samples"`
---
### Requirement: Privacy assertion (URL + body byte-by-byte)
The package SHALL provide a privacy-leak assertion utility (`src/privacy/assert-no-leak.ts`) that scans a candidate outgoing `Request` URL and body byte-by-byte for (a) the full SHA-1 hex digest, (b) the full password, and (c) the configured email. A hit on any of the three SHALL throw `HibpError(HIBP.MALFORMED_RESPONSE)` carrying which class of leak was detected (without echoing the leaked value into the error message). The utility SHALL be opt-in via `HibpClientOptions.assertOutgoingRequestSafe?: boolean` for production hardening, and SHALL be exercised by the test suite on every public method.
#### Scenario: outgoing URL containing the full SHA-1 throws
- **GIVEN** the helper is configured with the SHA-1 of `"hunter2"`
- **WHEN** the helper inspects a `Request` whose URL contains the full SHA-1
- **THEN** the call throws `HibpError` with code `"HIBP.MALFORMED_RESPONSE"` and a tag identifying the leak class as `"sha1"`
#### Scenario: outgoing URL containing only the 5-hex prefix passes
- **WHEN** the helper inspects a `Request` whose URL ends with `/range/F3BBB`
- **THEN** the call returns without throwing
#### Scenario: outgoing body containing the email throws
- **GIVEN** the helper is configured with email `"user@example.com"`
- **WHEN** the helper inspects a `Request` whose body byte-stream contains those bytes
- **THEN** the call throws `HibpError` with code `"HIBP.MALFORMED_RESPONSE"` and a tag identifying the leak class as `"email"`
**Verified by:** `packages/security/hibp/src/privacy/assert-no-leak.test.ts::"throws when URL contains the full SHA-1"` AND `packages/security/hibp/src/privacy/assert-no-leak.test.ts::"passes when URL contains only the 5-hex prefix"` AND `packages/security/hibp/src/privacy/assert-no-leak.test.ts::"throws when body contains the email"` AND `packages/security/hibp/src/privacy/range-no-leak.spec-conformance.test.ts::"1000-iteration checkPassword leaks zero bytes past the 5-hex prefix"`
---
### Requirement: `getBreachesForAccount`
The package SHALL export `getBreachesForAccount(email, opts?)` which dispatches `GET https://haveibeenpwned.com/api/v3/breachedaccount/{email}?truncateResponse=false&includeUnverified=true` per the [v3 BreachesForAccount endpoint](https://haveibeenpwned.com/API/v3#BreachesForAccount). The email path segment SHALL be `encodeURIComponent`-applied. The email SHALL never be logged and SHALL never appear in any thrown error message. Responses SHALL be Zod-validated through `BreachSchema[]`; mismatches throw `HIBP.MALFORMED_RESPONSE`.
#### Scenario: URL is built with documented query parameters
- **WHEN** `getBreachesForAccount("user+test@example.com")` is called with an injected `fetch`
- **THEN** the recorded URL equals `https://haveibeenpwned.com/api/v3/breachedaccount/user%2Btest%40example.com?truncateResponse=false&includeUnverified=true`
#### Scenario: response is Zod-validated
- **GIVEN** the response body omits the `Name` field on an entry
- **WHEN** `getBreachesForAccount("user@example.com")` is called
- **THEN** the call throws `HibpError` with code `"HIBP.MALFORMED_RESPONSE"`
#### Scenario: 404 response yields the documented empty result
- **GIVEN** the API returns `404` (no breaches for this account, per the v3 contract)
- **WHEN** `getBreachesForAccount("clean@example.com")` is called
- **THEN** the call resolves to `[]`
**Verified by:** `packages/security/hibp/src/breach/breach-client.test.ts::"builds breachedaccount URL with documented query parameters"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"throws MALFORMED_RESPONSE when Breach payload is missing fields"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"resolves to empty array on 404"`
---
### Requirement: `getBreach` and `getAllBreaches`
The package SHALL export `getBreach(name): Promise<Breach>` against [`/breach/{name}`](https://haveibeenpwned.com/API/v3#SingleBreach) and `getAllBreaches(opts?): Promise<Breach[]>` against [`/breaches`](https://haveibeenpwned.com/API/v3#AllBreaches), with optional `domain` query parameter on `getAllBreaches`. Both methods SHALL Zod-validate responses through `BreachSchema`. `getBreach` SHALL throw `HibpError(HIBP.NOT_FOUND)` on `404`.
#### Scenario: getBreach returns a typed Breach
- **WHEN** `getBreach("Adobe")` is called against a recorded fixture
- **THEN** the result satisfies `BreachSchema` and `result.Name === "Adobe"`
#### Scenario: getBreach throws NOT_FOUND on 404
- **WHEN** `getBreach("DoesNotExist")` is called and the API returns `404`
- **THEN** the call throws `HibpError` with code `"HIBP.NOT_FOUND"`
#### Scenario: getAllBreaches forwards the domain filter
- **WHEN** `getAllBreaches({ domain: "adobe.com" })` is called with an injected `fetch`
- **THEN** the recorded URL ends with `/breaches?domain=adobe.com`
**Verified by:** `packages/security/hibp/src/breach/breach-client.test.ts::"getBreach returns a Zod-validated Breach"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"getBreach throws NOT_FOUND on 404"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"getAllBreaches forwards domain query parameter"`
---
### Requirement: `getPastesForAccount`
The package SHALL export `getPastesForAccount(email): Promise<Paste[]>` against [`/pasteaccount/{email}`](https://haveibeenpwned.com/API/v3#PastesForAccount). Email is `encodeURIComponent`-applied. Responses are Zod-validated through `PasteSchema[]`. The email is never logged.
#### Scenario: URL encodes the email path segment
- **WHEN** `getPastesForAccount("user+test@example.com")` is called with an injected `fetch`
- **THEN** the recorded URL ends with `/pasteaccount/user%2Btest%40example.com`
#### Scenario: 404 yields empty result
- **GIVEN** the API returns `404`
- **WHEN** `getPastesForAccount("clean@example.com")` is called
- **THEN** the call resolves to `[]`
**Verified by:** `packages/security/hibp/src/breach/breach-client.test.ts::"getPastesForAccount URL-encodes the email"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"getPastesForAccount resolves to empty array on 404"`
---
### Requirement: `hibp-api-key` header sourced from constructor option
The package SHALL set the `hibp-api-key` request header on every authenticated breach / paste call from `HibpClientOptions.apiKey`. The key SHALL NEVER be read from `process.env` or any module-scope variable, SHALL NEVER be logged, and SHALL NEVER appear in any thrown error's serialized form per the [v3 Authorisation contract](https://haveibeenpwned.com/API/v3#Authorisation).
#### Scenario: header is set from constructor option
- **GIVEN** a `HibpClient` constructed with `apiKey: "k_test"`
- **WHEN** `getBreachesForAccount("user@example.com")` is called
- **THEN** the recorded `Request.headers.get("hibp-api-key")` equals `"k_test"`
#### Scenario: api key is not present in any thrown error
- **GIVEN** the API returns `500`
- **WHEN** `getBreachesForAccount("user@example.com")` is called
- **THEN** the call throws `HibpError` and `JSON.stringify(error)` does not contain the literal `"k_test"`
#### Scenario: api key is not read at module scope
- **WHEN** the source file `src/breach/breach-client.ts` is grepped for `process.env`
- **THEN** the grep returns zero hits
**Verified by:** `packages/security/hibp/src/breach/breach-client.test.ts::"hibp-api-key header is set from constructor option"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"thrown error does not contain the api key"` AND `packages/security/hibp/src/privacy/breach-no-log.spec-conformance.test.ts::"api key never appears in any captured log line"`
---
### Requirement: 1500 ms client-side rate-limit gate
The package SHALL serialize calls to the breach + paste endpoints behind a `RateLimitGate` that enforces a minimum 1500 ms gap between consecutive calls per the [v3 RateLimiting contract](https://haveibeenpwned.com/API/v3#RateLimiting). The gate SHALL drive its delay against an injectable `Clock` from `@suaveplan/types`. Range API calls SHALL NOT pass through the gate (the range endpoint is unauthenticated and has no client-gap requirement). Every gate-induced wait SHALL increment the `hibp.rate_limit_throttled_total` counter.
#### Scenario: second call within the window blocks until release
- **GIVEN** an injectable `Clock` initialised at `t0`
- **WHEN** `getBreachesForAccount(a)` resolves at `t0` and `getBreachesForAccount(b)` is called at `t0 + 500 ms`
- **THEN** the second call's outgoing fetch is dispatched at `t0 + 1500 ms`
#### Scenario: range calls bypass the gate
- **GIVEN** the gate state is fully throttled
- **WHEN** `checkPassword(password)` is called
- **THEN** the range fetch is dispatched immediately without consulting the gate
#### Scenario: throttle counter increments on every blocked call
- **WHEN** three consecutive `getBreachesForAccount` calls fire within 200 ms of each other
- **THEN** `hibp.rate_limit_throttled_total` increments by exactly 2 (the second and third calls)
**Verified by:** `packages/security/hibp/src/breach/rate-limit-gate.test.ts::"second call within 1500ms window blocks until release"` AND `packages/security/hibp/src/breach/rate-limit-gate.test.ts::"range calls bypass the gate"` AND `packages/security/hibp/src/breach/rate-limit-gate.test.ts::"throttle counter increments per blocked call"`
---
### Requirement: 429 retry honouring `Retry-After` with exponential backoff capped at 60 s
The package SHALL retry breach + paste requests that return HTTP `429` per the [v3 RateLimiting contract](https://haveibeenpwned.com/API/v3#RateLimiting). The retry delay SHALL parse the `Retry-After` response header (delta-seconds form OR HTTP-date form). If the header is missing, exponential backoff via `@suaveplan/retry`'s `RetryPolicy` SHALL drive the delay using an injectable `Rng`. The delay SHALL be hard-capped at 60 000 ms regardless of header value. Once the cap is reached or `RetryPolicy.maxAttempts` is exhausted, `HibpError(HIBP.RATE_LIMITED)` SHALL be thrown.
#### Scenario: delta-seconds Retry-After is honoured
- **GIVEN** the API returns `429` with `Retry-After: 5`
- **WHEN** `getBreachesForAccount("user@example.com")` is called
- **THEN** the next attempt is dispatched 5 000 ms later
#### Scenario: HTTP-date Retry-After is honoured
- **GIVEN** the API returns `429` with `Retry-After: <HTTP-date 7 seconds in the future>`
- **WHEN** `getBreachesForAccount("user@example.com")` is called against an injectable clock
- **THEN** the next attempt is dispatched at the parsed instant ±50 ms
#### Scenario: delay is capped at 60 seconds
- **GIVEN** the API returns `429` with `Retry-After: 9999`
- **WHEN** `getBreachesForAccount("user@example.com")` is called
- **THEN** the next attempt is dispatched no later than 60 000 ms after the prior failure
#### Scenario: throws RATE_LIMITED on exhaustion
- **GIVEN** the API returns `429` for every attempt and `maxAttempts` is exhausted
- **WHEN** `getBreachesForAccount("user@example.com")` is called
- **THEN** the call throws `HibpError` with code `"HIBP.RATE_LIMITED"`
**Verified by:** `packages/security/hibp/src/breach/retry-after.test.ts::"honours delta-seconds Retry-After"` AND `packages/security/hibp/src/breach/retry-after.test.ts::"honours HTTP-date Retry-After"` AND `packages/security/hibp/src/breach/retry-after.test.ts::"caps backoff at 60 seconds"` AND `packages/security/hibp/src/breach/retry-after.test.ts::"throws RATE_LIMITED on exhaustion"`
---
### Requirement: In-memory LRU cache (default 10 000 entries, 24 h TTL)
The package SHALL provide a default `LruHibpCache` keyed by uppercase 5-hex SHA-1 prefix that holds up to 10 000 entries with a 24 h TTL. The TTL clock SHALL be the injectable `Clock`. Eviction order on capacity SHALL be least-recently-used. The cache SHALL be the default `HibpClient` cache when no `cache` option is supplied.
#### Scenario: capacity eviction at 10 000 entries
- **GIVEN** the cache holds 10 000 entries and a new key is inserted
- **WHEN** `set(newKey, value)` is called
- **THEN** the cache size remains 10 000 and the least-recently-used key is no longer present
#### Scenario: TTL eviction at 24 h
- **GIVEN** an entry inserted at `t0` against an injectable clock
- **WHEN** `get(key)` is called at `t0 + 24h + 1ms`
- **THEN** the result is `undefined`
#### Scenario: keys are normalised to uppercase hex
- **WHEN** `set("abcde", v)` is called and `get("ABCDE")` is then called
- **THEN** the second call returns `v`
**Verified by:** `packages/security/hibp/src/cache/lru.test.ts::"evicts least-recently-used at capacity"` AND `packages/security/hibp/src/cache/lru.test.ts::"evicts on TTL expiry"` AND `packages/security/hibp/src/cache/lru.test.ts::"normalises keys to uppercase hex"`
---
### Requirement: Pluggable cache adapter
The package SHALL accept any `HibpCacheLike<V>` implementation as `HibpClientOptions.cache`. The interface SHALL expose `get`, `set`, `delete`, and `size`. Passing `cache: null` SHALL disable caching (resolves to `NullHibpCache`). Omitting the option SHALL use `LruHibpCache` defaults.
#### Scenario: caller-supplied cache is consulted
- **GIVEN** a `HibpCacheLike` whose `get` records every key it sees
- **WHEN** `checkPassword("hunter2")` is called twice
- **THEN** the first call records the prefix as a miss followed by `set`, and the second records the same prefix as a hit
#### Scenario: cache: null disables caching
- **GIVEN** `HibpClient` constructed with `cache: null`
- **WHEN** `checkPassword("hunter2")` is called twice
- **THEN** two range fetches are recorded (no second-call hit)
**Verified by:** `packages/security/hibp/src/client/client.test.ts::"caller-supplied cache is consulted on hit and miss"` AND `packages/security/hibp/src/client/client.test.ts::"cache null disables caching"`
---
### Requirement: Strict-mode response framing
The package SHALL operate in strict response-framing mode by default. Strict mode SHALL reject any range-response line that does not match `/^[0-9A-F]{35}:[0-9]+$/` per the [Pwned Passwords range API](https://haveibeenpwned.com/API/v3#PwnedPasswords) framing contract. Rejection SHALL throw `HibpError(HIBP.MALFORMED_RESPONSE)` carrying the offending line index. Strict mode SHALL be configurable off via `HibpClientOptions.strict: false`; off-mode SHALL accept lowercase hex and SHALL default an unparseable count to `0`.
#### Scenario: strict mode rejects lowercase hex
- **WHEN** the response contains `1b41f11d33eab4d6e16aafa77b5bcd4a6bd:1`
- **THEN** the call throws `HibpError` with code `"HIBP.MALFORMED_RESPONSE"`
#### Scenario: strict mode rejects malformed count
- **WHEN** the response contains `1B41F11D33EAB4D6E16AAFA77B5BCD4A6BD:notanumber`
- **THEN** the call throws `HibpError` with code `"HIBP.MALFORMED_RESPONSE"`
#### Scenario: off-mode accepts lowercase hex
- **GIVEN** `HibpClient` constructed with `strict: false`
- **WHEN** the response contains `1b41f11d33eab4d6e16aafa77b5bcd4a6bd:1`
- **THEN** the call resolves with `pwned: true, count: 1`
**Verified by:** `packages/security/hibp/src/range/range-strict-frame.test.ts::"strict mode rejects lowercase hex"` AND `packages/security/hibp/src/range/range-strict-frame.test.ts::"strict mode rejects non-numeric count"` AND `packages/security/hibp/src/range/range-strict-frame.test.ts::"off-mode accepts lowercase hex"`
---
### Requirement: Differential-privacy mode (decoy prefixes)
The package SHALL accept `HibpClientOptions.differentialPrivacy?: { extraPrefixes: number }`. When `extraPrefixes >= 1`, every `checkPassword` call SHALL submit the real prefix plus N additional random sibling prefixes drawn from an injectable `Rng`. The real prefix's response is the only one consulted for the result; decoy responses are discarded. Default behaviour (`extraPrefixes` absent or `0`) SHALL submit only the real prefix. The `Add-Padding: true` header SHALL be set on every decoy request.
#### Scenario: decoy count matches the option
- **WHEN** `checkPassword("anything", undefined)` is called against a client with `differentialPrivacy: { extraPrefixes: 3 }`
- **THEN** the recorder captures exactly 4 outgoing fetches
#### Scenario: real prefix is always present in the dispatched set
- **GIVEN** a deterministic `Rng` returning a fixed sequence of decoys
- **WHEN** `checkPassword(password)` is called
- **THEN** the set of dispatched URLs contains the real `/range/<prefix>` exactly once
#### Scenario: zero extra prefixes is a single dispatch
- **WHEN** `checkPassword("anything")` is called against a client with `differentialPrivacy: { extraPrefixes: 0 }` (or option absent)
- **THEN** the recorder captures exactly 1 outgoing fetch
**Verified by:** `packages/security/hibp/src/range/differential-privacy.test.ts::"dispatches real plus N decoys when extraPrefixes is N"` AND `packages/security/hibp/src/range/differential-privacy.test.ts::"real prefix is always present in the dispatched set"` AND `packages/security/hibp/src/range/differential-privacy.test.ts::"zero extra prefixes is a single dispatch"`
---
### Requirement: Error registry — seven HIBP codes
The package SHALL export an error code registry via `defineCodes("HIBP", [...])` containing exactly: `HIBP.RATE_LIMITED`, `HIBP.UNAUTHORIZED`, `HIBP.NOT_FOUND`, `HIBP.MALFORMED_RESPONSE`, `HIBP.NETWORK_ERROR`, `HIBP.ABORTED`, `HIBP.PAYLOAD_TOO_LARGE`. Every code SHALL be thrown by at least one production path. `HibpError extends SuaveplanError<HibpErrorCode>` SHALL serialize to a stable JSON form omitting the `apiKey`, the email, the password, and the full SHA-1 hash.
#### Scenario: every code value is present
- **WHEN** `Object.keys(HIBP_CODES)` is enumerated
- **THEN** the result equals the seven documented codes (set equality)
#### Scenario: every code is thrown by a production path
- **WHEN** the source tree is grepped for `throw new HibpError(HIBP_CODES.<code>)` for each code
- **THEN** each code has at least one production-path call site (excluding test fixtures)
#### Scenario: HibpError JSON form omits sensitive fields
- **GIVEN** a `HibpClient` constructed with `apiKey: "k_test"` configured for email `"user@example.com"`
- **WHEN** an authenticated call fails and `JSON.stringify(error)` is computed
- **THEN** the serialized string does not contain `"k_test"`, `"user@example.com"`, or any 40-char hex string
**Verified by:** `packages/security/hibp/src/errors/codes.test.ts::"registry contains exactly the seven documented codes"` AND `packages/security/hibp/src/errors/hibp-error.test.ts::"JSON form omits api key, email, password, and full hash"` AND `packages/security/hibp/src/errors/codes.test.ts::"every code is thrown by at least one production path"`
---
### Requirement: Zod boundary schemas
The package SHALL Zod-validate every public boundary. `BreachSchema` SHALL mirror the [v3 `Breach` payload](https://haveibeenpwned.com/API/v3#Breach) covering `Name`, `Title`, `Domain`, `BreachDate`, `AddedDate`, `ModifiedDate`, `PwnCount`, `Description`, `LogoPath`, `DataClasses[]`, `IsVerified`, `IsFabricated`, `IsSensitive`, `IsRetired`, `IsSpamList`, `IsMalware`, `IsSubscriptionFree`, `IsStealerLog`. `PasteSchema` SHALL mirror `Source`, `Id`, `Title?`, `Date?`, `EmailCount`. `PwnedResultSchema` SHALL be `{ pwned: boolean, count: int >= 0 }`. `HibpClientOptionsSchema` SHALL be a strict object rejecting unknown keys, empty `apiKey`, and negative `extraPrefixes`.
#### Scenario: BreachSchema accepts a recorded Adobe fixture
- **GIVEN** the recorded `/breach/Adobe` fixture
- **WHEN** `BreachSchema.parse(fixture)` is called
- **THEN** the call returns the parsed value with no error
#### Scenario: PwnedResultSchema rejects negative counts
- **WHEN** `PwnedResultSchema.parse({ pwned: false, count: -1 })` is called
- **THEN** the call throws
#### Scenario: HibpClientOptionsSchema rejects unknown keys
- **WHEN** `HibpClientOptionsSchema.parse({ apiKey: "k", unexpected: true })` is called
- **THEN** the call throws
**Verified by:** `packages/security/hibp/src/schemas/breach.schema.test.ts::"accepts recorded Adobe fixture"` AND `packages/security/hibp/src/schemas/pwned-result.schema.test.ts::"rejects negative counts"` AND `packages/security/hibp/src/client/options.schema.test.ts::"rejects unknown keys"`
---
### Requirement: AbortSignal threaded through every public method
The package SHALL accept `signal?: AbortSignal` on every public async method (`checkPassword`, `getBreachesForAccount`, `getBreach`, `getAllBreaches`, `getPastesForAccount`). A pre-aborted signal SHALL throw `HibpError(HIBP.ABORTED)` before any fetch is dispatched. A mid-flight abort SHALL propagate to the underlying `fetch` `RequestInit.signal` and SHALL terminate the streaming parser.
#### Scenario: pre-aborted signal throws ABORTED before fetch
- **GIVEN** an `AbortSignal` whose `aborted === true`
- **WHEN** `checkPassword("anything", { signal })` is called against a recording fetch
- **THEN** zero fetches are recorded and the call throws `HibpError` with code `"HIBP.ABORTED"`
#### Scenario: mid-flight abort terminates the streaming parser
- **GIVEN** a stream that emits 4 chunks separated by 10 ms each and an `AbortController` aborted after the first chunk
- **WHEN** `checkPassword("anything", { signal })` is called
- **THEN** the call throws `HibpError` with code `"HIBP.ABORTED"` and the underlying stream reader is released
#### Scenario: AbortSignal threaded into RequestInit
- **GIVEN** a recording fetch that captures `RequestInit.signal`
- **WHEN** `getBreachesForAccount("user@example.com", { signal })` is called
- **THEN** the captured `RequestInit.signal === signal` (reference equality)
**Verified by:** `packages/security/hibp/src/range/range.test.ts::"pre-aborted signal throws ABORTED before fetch"` AND `packages/security/hibp/src/range/range-stream-parse.test.ts::"mid-flight abort terminates the streaming parser"` AND `packages/security/hibp/src/breach/breach-client.test.ts::"AbortSignal is threaded into RequestInit"`
---
### Requirement: OpenTelemetry observability surface
The package SHALL emit two histograms — `hibp.range.duration_ms` and `hibp.breach.duration_ms` — recording wall-clock duration on every range and breach/paste call respectively. The package SHALL emit three counters: `hibp.range.outcome_total{outcome}` with `outcome ∈ {pwned, clean, error}`; `hibp.errors_total{code}` with one of the seven `HIBP.*` code values; and `hibp.rate_limit_throttled_total` incremented on every gate-induced wait. Histograms and counters SHALL be acquired against `metrics.getMeter("@suaveplan/hibp")` from `@opentelemetry/api`.
#### Scenario: range histogram records on success
- **GIVEN** a real in-memory `MeterProvider`
- **WHEN** `checkPassword("anything")` resolves
- **THEN** `hibp.range.duration_ms` has exactly one recording with the elapsed wall-clock duration
#### Scenario: outcome counter records `pwned` and `clean` correctly
- **WHEN** one pwned and one clean call are made
- **THEN** `hibp.range.outcome_total{outcome="pwned"}` equals 1 and `hibp.range.outcome_total{outcome="clean"}` equals 1
#### Scenario: errors counter increments on every code
- **WHEN** a `429` triggers `RATE_LIMITED` and a malformed body triggers `MALFORMED_RESPONSE`
- **THEN** `hibp.errors_total{code="HIBP.RATE_LIMITED"}` equals 1 and `hibp.errors_total{code="HIBP.MALFORMED_RESPONSE"}` equals 1
**Verified by:** `packages/security/hibp/src/observability/metrics.test.ts::"range histogram records on success"` AND `packages/security/hibp/src/observability/metrics.test.ts::"outcome counter splits pwned and clean"` AND `packages/security/hibp/src/observability/metrics.test.ts::"errors counter increments per code"` AND `packages/security/hibp/src/breach/rate-limit-gate.test.ts::"throttle counter increments per blocked call"`
---
### Requirement: Subpath exports
The package SHALL declare subpath exports so consumers can tree-shake to the surface they need. The exports map SHALL include `.`, `./range/range`, `./range/range-mode`, `./breach/breach-client`, `./cache/cache`, `./cache/lru`, `./cache/null`, `./client/client`, `./client/options.schema`, `./errors/codes`, `./errors/hibp-error`, `./schemas/breach.schema`, `./schemas/paste.schema`, `./schemas/pwned-result.schema`, and `./observability/metrics`.
#### Scenario: every documented subpath resolves
- **WHEN** every documented subpath is imported in a consumer test fixture
- **THEN** every import resolves with no `Module not found` errors
#### Scenario: root barrel uses explicit named re-exports
- **WHEN** `src/index.ts` is grepped for `^export \\*`
- **THEN** the grep returns zero hits (per CLAUDE.md rule 10)
**Verified by:** `packages/security/hibp/src/index.test.ts::"every documented subpath export resolves"` AND `packages/security/hibp/src/index.test.ts::"root barrel uses explicit named re-exports"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,153 @@
# Tasks — `@suaveplan/hibp` (privacy-preserving HIBP client)
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/hibp.md` end-to-end
- [x] 1.2 Read [HIBP API v3](https://haveibeenpwned.com/API/v3) — every endpoint, header, rate-limit, status-code anchor
- [x] 1.3 Read [Pwned Passwords range API](https://haveibeenpwned.com/API/v3#PwnedPasswords) and [`Add-Padding` notes](https://haveibeenpwned.com/API/v3#PwnedPasswordsPadding)
- [x] 1.4 Read Cloudflare _Validating Leaked Passwords with k-Anonymity_ post for the privacy threat model
- [x] 1.5 Read `openspec/AGENTS.md` Sections 13, 14, 14a
- [x] 1.6 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.7 Fill in `## Source mapping` table — every requirement cites either an `enterprise/hibp.md` line range or a documented API v3 anchor
- [x] 1.8 Draft `specs/hibp/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.9 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
- [x] 1.10 `bun scripts/openspec-conformance.ts @suaveplan/hibp` clean
## 2. Package scaffold
- [x] 2.1 `packages/security/hibp/src/{range,breach,cache,client,errors,observability,privacy,schemas}/` directories created
- [x] 2.2 `package.json` — `@suaveplan/hibp@0.0.0`, `type: module`, `sideEffects: false`, deps via catalog/workspace refs (`@suaveplan/crypto-hash`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/retry`, `@suaveplan/timing`, `@opentelemetry/api`, `tslib`, `zod`)
- [x] 2.3 `tsconfig.lib.json` extends `@suaveplan/typescript-config/library.json`
- [x] 2.4 `vite.config.ts` and `vitest.config.ts` (latter via `createTestConfig()` from `@suaveplan/testing/config`)
- [x] 2.5 `README.md` with package description, install snippet, quick-start, and `## Modules` table linking every co-located `.md`
## 3. Implementation — range API (k-anonymity)
- [x] 3.1 `src/range/range.ts` — `checkPassword(password, opts?)` SHA-1s the input, slices first 5 hex chars, dispatches range fetch, returns `{ pwned, count }`
- [x] 3.2 `src/range/range.ts` — sets `Add-Padding: true` on every outgoing request (mandatory; not configurable off)
- [x] 3.3 `src/range/range-mode.ts` — `RangeMode = "sha1" | "ntlm"`; NTLM appends `?mode=ntlm` and routes to the NTLM hash adapter
- [x] 3.4 `src/range/range-stream-parse.ts` — line streamer over `Response.body` via `TextDecoderStream` and newline framing; never collects to a single string
- [x] 3.5 `src/range/range-strict-frame.ts` — strict-mode validator rejecting lowercase hex / non-`/^[0-9A-F]{35}:[0-9]+$/` lines with `HIBP.MALFORMED_RESPONSE`
- [x] 3.6 `src/range/constant-time-compare.ts` — `constantTimeEqualsHex(a, b)` XOR-folds the full 35-char range with no early exit
- [x] 3.7 `src/range/differential-privacy.ts` — `padPrefixes(real, n, rng)` returning real plus N uniformly random sibling prefixes
- [x] 3.8 AbortSignal threaded through `checkPassword` and the inner streaming parser
## 4. Implementation — authenticated breach / paste API
- [x] 4.1 `src/breach/breach-client.ts` — `getBreachesForAccount(email, opts?)` builds `https://haveibeenpwned.com/api/v3/breachedaccount/{email}?truncateResponse=false&includeUnverified=true`; email URL-encoded; `hibp-api-key` header set
- [x] 4.2 `src/breach/breach-client.ts` — `getBreach(name)` against `/breach/{name}`
- [x] 4.3 `src/breach/breach-client.ts` — `getAllBreaches(opts?)` against `/breaches` with optional `domain` query param
- [x] 4.4 `src/breach/breach-client.ts` — `getPastesForAccount(email)` against `/pasteaccount/{email}`
- [x] 4.5 `src/breach/rate-limit-gate.ts` — `RateLimitGate` enforcing 1500 ms minimum gap on breach + paste endpoints; injectable `Clock`
- [x] 4.6 `src/breach/retry-after.ts` — `parseRetryAfter` honours both delta-seconds and HTTP-date forms; integrates with `@suaveplan/retry` `RetryPolicy`; cap at 60 s
- [x] 4.7 `src/breach/breach-client.ts` — never logs the email, never logs `hibp-api-key`, never echoes them into thrown errors
- [x] 4.8 AbortSignal threaded through every public method
## 5. Implementation — cache layer
- [x] 5.1 `src/cache/cache.ts` — `HibpCacheLike<V>` interface (`get`, `set`, `delete`, `size`)
- [x] 5.2 `src/cache/lru.ts` — `LruHibpCache` with default 10 000 entries, 24 h TTL, key normalization to uppercase hex
- [x] 5.3 `src/cache/null.ts` — `NullHibpCache` always-miss adapter
- [x] 5.4 `HibpClientOptions.cache?: HibpCacheLike` accepted; `cache: null` disables; absent uses `LruHibpCache` default
## 6. Implementation — schemas and errors
- [x] 6.1 `src/schemas/breach.schema.ts` — `BreachSchema` matching API v3 `Breach` shape (every field, including `IsStealerLog`)
- [x] 6.2 `src/schemas/paste.schema.ts` — `PasteSchema` matching API v3 `Paste` shape
- [x] 6.3 `src/schemas/pwned-result.schema.ts` — `PwnedResultSchema` (`pwned: boolean`, `count: int >= 0`)
- [x] 6.4 `src/client/options.schema.ts` — `HibpClientOptionsSchema` strict object; rejects `apiKey === ""`, `extraPrefixes < 0`, unknown keys
- [x] 6.5 `src/errors/codes.ts` — `defineCodes("HIBP", [...])` with all seven codes (`RATE_LIMITED`, `UNAUTHORIZED`, `NOT_FOUND`, `MALFORMED_RESPONSE`, `NETWORK_ERROR`, `ABORTED`, `PAYLOAD_TOO_LARGE`)
- [x] 6.6 `src/errors/hibp-error.ts` — `HibpError extends SuaveplanError<HibpErrorCode>` with stable JSON form
- [x] 6.7 Every code in the registry thrown by ≥1 production path (no decorative entries)
## 7. Implementation — observability
- [x] 7.1 `src/observability/metrics.ts` — `hibp.range.duration_ms` and `hibp.breach.duration_ms` histograms
- [x] 7.2 `src/observability/metrics.ts` — `hibp.range.outcome_total{outcome=pwned|clean|error}` counter
- [x] 7.3 `src/observability/metrics.ts` — `hibp.errors_total{code}` counter
- [x] 7.4 `src/observability/metrics.ts` — `hibp.rate_limit_throttled_total` counter
- [x] 7.5 Histograms recorded on every `checkPassword`, `getBreachesForAccount`, `getBreach`, `getAllBreaches`, `getPastesForAccount` exit path
## 8. Implementation — privacy assertions
- [x] 8.1 `src/privacy/assert-no-leak.ts` — scans an outgoing `Request` URL and body byte-by-byte for the full SHA-1 hash, the full password, and the configured email
- [x] 8.2 `HibpClientOptions.assertOutgoingRequestSafe?: boolean` opt-in production gate (default off, defence-in-depth)
- [x] 8.3 Privacy assertion thrown errors carry `HIBP.MALFORMED_RESPONSE` (privacy violation is a malformed-self-state condition)
## 9. Client orchestrator + barrels
- [x] 9.1 `src/client/client.ts` — `HibpClient` composes range + breach + cache + rate-limit + observability + privacy
- [x] 9.2 Constructor takes `HibpClientOptions` (api key, fetch, clock, rng, cache, rate-limit gate, observability, strict mode, differential-privacy mode, opt-in privacy assertions)
- [x] 9.3 `src/index.ts` — explicit named re-exports across all sub-namespaces (no `export *` per CLAUDE.md rule 10)
- [x] 9.4 `package.json` `exports` map covers every documented subpath
## 10. Documentation
- [x] 10.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [x] 10.2 `README.md` `## Modules` section links every co-located `.md`
- [x] 10.3 README documents the privacy contract verbatim (full password and full hash never leave the function)
- [x] 10.4 README documents the differential-privacy mode trade-offs
## 11. Tests (runner-neutral imports per CLAUDE.md rule 11)
- [x] 11.1 `range/range.test.ts` — k-anonymity slice, `Add-Padding` header, NTLM mode, OTel emission, AbortSignal
- [x] 11.2 `range/range-stream-parse.test.ts` — multi-chunk 30 KiB streaming decode without single-string buffering
- [x] 11.3 `range/range-strict-frame.test.ts` — strict-mode rejects lowercase / malformed `:count` / non-uppercase hex
- [x] 11.4 `range/range-mode.test.ts` — NTLM URL + adapter routing
- [x] 11.5 `range/constant-time-compare.test.ts` — every byte touched on both equal and unequal inputs; differential-timing test
- [x] 11.6 `range/differential-privacy.test.ts` — `padPrefixes` returns N+1 entries with real present
- [x] 11.7 `breach/breach-client.test.ts` — every endpoint URL, query, header; AbortSignal; Zod validation
- [x] 11.8 `breach/rate-limit-gate.test.ts` — 1500 ms gate; injectable clock; `hibp.rate_limit_throttled_total` increments
- [x] 11.9 `breach/retry-after.test.ts` — delta-seconds and HTTP-date forms; 60 s cap; `@suaveplan/retry` integration
- [x] 11.10 `cache/cache.test.ts` — interface type-level conformance
- [x] 11.11 `cache/lru.test.ts` — capacity eviction; TTL eviction; uppercase normalization
- [x] 11.12 `cache/null.test.ts` — always-miss; `set` is a no-op
- [x] 11.13 `client/client.test.ts` — full composition; `cache: null` disables; default `LruHibpCache`
- [x] 11.14 `client/options.schema.test.ts` — every Zod rejection path
- [x] 11.15 `schemas/breach.schema.test.ts` — recorded `/breach/Adobe` fixture round-trip
- [x] 11.16 `schemas/paste.schema.test.ts` — recorded `/pasteaccount/...` fixture round-trip
- [x] 11.17 `schemas/pwned-result.schema.test.ts` — accepts valid; rejects negative count
- [x] 11.18 `errors/codes.test.ts` — registry shape, no duplicate keys
- [x] 11.19 `errors/hibp-error.test.ts` — JSON serialization, `code` + `cause` carriage
- [x] 11.20 `observability/metrics.test.ts` — every histogram and counter against an in-memory `MeterProvider`
- [x] 11.21 `privacy/assert-no-leak.test.ts` — full SHA-1 → throws; prefix-only → passes; full password → throws; email → throws
- [x] 11.22 `privacy/range-no-leak.spec-conformance.test.ts` — 1 000-iteration `checkPassword("hunter2")` interception; zero leaks across all outgoing URLs and bodies
- [x] 11.23 `privacy/breach-no-log.spec-conformance.test.ts` — `LoggerLike` capture; email never appears in logs
- [x] 11.24 `index.test.ts` — every documented subpath export resolves
- [x] 11.25 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for network-bound integration cases — none expected since fetch is injected)
- [x] 11.26 Runner-neutral imports — CLAUDE.md §11 grep clean
## 12. Benchmarks (mitata)
- [x] 12.1 `src/__benchmarks__/range-cold.bench.ts` — cold range fetch + parse
- [x] 12.2 `src/__benchmarks__/range-warm-cache.bench.ts` — LRU hit path
- [x] 12.3 `src/__benchmarks__/range-parse-30kib.bench.ts` — streaming parser throughput on a 30 KiB padded fixture
- [x] 12.4 Baseline numbers captured in README
## 13. Pre-archive verification (the 5-gate triple)
- [x] 13.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info
- [x] 13.2 `bunx turbo lint typecheck test --filter=@suaveplan/hibp` — green
- [x] 13.3 `bunx turbo test:coverage --filter=@suaveplan/hibp` — 100/100/100/100
- [x] 13.4 `bun scripts/openspec-conformance.ts @suaveplan/hibp` — exit 0, no `error` findings
- [x] 13.5 `bunx turbo build --filter=@suaveplan/hibp` — green
## 14. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 14.1 Generics threaded; `expectTypeOf` assertions on `HibpCacheLike<V>` and `HibpClientOptions`
- [x] 14.2 Zod schemas wired at every declared boundary (range result, breach response, paste response, options)
- [x] 14.3 Every error code in `errors/codes.ts` thrown by at least one production path
- [x] 14.4 Strict-mode framing tested both on (default) and off
- [x] 14.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
- [x] 14.6 Lifecycle dedup — N/A (`HibpClient` is stateless beyond its cache; cache `set` is idempotent and tested)
- [x] 14.7 No retroactive tick — every box above reflects work done in this change
## 15. Archive
- [x] 15.1 Fold spec delta into `openspec/specs/hibp/spec.md`
- [x] 15.2 Move `openspec/changes/enterprise-hibp/` → `openspec/archive/<yyyy-mm-dd>-enterprise-hibp/`
- [x] 15.3 Refresh `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts`)
- [x] 15.4 Update `STATUS.md` row from Pending → Completed
- [x] 15.5 Update `CLAUDE.md` package listing under `#### Security / supply chain`