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, sendGET https://api.pwnedpasswords.com/range/<prefix>withAdd-Padding: true, scan response for the matching 35-char suffix, return{ pwned, count }. - ADDED:
src/range/range-stream-parse.ts— line-streaming parser overResponse.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]+$/, raisingHIBP.MALFORMED_RESPONSEwith the offending line index. - ADDED:
src/range/range-mode.ts—RangeMode = "sha1" | "ntlm"with the NTLM variant appending?mode=ntlmto the URL and hashing via the NTLM adapter (caller-supplied if Node-onlycrypto.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-indifferentialPrivacy: { extraPrefixes: N }mode. - ADDED:
src/breach/breach-client.ts—getBreachesForAccount(email, opts?),getBreach(name),getAllBreaches(opts?),getPastesForAccount(email)against the authenticatedhttps://haveibeenpwned.com/api/v3/*endpoints. - ADDED:
src/breach/rate-limit-gate.ts—RateLimitGate(1500 ms minimum gap on breach / paste) backed byClockfrom@suaveplan/types. - ADDED:
src/breach/retry-after.ts—parseRetryAfter(headerValue, now)returning{ delayMs }honouring both delta-seconds and HTTP-date forms;runWithRetryAfterdrives@suaveplan/retry'sRetryPolicycapped 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 passescache: null). - ADDED:
src/client/client.ts—HibpClientconstructor takingHibpClientOptions(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; rejectsapiKey === ""; rejectsextraPrefixes < 0; raisesHIBP.MALFORMED_RESPONSEwhen downstream JSON fails the schema. - ADDED:
src/schemas/breach.schema.ts—BreachSchemaZod boundary mirroring the v3Breachshape (Name,Title,Domain,BreachDate,AddedDate,ModifiedDate,PwnCount,Description,LogoPath,DataClasses[],IsVerified,IsFabricated,IsSensitive,IsRetired,IsSpamList,IsMalware,IsSubscriptionFree,IsStealerLog). - ADDED:
src/schemas/paste.schema.ts—PasteSchemamirroring the v3Pasteshape (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_msandhibp.breach.duration_mshistograms;hibp.range.outcome_total{outcome=pwned|clean|error},hibp.errors_total{code},hibp.rate_limit_throttled_totalcounters. - ADDED:
src/privacy/assert-no-leak.ts— runtime helper used in tests (and optionally in production viaassertOutgoingRequestSafe: true) that scans every outgoingRequestURL + body byte-by-byte for the full SHA-1 hash, the full password, and the configured email, throwingHIBP.MALFORMED_RESPONSEon 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—HibpCacheLikeinterface./cache/lru—LruHibpCache./client/client—HibpClientorchestrator +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:^—RetryPolicyfor the 429 backoff loop.@suaveplan/timing workspace:^—RateLimitGateruns 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/vaultto callHibpClient— 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
LruHibpCacheis in-memory. Callers that need persistence inject aHibpCacheLikeadapter (e.g. backed by@suaveplan/storage). - Auto-refreshing the breach catalogue —
getAllBreachesis 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: trueheader asserted on every fetch;NTLMmode 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=ntlmand 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;realis 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-keyheader 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_totalcounter increments.src/breach/retry-after.test.ts— delta-seconds and HTTP-date forms; cap at 60 s; integrates with@suaveplan/retryRetryPolicy.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 returnsundefined;setis a no-op.src/client/client.test.ts— composes range + breach + cache + rate-limit + observability; default options; explicitcache: nulldisables cache.src/client/options.schema.test.ts— every Zod path: emptyapiKey, negativeextraPrefixes, unknown keys.src/schemas/breach.schema.test.ts— round-trip a real HIBP/breach/Adobepayload (recorded fixture, not live).src/schemas/paste.schema.test.ts— round-trip a real/pasteaccount/test@example.compayload (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—HibpErrorcarriescode,cause, JSON serializes deterministically.src/observability/metrics.test.ts— every histogram + counter recorded by an in-memoryMeterProvider.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— repeatscheckPassword("hunter2")1 000 times, interceptingfetch; 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 aLoggerLikethroughHibpClient, callsgetBreachesForAccount("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).