Files
2026-05-27 23:29:53 +01:00

18 KiB

@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 corpus. The two relevant endpoints are the Pwned Passwords range API (GET https://api.pwnedpasswords.com/range/<5-char-prefix>, no API key required, k-anonymity) and the authenticated breach / paste API (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 (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 (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 anchor it implements.

Requirement Source API anchor / line range Notes
Range API k-anonymity check enterprise/hibp.md §3 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; plan L31 Mitigates response-size correlation attacks; see Cloudflare k-anonymity post
NTLM mode opt-in enterprise/hibp.md §3 API v3 §PwnedPasswords (?mode=ntlm query); plan L32 For Windows-credential workflows
Streaming response parser enterprise/hibp.md §3 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; plan L37 truncateResponse=false&includeUnverified=true; email URL-encoded; never logged
getBreach(name) enterprise/hibp.md §3 API v3 §SingleBreach; plan L38
getAllBreaches enterprise/hibp.md §3 API v3 §AllBreaches; plan L39
getPastesForAccount enterprise/hibp.md §3 API v3 §PastesForAccount; plan L40
hibp-api-key header from constructor enterprise/hibp.md §3 API v3 §Authorisation; plan L41 Never read at module scope
1500 ms client-side rate-limit gate enterprise/hibp.md §3 API v3 §RateLimiting; plan L42 Injectable clock
429 + Retry-After honoured enterprise/hibp.md §3 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 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, API v3 §Paste; 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).