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 +171 -0
- package/README.md +87 -2
- package/SETUP_CHECKLIST.md +68 -0
- package/config/hazo_secure_config.ini.sample +71 -0
- package/db_setup_postgres.sql +20 -0
- package/db_setup_sqlite.sql +18 -0
- package/dist/crypto/index.d.ts +44 -4
- package/dist/crypto/index.js +94 -9
- package/dist/csrf/index.d.ts +10 -1
- package/dist/csrf/index.js +9 -4
- package/dist/fetch/index.d.ts +29 -2
- package/dist/fetch/index.js +69 -8
- package/dist/gdpr/index.d.ts +49 -2
- package/dist/gdpr/index.js +94 -9
- package/dist/index.d.ts +40 -7
- package/dist/index.js +38 -9
- package/dist/ratelimit/index.d.ts +55 -3
- package/dist/ratelimit/index.js +98 -2
- package/migrations/001_hazo_rl_buckets.sql +26 -0
- package/package.json +45 -16
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,
|
|
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
|
|
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
|
+
);
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 };
|
package/dist/crypto/index.js
CHANGED
|
@@ -1,17 +1,101 @@
|
|
|
1
1
|
// src/crypto/index.ts
|
|
2
2
|
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
|
3
|
-
|
|
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(
|
|
6
|
-
|
|
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
|
|
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 !==
|
|
107
|
+
if (key.byteLength !== KEY_LEN2) {
|
|
24
108
|
throw new CryptoError(
|
|
25
|
-
`Key for ${keyId} is ${key.byteLength} bytes; AES-256-GCM needs ${
|
|
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 !==
|
|
186
|
+
if (buf.byteLength !== KEY_LEN2) {
|
|
103
187
|
throw new CryptoError(
|
|
104
|
-
`${varName} decodes to ${buf.byteLength} bytes; AES-256-GCM needs ${
|
|
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,
|