hazo_secure 0.5.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGE_LOG.md ADDED
@@ -0,0 +1,171 @@
1
+ # Change Log
2
+
3
+ ## 1.1.0 — 2026-05-29
4
+
5
+ ### hazo workspace v2 — Wave 2 migration
6
+
7
+ Additive workspace standardisation. Existing 1.0.x API surface unchanged:
8
+ `SafeFetchError`, `CryptoError`, `CsrfError`, `GdprRegistryError` keep their
9
+ constructors and `err.code` short discriminators; consumer `instanceof`
10
+ checks continue to work.
11
+
12
+ - **`hazo_core` required peer dep** (`^1.0.0`). All typed errors now extend
13
+ `HazoError` so they participate in `HazoError.is(err)` cross-package
14
+ checks and the standard envelope shape (httpStatus + retryable derived
15
+ per-code). Bare `throw new Error(...)` sites in
16
+ `src/ratelimit/store-connect.ts` replaced by `HazoConfigError` /
17
+ `HazoValidationError` with `HAZO_SECURE_RATELIMIT_*` codes.
18
+ - **Logger** — new `src/utils/logger.ts` resolves to
19
+ `createLogger('hazo_secure')` from `hazo_core`. Root export adds
20
+ `get_logger`, `set_logger`, `noop_logger`. The legacy `NoopLogger`
21
+ alias is kept as a deprecated re-export.
22
+ - **Correlation IDs** — `safeFetch` injects the workspace `x-request-id`
23
+ header on outbound calls when a context correlation ID is available
24
+ (caller-set headers always win).
25
+ - **INI config** — new `config/hazo_secure_config.ini.sample` covers the
26
+ `[general]`, `[log.overrides]`, `[fetch]`, `[ratelimit]`, `[csrf]`
27
+ sections. Loader: `src/lib/config/hazo_secure_config.ts ::
28
+ getSecureConfig()`. Env-var overrides: `HAZO_SECURE_<SECTION>_<KEY>`.
29
+ - **`db_setup_*.sql` filled out** — `hazo_rl_buckets` schema is now
30
+ inlined into both root setup files (idempotent, postgres ends with
31
+ `NOTIFY pgrst, 'reload schema';`). `migrations/001_hazo_rl_buckets.sql`
32
+ retained as the historical record.
33
+ - **`design/feature_requests/INDEX.md`** added (S16).
34
+ - **Optional-import discipline** — `ConnectRateLimitStore` now uses
35
+ `optional_import` from `hazo_core` to resolve `hazo_connect/server`
36
+ with a clear error envelope if the peer is missing.
37
+ - **Dispatcher consistency** — `validateConnectIp` throws
38
+ `SafeFetchError("private_ip_blocked")` (instead of a bare `Error` with
39
+ `code: 'SSRF_PRIVATE_IP'`); the legacy `ssrfCode` field is attached
40
+ for tooling that grepped for it.
41
+ - **package.json** — `engines` block removed (Node-version policy lives
42
+ in workspace docs). Canonical version pins for `@types/node`,
43
+ `tsup`. `hazo_core`, `hazo_logs`, `hazo_connect`, `hazo_jobs`,
44
+ `hazo_files`, `hazo_ui` declared as peer + devDep.
45
+
46
+ ## 1.0.1 — 2026-05-24
47
+
48
+ ### Test-app — expanded interactive scenarios
49
+
50
+ No library API changes. All improvements are inside `test-app/`.
51
+
52
+ - **Fetch page** — added 3 interactive scenario buttons: redirect chain (3 hops of 302 → 200), redirect-limit-exceeded (maxRedirects: 1 triggers error), and timeout (1 s against a 5 s endpoint). Fetch API route now absolutifies relative URLs and forwards optional `maxRedirects` / `timeoutMs` per-request.
53
+ - **Crypto page** — added 3 new sections: key-rotation walkthrough (encrypt v1 → rotate to v2 → verify v1 still decrypts → encrypt v2), version-mismatch demo (tampers `keyId` → expects `key_not_found`), and HttpKeyProvider demo (encrypts via mock KMS at `/api/kms`, decrypts from cached key).
54
+ - **GDPR page** — added dry-run export panel (calls `dryRunExport` — filenames only, no data) and live audit-log panel (registry wired with `auditIntent`; every export/anonymise/dry-run appends an entry; clear button resets).
55
+ - **Token-bucket page (new)** — side-by-side `MemoryRateLimitStore` (fixed-window) vs `ConnectRateLimitStore` (token-bucket via in-memory `_svc`). "Fire 1" and "Burst × 8" buttons. Demonstrates partial recovery after wait.
56
+ - **Mock KMS route (new)** — `GET /api/kms/[segment]` returns a stable per-process 32-byte key for `current` or the explicit key ID. Backed by `HttpKeyProvider` in the crypto route.
57
+ - Routes: 13 → 17 (added `/api/fetch/redirect`, `/api/kms/[segment]`, `/api/token-bucket`, `/token-bucket`).
58
+
59
+ ## 1.0.0 — 2026-05-24
60
+
61
+ First stable release. All four subpaths ship production-grade implementations. All changes are additive — existing 0.x APIs remain unchanged.
62
+
63
+ ### `/ratelimit` — ConnectRateLimitStore (token bucket via hazo_connect)
64
+
65
+ - New `ConnectRateLimitStore` stores per-key token-bucket state in `hazo_rl_buckets` (PostgreSQL or SQLite). Requires `hazo_connect` optional peer. Token bucket smooths request flow — no 2× burst at window boundary unlike the fixed-window MemoryRateLimitStore.
66
+ - `RateLimitStore.increment()` interface extended with optional `max?` param so stores can enforce capacity limits server-side.
67
+ - Migration file: `migrations/001_hazo_rl_buckets.sql` (supports both PostgreSQL and SQLite).
68
+ - `MemoryRateLimitStore` unchanged — no migration needed for projects already using it.
69
+
70
+ ### `/fetch` — undici dispatcher, connect-time IP validation, DNS rebinding fix
71
+
72
+ - New `createSsrfDispatcher(policy)` returns an undici `Dispatcher` that validates the resolved IP at socket-creation time, closing the DNS rebinding gap present since 0.2.0 (where `fetch` could re-resolve between pre-flight check and connect).
73
+ - `safeFetch()` now accepts an optional `dispatcher` param — pass the undici dispatcher for Node production use; omit for edge-compatible pre-flight-only mode.
74
+ - New `validateConnectIp(ip)` exported — throws `SafeFetchError` if the IP is in a blocked CIDR range. Useful when rolling your own dispatcher or testing IP validation logic.
75
+ - Undici added as a runtime dependency (`^7.3.0`).
76
+
77
+ ### `/crypto` — HttpKeyProvider (cloud-agnostic HTTPS key endpoint)
78
+
79
+ - New `HttpKeyProvider({ endpoint, headers?, ttlMs? })` fetches the current encryption key from any HTTPS endpoint that returns `{ keyId, key: "<base64-32-bytes>" }`. Works with AWS Parameter Store, GCP Secret Manager, HashiCorp Vault, or any custom KMS HTTP façade.
80
+ - In-process TTL cache (default 30 s) avoids per-encrypt latency. `provider.clearCache()` forces a re-fetch (useful in tests or on key rotation events).
81
+ - `EnvKeyProvider` and `StaticKeyProvider` unchanged.
82
+
83
+ ### `/gdpr` — registry enhancements (validateRegistry, exportUser, anonymiseUser, dryRunExport, auditIntent)
84
+
85
+ - `exportUser({ userId })` — async generator that streams `{ filename, content }` records from all registered exporters in sequence. Replaces ad-hoc iteration over `registry.exporters`.
86
+ - `anonymiseUser({ userId })` — calls every registered anonymiser in sequence; rolls back (best-effort) on first failure.
87
+ - `validateRegistry({ requiredDomains? })` — returns an array of `{ domain, issue }` objects for any domain that is missing an exporter, missing an anonymiser, or not present in `requiredDomains`. Returns `[]` if fully valid.
88
+ - `dryRunExport({ userId })` — runs all exporters, collects every yielded file into memory, and returns `GdprFile[]` without writing anything. Useful in test suites.
89
+ - `auditIntent` injection — `createGdprRegistry({ auditIntent })` accepts an async callback `(action, userId) => void`. Called before `exportUser`, `anonymiseUser`, and `dryRunExport` so every invocation is auditable.
90
+ - New `GdprRegistryError` typed error class — throw/catch by `instanceof`. Discriminated by `code` (`missing_exporter`, `missing_anonymiser`, `export_failed`, `anonymise_failed`).
91
+
92
+ ### Test-app (new)
93
+
94
+ - Six-page Next.js demo app in `test-app/` covering CSRF, rate limiting, SSRF/fetch, crypto, GDPR, and the registry validator. Sidebar layout with collapsible nav. Builds clean (13 routes).
95
+
96
+ ### Tests
97
+
98
+ 134 passing across 9 suites (was 103 in 0.5.0).
99
+
100
+ ## 0.5.0 — 2026-05-17
101
+
102
+ New subpath implementation: **`hazo_secure/crypto`** — AES-256-GCM field-level encryption.
103
+
104
+ `encryptField(plaintext, { keys, aad? })` produces an `EncryptedField` envelope: `{ v, keyId, iv, ct, tag, aad? }`. `decryptField(envelope, { keys, aad? })` round-trips it. Uses Node's `node:crypto`, 12-byte CSPRNG IV per encryption, 16-byte GCM auth tag. AAD is optional but recommended — bind it to `<table>:<entityId>:<field>` so envelopes can't be transplanted across rows or fields.
105
+
106
+ Key providers:
107
+
108
+ - `EnvKeyProvider(prefix)` — reads `${PREFIX}_CURRENT` for the current key id, then `${PREFIX}_<keyId>` (base64-encoded 32-byte AES-256 key) for each known id. Supports rotation: write under v2, leave v1 in env so historical rows still decrypt; bulk re-encrypt job can migrate later.
109
+ - `StaticKeyProvider(currentKeyId, { keyId: key })` — for tests + ephemeral one-shot encryption. Don't ship to production.
110
+ - Custom: implement the `KeyProvider` interface for KMS adapters (AWS KMS, GCP KMS, HashiCorp Vault).
111
+
112
+ Helper `aadFor(table, entityId, field)` builds the canonical AAD shape.
113
+
114
+ Errors: typed `CryptoError` with `code` discriminator (`unsupported_version`, `key_not_found`, `decrypt_failed`, `aad_mismatch`, `invalid_key`, `missing_env_key`, `no_keys_loaded`).
115
+
116
+ 17 new tests: round-trip + unicode + envelope shape + rotation + AAD binding + validation + EnvKeyProvider env-var handling. Total 103/103 across all subpaths.
117
+
118
+ ## 0.4.0 — 2026-05-16
119
+
120
+ New subpath: **`hazo_secure/csrf`** — double-submit cookie pattern.
121
+
122
+ - `issueCsrfToken(policy?)` — base64url-encoded random token (default 32 bytes)
123
+ - `csrfCookieHeader(token, policy?)` — Set-Cookie value builder. `SameSite=Lax` default, `Secure` defaults to true in production, customisable name/domain/maxAge
124
+ - `verifyCsrf(req, policy?)` — reads cookie + header, constant-time compare. Safe methods (GET/HEAD/OPTIONS) bypass automatically
125
+ - `withCsrf(handler, opts?)` — Next.js wrapper. 403 with `X-CSRF-Reject-Reason` header by default, customisable via `onReject`. Auto-issues a fresh cookie on safe-method calls when none present (clients always have a token ready to echo)
126
+ - 20 unit tests cover token issue, cookie header, all verify branches, wrapper, and overrides
127
+
128
+ Design choices: NOT httpOnly (client needs to read it to echo); `SameSite=Lax` default (Strict breaks invite-link UX); constant-time compare via `timingSafeEqual` to defeat timing attacks; pure crypto + cookie work, zero dependencies. Intended for App Router API routes — Server Actions have their own origin check.
129
+
130
+ ## 0.3.1 — 2026-05-16
131
+
132
+ `withRateLimit` typing: loosened handler constraint from `(req: Request, ...) => Promise<Response>` to `(...args: any[]) => Promise<Response>` so framework subclasses (`NextRequest`, etc.) typecheck cleanly. `args[0]` is asserted as `Request` internally — every framework's request type is structurally a Request, so this is safe in practice.
133
+
134
+ ## 0.3.0 — 2026-05-16
135
+
136
+ `hazo_secure/ratelimit` — first implementation.
137
+
138
+ - `createRateLimiter({ store?, windowMs, max, keyResolver?, now? })` returns `{ check(req), checkKey(key) }`
139
+ - One-shot `checkRateLimit(req, opts)` helper for simple cases
140
+ - `withRateLimit(handler, opts)` wraps any `(req, ...rest) => Promise<Response>` — returns 429 with `Retry-After` + `X-RateLimit-*` headers when blocked; adds the headers on success too
141
+ - Default `MemoryRateLimitStore` — fixed-window counter, in-memory `Map`, lazy expiry on read + periodic background sweep (unref'd timer so it doesn't keep the event loop alive)
142
+ - `RateLimitStore` interface for Redis/etc. swap-in
143
+ - Default key resolver checks `x-forwarded-for` → `x-real-ip` → `cf-connecting-ip` → `"unknown"`
144
+ - Custom 429 response via `onLimit` override
145
+ - 19 unit tests cover store, limiter, resolver, and wrapper paths
146
+
147
+ Known limitation: fixed-window can permit 2× burst at the window boundary. Acceptable for spam-defense and brute-force throttling, which is the current target. Sliding-window or token-bucket strategies can be added as alternative stores without API change.
148
+
149
+ ## 0.2.0 — 2026-05-16
150
+
151
+ `hazo_secure/fetch` — first real implementation.
152
+
153
+ - `safeFetch(url, { policy, deps? })` — SSRF-safe wrapper over native `fetch`
154
+ - Policy supports: `allowedHosts`, `allowedHostPatterns`, `blockPrivateIps` (default on), `maxRedirects` (default 3), `timeoutMs` (default 10s), `allowedProtocols` (default `http:`/`https:`)
155
+ - DNS pre-flight blocks: IPv4 RFC1918, loopback, link-local incl. 169.254.169.254 cloud metadata, CGNAT, multicast, reserved; IPv6 loopback, link-local, ULA, IPv4-mapped equivalents
156
+ - Manual redirect following with per-hop revalidation + loop detection
157
+ - Per-request timeout via AbortController, propagates external `signal`
158
+ - 47 unit tests cover CIDR matching and the full SSRF gate matrix
159
+
160
+ DNS rebinding caveat: the resolved-IP check happens before connect, but native `fetch` re-resolves at connect time. Upgrade path (production-grade): undici Agent with custom `connect` that validates at socket-create time. Acceptable for current consumers (server-to-server with trusted DNS infra); revisit when consumers face hostile networks.
161
+
162
+ ## 0.1.0 — 2026-05-16
163
+
164
+ Initial scaffold.
165
+
166
+ - `package.json` with subpath exports for `/fetch`, `/ratelimit`, `/crypto`, `/gdpr`
167
+ - `tsup` build config covering all five entry points
168
+ - Stub modules + public interfaces in place; implementations land in 0.2.x
169
+ - `hazo_logs` as required peer; `hazo_jobs` and `hazo_files` as optional peers (gdpr only)
170
+
171
+ Why now: §21 of the Kinstripe master plan (Security & Compliance) needed reusable primitives instead of inline code. Pulled CSRF into hazo_auth, kept fetch/ratelimit/crypto/gdpr together under one umbrella because they share a single audience: any project handling PII or untrusted input.
package/README.md CHANGED
@@ -5,9 +5,10 @@ Security & compliance primitives for Node.js / Next.js apps. Four independent su
5
5
  | Subpath | Purpose |
6
6
  |---|---|
7
7
  | `hazo_secure/fetch` | SSRF-safe `safeFetch()` — domain allowlist, private-CIDR blocker, redirect controls |
8
- | `hazo_secure/ratelimit` | Sliding-window + token-bucket limiter with pluggable store (in-memory default, Redis adapter) |
8
+ | `hazo_secure/ratelimit` | Sliding-window + token-bucket limiter with pluggable store (in-memory default, hazo_connect adapter) |
9
9
  | `hazo_secure/crypto` | AES-256-GCM field-level encryption with key versioning and AAD support |
10
10
  | `hazo_secure/gdpr` | Exporter / anonymiser registry + lifecycle orchestrator. Uses `hazo_jobs` for async export, `hazo_files` for ZIP delivery |
11
+ | `hazo_secure/csrf` | Double-submit CSRF protection for Next.js routes — token generation, cookie header, constant-time verification |
11
12
 
12
13
  ## Install
13
14
 
@@ -15,7 +16,7 @@ Security & compliance primitives for Node.js / Next.js apps. Four independent su
15
16
  npm install hazo_secure
16
17
  ```
17
18
 
18
- `hazo_logs` is a required peer dep. `hazo_jobs` and `hazo_files` are optional peers — only needed if you use `hazo_secure/gdpr`.
19
+ `hazo_logs` is a required peer dep. `hazo_connect`, `hazo_jobs`, and `hazo_files` are optional peers — `hazo_connect` is needed for `ConnectRateLimitStore`; `hazo_jobs` and `hazo_files` are only needed for `hazo_secure/gdpr`.
19
20
 
20
21
  ## Quick start
21
22
 
@@ -72,6 +73,90 @@ gdpr.registerExporter({
72
73
  });
73
74
  ```
74
75
 
76
+ ## New in 1.0.0
77
+
78
+ ### `hazo_secure/ratelimit` — ConnectRateLimitStore (token bucket)
79
+
80
+ ```ts
81
+ import { ConnectRateLimitStore } from "hazo_secure/ratelimit";
82
+
83
+ const store = new ConnectRateLimitStore({ getHazoConnect: () => db });
84
+ const limiter = createRateLimiter({ store, windowMs: 60_000, max: 30 });
85
+ ```
86
+
87
+ Stores token-bucket state in `hazo_rl_buckets` (see `migrations/001_hazo_rl_buckets.sql`). Run the migration before use. Requires `hazo_connect` peer.
88
+
89
+ ### `hazo_secure/fetch` — undici dispatcher + `validateConnectIp`
90
+
91
+ ```ts
92
+ import { safeFetch, createSecureDispatcher, validateConnectIp } from "hazo_secure/fetch";
93
+
94
+ // Use standalone to close the DNS-rebinding gap at socket creation time
95
+ const dispatcher = createSecureDispatcher();
96
+ const res = await safeFetch(url, { policy, deps: { fetchImpl: (u, i) => fetch(u, { ...i, dispatcher }) } });
97
+
98
+ // Or validate resolved IPs yourself
99
+ validateConnectIp("169.254.169.254"); // throws — blocked CIDR
100
+ ```
101
+
102
+ Node.js only — undici is a Node dep. Edge runtime gets pre-flight checks only.
103
+
104
+ ### `hazo_secure/crypto` — HttpKeyProvider
105
+
106
+ ```ts
107
+ import { HttpKeyProvider } from "hazo_secure/crypto";
108
+
109
+ const provider = new HttpKeyProvider({
110
+ endpoint: "https://kms.internal/keys",
111
+ headers: { Authorization: `Bearer ${token}` },
112
+ ttlMs: 30_000, // in-process cache TTL (default: 30 s)
113
+ });
114
+ provider.clearCache(); // force next fetch to re-fetch from endpoint
115
+ ```
116
+
117
+ Cloud-agnostic — works with any HTTPS key endpoint that returns `{ keyId, key: "<base64>" }`. Caches keys in-process to avoid latency on every encrypt/decrypt call.
118
+
119
+ ### `hazo_secure/gdpr` — registry enhancements
120
+
121
+ ```ts
122
+ import { createGdprRegistry, GdprRegistryError } from "hazo_secure/gdpr";
123
+
124
+ const gdpr = createGdprRegistry({ auditIntent: logAuditEntry });
125
+
126
+ // Validate registry completeness
127
+ const issues = await gdpr.validateRegistry({ requiredDomains: ["persons", "payments"] });
128
+
129
+ // Export user data (streams records from all registered exporters)
130
+ for await (const file of gdpr.exportUser({ userId })) {
131
+ console.log(file.filename, file.content);
132
+ }
133
+
134
+ // Dry-run export without side effects
135
+ const files = await gdpr.dryRunExport({ userId });
136
+
137
+ // Anonymise user across all registered domains
138
+ await gdpr.anonymiseUser({ userId });
139
+ ```
140
+
141
+ `GdprRegistryError` is a typed error class — catch and `instanceof`-check it.
142
+
143
+ ---
144
+
145
+ ## Runtime Compatibility
146
+
147
+ | Subpath | Edge runtime | Node.js (server) | Notes |
148
+ |---|---|---|---|
149
+ | `hazo_secure` | ✅ | ✅ | Types only, no runtime weight |
150
+ | `hazo_secure/csrf` | ✅ | ✅ | Web Crypto API only |
151
+ | `hazo_secure/ratelimit` | ✅ (MemoryRateLimitStore) | ✅ | ConnectRateLimitStore is Node-only (hazo_connect) |
152
+ | `hazo_secure/fetch` | ⚠️ pre-flight only | ✅ full protection | Edge: host/IP checks only; Node: + undici connect-time validation |
153
+ | `hazo_secure/crypto` | ❌ | ✅ | Uses `node:crypto` |
154
+ | `hazo_secure/gdpr` | ❌ | ✅ | Async iterables + optional hazo_connect |
155
+
156
+ **Node.js ≥ 18 required** for all Node-only subpaths.
157
+
158
+ ---
159
+
75
160
  ## Design
76
161
 
77
162
  Each subpath is independent — no cross-imports between `/fetch`, `/ratelimit`, `/crypto`, `/gdpr`. The `gdpr` module *consumes* the others when a project wires them together, but the package itself doesn't entangle them.
@@ -0,0 +1,68 @@
1
+ # Setup Checklist
2
+
3
+ For a consuming app (e.g. Kinstripe) pulling in `hazo_secure`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install hazo_secure
9
+ ```
10
+
11
+ Add `hazo_logs` if not already present:
12
+
13
+ ```bash
14
+ npm install hazo_logs
15
+ ```
16
+
17
+ ## Per-subpath setup
18
+
19
+ ### `hazo_secure/fetch`
20
+
21
+ No setup. Construct a `SafeFetchPolicy` at each callsite and pass via the `policy` option.
22
+
23
+ ### `hazo_secure/ratelimit`
24
+
25
+ Default in-memory store works out of the box. For multi-instance deployments, supply a Redis-backed `RateLimitStore` implementation.
26
+
27
+ ### `hazo_secure/crypto`
28
+
29
+ 1. Provision a 32-byte master key — store base64-encoded in env (`HAZO_SECURE_CRYPTO_KEY` or app-specific name)
30
+ 2. Choose a `KeyProvider` implementation:
31
+ - **`EnvKeyProvider(prefix)`** — reads from env vars; supports rotation by leaving old key ids in env
32
+ - **`HttpKeyProvider({ endpoint, headers?, ttlMs? })`** — fetches from any HTTPS endpoint returning `{ keyId, key: "<base64>" }` (AWS Parameter Store, GCP Secret Manager, HashiCorp Vault, or a custom facade); caches in-process for `ttlMs` (default 30 s)
33
+ - **`StaticKeyProvider`** — for tests only; do not ship to production
34
+ - **Custom** — implement the `KeyProvider` interface directly
35
+ 3. Decide AAD scheme — usually `domain:entityId` (e.g. `health:${personId}`)
36
+
37
+ ### `hazo_secure/gdpr`
38
+
39
+ 1. Install optional peers if you'll use async export and ZIP delivery:
40
+
41
+ ```bash
42
+ npm install hazo_jobs hazo_files
43
+ ```
44
+
45
+ 2. Create the registry on app boot:
46
+
47
+ ```ts
48
+ const gdpr = createGdprRegistry();
49
+ ```
50
+
51
+ 3. Register per-domain exporters and anonymisers in feature modules
52
+ 4. Wire `/api/me/export` and `/api/me/delete` route handlers (project-specific)
53
+
54
+ ## Verifying
55
+
56
+ ```bash
57
+ # Fetch — expect: SafeFetchError, safeFetch, createSecureDispatcher, validateConnectIp
58
+ node --input-type=module -e "import * as m from 'hazo_secure/fetch'; console.log(Object.keys(m).join(', '))"
59
+
60
+ # Ratelimit — expect: MemoryRateLimitStore, ConnectRateLimitStore, withRateLimit, checkRateLimit, createRateLimiter
61
+ node --input-type=module -e "import * as m from 'hazo_secure/ratelimit'; console.log(Object.keys(m).join(', '))"
62
+
63
+ # Crypto — expect: encryptField, decryptField, CryptoError, EnvKeyProvider, StaticKeyProvider, HttpKeyProvider
64
+ node --input-type=module -e "import * as m from 'hazo_secure/crypto'; console.log(Object.keys(m).join(', '))"
65
+
66
+ # GDPR — expect: createGdprRegistry, GdprRegistryError
67
+ node --input-type=module -e "import * as m from 'hazo_secure/gdpr'; console.log(Object.keys(m).join(', '))"
68
+ ```
@@ -0,0 +1,71 @@
1
+ ; hazo_secure configuration
2
+ ;
3
+ ; Copy to `hazo_secure_config.ini` and customise. Loaded by getSecureConfig()
4
+ ; on first access. INI is the workspace standard; only the [general] and
5
+ ; [log.overrides] sections are universal — the rest are package-specific
6
+ ; defaults the subpath modules can read.
7
+ ;
8
+ ; Per hazo workspace standard S7, every value can be overridden via env vars:
9
+ ; HAZO_SECURE_<SECTION>_<KEY>=value
10
+ ; e.g. HAZO_SECURE_FETCH_TIMEOUT_MS=15000
11
+ ;
12
+ ; Per D-019, a per-environment overlay file at
13
+ ; config/hazo_secure_config.<HAZO_ENV>.ini
14
+ ; is layered on top of this base file when HAZO_ENV is set.
15
+
16
+ ; ============================================================================
17
+ ; [general] - Workspace-standard general configuration
18
+ ; ============================================================================
19
+ [general]
20
+
21
+ ; Deployment environment label. Falls back to NODE_ENV, then 'development'.
22
+ ; env = production
23
+
24
+ ; ============================================================================
25
+ ; [log.overrides] - Per-namespace log level overrides (workspace standard)
26
+ ; Format: <namespace> = trace|debug|info|warn|error
27
+ ; ============================================================================
28
+ [log.overrides]
29
+
30
+ ; hazo_secure = info
31
+ ; hazo_secure/fetch = warn
32
+ ; hazo_secure/ratelimit = warn
33
+ ; hazo_secure/crypto = info
34
+ ; hazo_secure/csrf = warn
35
+
36
+ ; ============================================================================
37
+ ; [fetch] - SSRF-safe fetch defaults applied when callers omit them
38
+ ; ============================================================================
39
+ [fetch]
40
+
41
+ ; Default request timeout in milliseconds (matches DEFAULT_TIMEOUT_MS).
42
+ ; timeout_ms = 10000
43
+
44
+ ; Maximum redirect hops to follow (matches DEFAULT_MAX_REDIRECTS).
45
+ ; max_redirects = 3
46
+
47
+ ; Whether to block private/reserved IPs by default. Leave true unless your
48
+ ; consumer explicitly needs intranet access.
49
+ ; block_private_ips = true
50
+
51
+ ; ============================================================================
52
+ ; [ratelimit] - Rate-limiting defaults
53
+ ; ============================================================================
54
+ [ratelimit]
55
+
56
+ ; Default sweep interval for MemoryRateLimitStore (ms). 0 disables.
57
+ ; memory_sweep_interval_ms = 60000
58
+
59
+ ; ============================================================================
60
+ ; [csrf] - Double-submit cookie defaults
61
+ ; ============================================================================
62
+ [csrf]
63
+
64
+ ; Cookie name written by `csrfCookieHeader()` and read by `verifyCsrf()`.
65
+ ; cookie_name = hs_csrf
66
+
67
+ ; Header name the client echoes the token back through.
68
+ ; header_name = x-csrf-token
69
+
70
+ ; Token byte length (final base64url string is longer).
71
+ ; token_length = 32
@@ -0,0 +1,20 @@
1
+ -- hazo_secure PostgreSQL setup (idempotent)
2
+ --
3
+ -- One table is owned by this package:
4
+ -- hazo_rl_buckets — token-bucket state for `ConnectRateLimitStore`
5
+ -- (subpath `hazo_secure/ratelimit`)
6
+ --
7
+ -- All other subpaths (/fetch, /crypto, /csrf, /gdpr) are stateless; the GDPR
8
+ -- registry hands persistence off to consumer-supplied exporters/anonymisers.
9
+ --
10
+ -- Re-run safe — uses IF NOT EXISTS throughout.
11
+
12
+ CREATE TABLE IF NOT EXISTS hazo_rl_buckets (
13
+ id TEXT NOT NULL PRIMARY KEY,
14
+ tokens DOUBLE PRECISION NOT NULL,
15
+ last_refill_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
16
+ max_tokens DOUBLE PRECISION NOT NULL,
17
+ refill_rate_per_ms DOUBLE PRECISION NOT NULL
18
+ );
19
+
20
+ NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,18 @@
1
+ -- hazo_secure SQLite setup (idempotent)
2
+ --
3
+ -- One table is owned by this package:
4
+ -- hazo_rl_buckets — token-bucket state for `ConnectRateLimitStore`
5
+ -- (subpath `hazo_secure/ratelimit`)
6
+ --
7
+ -- All other subpaths (/fetch, /crypto, /csrf, /gdpr) are stateless; the GDPR
8
+ -- registry hands persistence off to consumer-supplied exporters/anonymisers.
9
+ --
10
+ -- Re-run safe — uses IF NOT EXISTS throughout.
11
+
12
+ CREATE TABLE IF NOT EXISTS hazo_rl_buckets (
13
+ id TEXT NOT NULL PRIMARY KEY,
14
+ tokens REAL NOT NULL,
15
+ last_refill_at TEXT NOT NULL DEFAULT (datetime('now')),
16
+ max_tokens REAL NOT NULL,
17
+ refill_rate_per_ms REAL NOT NULL
18
+ );
@@ -1,3 +1,36 @@
1
+ import { HazoError } from 'hazo_core';
2
+
3
+ interface HttpKeyProviderOptions {
4
+ /**
5
+ * Base HTTPS endpoint for key material.
6
+ * - current(): GET {endpoint}/current → { keyId: string; key: string }
7
+ * - byId(id): GET {endpoint}/{id} → { keyId: string; key: string }
8
+ *
9
+ * `key` must be base64url-encoded 32-byte AES-256 key material.
10
+ */
11
+ endpoint: string;
12
+ /**
13
+ * Fetch implementation. Inject safeFetch from hazo_secure/fetch for SSRF
14
+ * protection on external KMS endpoints. Defaults to globalThis.fetch.
15
+ */
16
+ fetch?: typeof globalThis.fetch;
17
+ /** Bearer token added as Authorization header on every request. */
18
+ token?: string;
19
+ /** Additional headers added to every request. */
20
+ headers?: Record<string, string>;
21
+ }
22
+ declare class HttpKeyProvider implements KeyProvider {
23
+ #private;
24
+ constructor(opts: HttpKeyProviderOptions);
25
+ current(): Promise<{
26
+ keyId: string;
27
+ key: Uint8Array;
28
+ }>;
29
+ byId(keyId: string): Promise<Uint8Array>;
30
+ /** Clear in-process key cache. Call after rotating keys. */
31
+ clearCache(): void;
32
+ }
33
+
1
34
  /**
2
35
  * Source of symmetric keys for `encryptField` / `decryptField`. The current
3
36
  * key is used for encryption; `byId` resolves any key (current or rotated-out)
@@ -38,9 +71,16 @@ interface EncryptedField {
38
71
  tag: string;
39
72
  aad?: string;
40
73
  }
41
- declare class CryptoError extends Error {
42
- readonly code: "unsupported_version" | "key_not_found" | "decrypt_failed" | "aad_mismatch" | "invalid_key" | "missing_env_key" | "no_keys_loaded";
43
- constructor(message: string, code: "unsupported_version" | "key_not_found" | "decrypt_failed" | "aad_mismatch" | "invalid_key" | "missing_env_key" | "no_keys_loaded");
74
+ type CryptoErrorCode = "unsupported_version" | "key_not_found" | "decrypt_failed" | "aad_mismatch" | "invalid_key" | "missing_env_key" | "no_keys_loaded";
75
+ /**
76
+ * Typed error raised by `/crypto`. Extends `HazoError` so it participates in
77
+ * the workspace's correlation-ID auto-attach + envelope shape, while
78
+ * preserving the legacy `(message, code)` constructor + `err.code` short
79
+ * discriminator that 1.0.x consumers already check.
80
+ */
81
+ declare class CryptoError extends HazoError {
82
+ readonly code: CryptoErrorCode;
83
+ constructor(message: string, code: CryptoErrorCode);
44
84
  }
45
85
  /**
46
86
  * Encrypt a UTF-8 string under the current key.
@@ -104,4 +144,4 @@ declare class StaticKeyProvider implements KeyProvider {
104
144
  * Convention: `<table>:<entityId>:<field>` — collision-free across tables. */
105
145
  declare function aadFor(table: string, entityId: string, field: string): string;
106
146
 
107
- export { CryptoError, type EncryptedField, EnvKeyProvider, type KeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField };
147
+ export { CryptoError, type CryptoErrorCode, type EncryptedField, EnvKeyProvider, HttpKeyProvider, type HttpKeyProviderOptions, type KeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField };
@@ -1,17 +1,101 @@
1
1
  // src/crypto/index.ts
2
2
  import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
3
- var CryptoError = class extends Error {
3
+ import { HazoError } from "hazo_core";
4
+
5
+ // src/crypto/provider-http.ts
6
+ var KEY_LEN = 32;
7
+ var HttpKeyProvider = class {
8
+ #endpoint;
9
+ #fetch;
10
+ #token;
11
+ #headers;
12
+ /** Cache for key material, keyed by keyId. */
13
+ #cache = /* @__PURE__ */ new Map();
14
+ /** Cached result of the most recent successful /current fetch. */
15
+ #currentCache = null;
16
+ constructor(opts) {
17
+ this.#endpoint = opts.endpoint.replace(/\/$/, "");
18
+ this.#fetch = opts.fetch ?? globalThis.fetch;
19
+ this.#token = opts.token;
20
+ this.#headers = opts.headers ?? {};
21
+ }
22
+ async current() {
23
+ if (this.#currentCache) return this.#currentCache;
24
+ const data = await this.#fetchSegment("current");
25
+ const key = this.#parseAndCacheKey(data);
26
+ this.#currentCache = { keyId: data.keyId, key };
27
+ return this.#currentCache;
28
+ }
29
+ async byId(keyId) {
30
+ const cached = this.#cache.get(keyId);
31
+ if (cached) return cached;
32
+ const data = await this.#fetchSegment(keyId);
33
+ return this.#parseAndCacheKey(data);
34
+ }
35
+ /** Clear in-process key cache. Call after rotating keys. */
36
+ clearCache() {
37
+ this.#cache.clear();
38
+ this.#currentCache = null;
39
+ }
40
+ #parseAndCacheKey(data) {
41
+ const buf = Buffer.from(data.key, "base64url");
42
+ if (buf.byteLength !== KEY_LEN) {
43
+ throw new CryptoError(
44
+ `HttpKeyProvider: key ${data.keyId} is ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN}`,
45
+ "invalid_key"
46
+ );
47
+ }
48
+ const key = new Uint8Array(buf);
49
+ this.#cache.set(data.keyId, key);
50
+ return key;
51
+ }
52
+ async #fetchSegment(segment) {
53
+ const url = `${this.#endpoint}/${segment}`;
54
+ const headers = { ...this.#headers };
55
+ if (this.#token) headers["Authorization"] = `Bearer ${this.#token}`;
56
+ let res;
57
+ try {
58
+ res = await this.#fetch(url, { headers });
59
+ } catch (err) {
60
+ throw new CryptoError(
61
+ `HttpKeyProvider: fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`,
62
+ "key_not_found"
63
+ );
64
+ }
65
+ if (!res.ok) {
66
+ throw new CryptoError(
67
+ `HttpKeyProvider: endpoint returned ${res.status} for ${url}`,
68
+ "key_not_found"
69
+ );
70
+ }
71
+ try {
72
+ return await res.json();
73
+ } catch {
74
+ throw new CryptoError(
75
+ `HttpKeyProvider: invalid JSON response from ${url}`,
76
+ "key_not_found"
77
+ );
78
+ }
79
+ }
80
+ };
81
+
82
+ // src/crypto/index.ts
83
+ var CryptoError = class extends HazoError {
4
84
  constructor(message, code) {
5
- super(message);
6
- this.code = code;
85
+ super({
86
+ code,
87
+ pkg: "hazo_secure",
88
+ message,
89
+ httpStatus: 500,
90
+ retryable: false
91
+ });
7
92
  this.name = "CryptoError";
8
93
  }
9
- code;
10
94
  };
11
95
  var ALG = "aes-256-gcm";
12
96
  var IV_LEN = 12;
13
97
  var TAG_LEN = 16;
14
- var KEY_LEN = 32;
98
+ var KEY_LEN2 = 32;
15
99
  function toB64(buf) {
16
100
  return Buffer.from(buf).toString("base64");
17
101
  }
@@ -20,9 +104,9 @@ function fromB64(s) {
20
104
  }
21
105
  async function encryptField(plaintext, opts) {
22
106
  const { keyId, key } = await opts.keys.current();
23
- if (key.byteLength !== KEY_LEN) {
107
+ if (key.byteLength !== KEY_LEN2) {
24
108
  throw new CryptoError(
25
- `Key for ${keyId} is ${key.byteLength} bytes; AES-256-GCM needs ${KEY_LEN}.`,
109
+ `Key for ${keyId} is ${key.byteLength} bytes; AES-256-GCM needs ${KEY_LEN2}.`,
26
110
  "invalid_key"
27
111
  );
28
112
  }
@@ -99,9 +183,9 @@ var EnvKeyProvider = class {
99
183
  throw new CryptoError(`Missing ${varName} env var`, "key_not_found");
100
184
  }
101
185
  const buf = Buffer.from(raw, "base64");
102
- if (buf.byteLength !== KEY_LEN) {
186
+ if (buf.byteLength !== KEY_LEN2) {
103
187
  throw new CryptoError(
104
- `${varName} decodes to ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN}`,
188
+ `${varName} decodes to ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN2}`,
105
189
  "invalid_key"
106
190
  );
107
191
  }
@@ -140,6 +224,7 @@ function aadFor(table, entityId, field) {
140
224
  export {
141
225
  CryptoError,
142
226
  EnvKeyProvider,
227
+ HttpKeyProvider,
143
228
  StaticKeyProvider,
144
229
  aadFor,
145
230
  decryptField,