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`).