hazo_secure 1.0.1 → 1.3.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 +182 -0
- package/README.md +24 -2
- package/SETUP_CHECKLIST.md +85 -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 +46 -4
- package/dist/crypto/index.js +48 -4
- package/dist/csrf/index.d.ts +10 -1
- package/dist/csrf/index.js +9 -4
- package/dist/fetch/index.d.ts +14 -3
- package/dist/fetch/index.js +32 -6
- package/dist/gdpr/index.d.ts +13 -4
- package/dist/gdpr/index.js +9 -4
- package/dist/index.d.ts +40 -7
- package/dist/index.js +38 -9
- package/dist/mask/index.d.ts +48 -0
- package/dist/mask/index.js +115 -0
- package/dist/ratelimit/index.d.ts +7 -0
- package/dist/ratelimit/index.js +22 -6
- package/dist/secrets/index.d.ts +41 -0
- package/dist/secrets/index.js +84 -0
- package/migrations/001_hazo_rl_buckets.sql +26 -0
- package/package.json +50 -18
package/CHANGE_LOG.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
## 1.3.0 — 2026-06-12
|
|
4
|
+
|
|
5
|
+
### New: `hazo_secure/secrets` — SecretsProvider
|
|
6
|
+
|
|
7
|
+
- **`SecretsProvider` interface** with `get(name)` / `resolve(names[])` methods.
|
|
8
|
+
- **`EnvSecretsProvider`** — reads from env vars (`${prefix}_${NAME}`; default prefix `HAZO_SECRET`).
|
|
9
|
+
- **`StaticSecretsProvider`** — in-memory map; primary use is tests.
|
|
10
|
+
- **`LookupSecretsProvider`** — caller-supplied `(name) => string|undefined` function.
|
|
11
|
+
- **`SecretsError`** — extends `HazoError`, code `HAZO_SECURE_SECRET_NOT_FOUND` (httpStatus 400, retryable false).
|
|
12
|
+
- New `./secrets` subpath export.
|
|
13
|
+
|
|
14
|
+
## 1.1.0 — 2026-05-29
|
|
15
|
+
|
|
16
|
+
### hazo workspace v2 — Wave 2 migration
|
|
17
|
+
|
|
18
|
+
Additive workspace standardisation. Existing 1.0.x API surface unchanged:
|
|
19
|
+
`SafeFetchError`, `CryptoError`, `CsrfError`, `GdprRegistryError` keep their
|
|
20
|
+
constructors and `err.code` short discriminators; consumer `instanceof`
|
|
21
|
+
checks continue to work.
|
|
22
|
+
|
|
23
|
+
- **`hazo_core` required peer dep** (`^1.0.0`). All typed errors now extend
|
|
24
|
+
`HazoError` so they participate in `HazoError.is(err)` cross-package
|
|
25
|
+
checks and the standard envelope shape (httpStatus + retryable derived
|
|
26
|
+
per-code). Bare `throw new Error(...)` sites in
|
|
27
|
+
`src/ratelimit/store-connect.ts` replaced by `HazoConfigError` /
|
|
28
|
+
`HazoValidationError` with `HAZO_SECURE_RATELIMIT_*` codes.
|
|
29
|
+
- **Logger** — new `src/utils/logger.ts` resolves to
|
|
30
|
+
`createLogger('hazo_secure')` from `hazo_core`. Root export adds
|
|
31
|
+
`get_logger`, `set_logger`, `noop_logger`. The legacy `NoopLogger`
|
|
32
|
+
alias is kept as a deprecated re-export.
|
|
33
|
+
- **Correlation IDs** — `safeFetch` injects the workspace `x-request-id`
|
|
34
|
+
header on outbound calls when a context correlation ID is available
|
|
35
|
+
(caller-set headers always win).
|
|
36
|
+
- **INI config** — new `config/hazo_secure_config.ini.sample` covers the
|
|
37
|
+
`[general]`, `[log.overrides]`, `[fetch]`, `[ratelimit]`, `[csrf]`
|
|
38
|
+
sections. Loader: `src/lib/config/hazo_secure_config.ts ::
|
|
39
|
+
getSecureConfig()`. Env-var overrides: `HAZO_SECURE_<SECTION>_<KEY>`.
|
|
40
|
+
- **`db_setup_*.sql` filled out** — `hazo_rl_buckets` schema is now
|
|
41
|
+
inlined into both root setup files (idempotent, postgres ends with
|
|
42
|
+
`NOTIFY pgrst, 'reload schema';`). `migrations/001_hazo_rl_buckets.sql`
|
|
43
|
+
retained as the historical record.
|
|
44
|
+
- **`design/feature_requests/INDEX.md`** added (S16).
|
|
45
|
+
- **Optional-import discipline** — `ConnectRateLimitStore` now uses
|
|
46
|
+
`optional_import` from `hazo_core` to resolve `hazo_connect/server`
|
|
47
|
+
with a clear error envelope if the peer is missing.
|
|
48
|
+
- **Dispatcher consistency** — `validateConnectIp` throws
|
|
49
|
+
`SafeFetchError("private_ip_blocked")` (instead of a bare `Error` with
|
|
50
|
+
`code: 'SSRF_PRIVATE_IP'`); the legacy `ssrfCode` field is attached
|
|
51
|
+
for tooling that grepped for it.
|
|
52
|
+
- **package.json** — `engines` block removed (Node-version policy lives
|
|
53
|
+
in workspace docs). Canonical version pins for `@types/node`,
|
|
54
|
+
`tsup`. `hazo_core`, `hazo_logs`, `hazo_connect`, `hazo_jobs`,
|
|
55
|
+
`hazo_files`, `hazo_ui` declared as peer + devDep.
|
|
56
|
+
|
|
57
|
+
## 1.0.1 — 2026-05-24
|
|
58
|
+
|
|
59
|
+
### Test-app — expanded interactive scenarios
|
|
60
|
+
|
|
61
|
+
No library API changes. All improvements are inside `test-app/`.
|
|
62
|
+
|
|
63
|
+
- **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.
|
|
64
|
+
- **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).
|
|
65
|
+
- **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).
|
|
66
|
+
- **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.
|
|
67
|
+
- **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.
|
|
68
|
+
- Routes: 13 → 17 (added `/api/fetch/redirect`, `/api/kms/[segment]`, `/api/token-bucket`, `/token-bucket`).
|
|
69
|
+
|
|
70
|
+
## 1.0.0 — 2026-05-24
|
|
71
|
+
|
|
72
|
+
First stable release. All four subpaths ship production-grade implementations. All changes are additive — existing 0.x APIs remain unchanged.
|
|
73
|
+
|
|
74
|
+
### `/ratelimit` — ConnectRateLimitStore (token bucket via hazo_connect)
|
|
75
|
+
|
|
76
|
+
- 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.
|
|
77
|
+
- `RateLimitStore.increment()` interface extended with optional `max?` param so stores can enforce capacity limits server-side.
|
|
78
|
+
- Migration file: `migrations/001_hazo_rl_buckets.sql` (supports both PostgreSQL and SQLite).
|
|
79
|
+
- `MemoryRateLimitStore` unchanged — no migration needed for projects already using it.
|
|
80
|
+
|
|
81
|
+
### `/fetch` — undici dispatcher, connect-time IP validation, DNS rebinding fix
|
|
82
|
+
|
|
83
|
+
- 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).
|
|
84
|
+
- `safeFetch()` now accepts an optional `dispatcher` param — pass the undici dispatcher for Node production use; omit for edge-compatible pre-flight-only mode.
|
|
85
|
+
- 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.
|
|
86
|
+
- Undici added as a runtime dependency (`^7.3.0`).
|
|
87
|
+
|
|
88
|
+
### `/crypto` — HttpKeyProvider (cloud-agnostic HTTPS key endpoint)
|
|
89
|
+
|
|
90
|
+
- 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.
|
|
91
|
+
- 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).
|
|
92
|
+
- `EnvKeyProvider` and `StaticKeyProvider` unchanged.
|
|
93
|
+
|
|
94
|
+
### `/gdpr` — registry enhancements (validateRegistry, exportUser, anonymiseUser, dryRunExport, auditIntent)
|
|
95
|
+
|
|
96
|
+
- `exportUser({ userId })` — async generator that streams `{ filename, content }` records from all registered exporters in sequence. Replaces ad-hoc iteration over `registry.exporters`.
|
|
97
|
+
- `anonymiseUser({ userId })` — calls every registered anonymiser in sequence; rolls back (best-effort) on first failure.
|
|
98
|
+
- `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.
|
|
99
|
+
- `dryRunExport({ userId })` — runs all exporters, collects every yielded file into memory, and returns `GdprFile[]` without writing anything. Useful in test suites.
|
|
100
|
+
- `auditIntent` injection — `createGdprRegistry({ auditIntent })` accepts an async callback `(action, userId) => void`. Called before `exportUser`, `anonymiseUser`, and `dryRunExport` so every invocation is auditable.
|
|
101
|
+
- New `GdprRegistryError` typed error class — throw/catch by `instanceof`. Discriminated by `code` (`missing_exporter`, `missing_anonymiser`, `export_failed`, `anonymise_failed`).
|
|
102
|
+
|
|
103
|
+
### Test-app (new)
|
|
104
|
+
|
|
105
|
+
- 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).
|
|
106
|
+
|
|
107
|
+
### Tests
|
|
108
|
+
|
|
109
|
+
134 passing across 9 suites (was 103 in 0.5.0).
|
|
110
|
+
|
|
111
|
+
## 0.5.0 — 2026-05-17
|
|
112
|
+
|
|
113
|
+
New subpath implementation: **`hazo_secure/crypto`** — AES-256-GCM field-level encryption.
|
|
114
|
+
|
|
115
|
+
`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.
|
|
116
|
+
|
|
117
|
+
Key providers:
|
|
118
|
+
|
|
119
|
+
- `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.
|
|
120
|
+
- `StaticKeyProvider(currentKeyId, { keyId: key })` — for tests + ephemeral one-shot encryption. Don't ship to production.
|
|
121
|
+
- Custom: implement the `KeyProvider` interface for KMS adapters (AWS KMS, GCP KMS, HashiCorp Vault).
|
|
122
|
+
|
|
123
|
+
Helper `aadFor(table, entityId, field)` builds the canonical AAD shape.
|
|
124
|
+
|
|
125
|
+
Errors: typed `CryptoError` with `code` discriminator (`unsupported_version`, `key_not_found`, `decrypt_failed`, `aad_mismatch`, `invalid_key`, `missing_env_key`, `no_keys_loaded`).
|
|
126
|
+
|
|
127
|
+
17 new tests: round-trip + unicode + envelope shape + rotation + AAD binding + validation + EnvKeyProvider env-var handling. Total 103/103 across all subpaths.
|
|
128
|
+
|
|
129
|
+
## 0.4.0 — 2026-05-16
|
|
130
|
+
|
|
131
|
+
New subpath: **`hazo_secure/csrf`** — double-submit cookie pattern.
|
|
132
|
+
|
|
133
|
+
- `issueCsrfToken(policy?)` — base64url-encoded random token (default 32 bytes)
|
|
134
|
+
- `csrfCookieHeader(token, policy?)` — Set-Cookie value builder. `SameSite=Lax` default, `Secure` defaults to true in production, customisable name/domain/maxAge
|
|
135
|
+
- `verifyCsrf(req, policy?)` — reads cookie + header, constant-time compare. Safe methods (GET/HEAD/OPTIONS) bypass automatically
|
|
136
|
+
- `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)
|
|
137
|
+
- 20 unit tests cover token issue, cookie header, all verify branches, wrapper, and overrides
|
|
138
|
+
|
|
139
|
+
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.
|
|
140
|
+
|
|
141
|
+
## 0.3.1 — 2026-05-16
|
|
142
|
+
|
|
143
|
+
`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.
|
|
144
|
+
|
|
145
|
+
## 0.3.0 — 2026-05-16
|
|
146
|
+
|
|
147
|
+
`hazo_secure/ratelimit` — first implementation.
|
|
148
|
+
|
|
149
|
+
- `createRateLimiter({ store?, windowMs, max, keyResolver?, now? })` returns `{ check(req), checkKey(key) }`
|
|
150
|
+
- One-shot `checkRateLimit(req, opts)` helper for simple cases
|
|
151
|
+
- `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
|
|
152
|
+
- 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)
|
|
153
|
+
- `RateLimitStore` interface for Redis/etc. swap-in
|
|
154
|
+
- Default key resolver checks `x-forwarded-for` → `x-real-ip` → `cf-connecting-ip` → `"unknown"`
|
|
155
|
+
- Custom 429 response via `onLimit` override
|
|
156
|
+
- 19 unit tests cover store, limiter, resolver, and wrapper paths
|
|
157
|
+
|
|
158
|
+
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.
|
|
159
|
+
|
|
160
|
+
## 0.2.0 — 2026-05-16
|
|
161
|
+
|
|
162
|
+
`hazo_secure/fetch` — first real implementation.
|
|
163
|
+
|
|
164
|
+
- `safeFetch(url, { policy, deps? })` — SSRF-safe wrapper over native `fetch`
|
|
165
|
+
- Policy supports: `allowedHosts`, `allowedHostPatterns`, `blockPrivateIps` (default on), `maxRedirects` (default 3), `timeoutMs` (default 10s), `allowedProtocols` (default `http:`/`https:`)
|
|
166
|
+
- 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
|
|
167
|
+
- Manual redirect following with per-hop revalidation + loop detection
|
|
168
|
+
- Per-request timeout via AbortController, propagates external `signal`
|
|
169
|
+
- 47 unit tests cover CIDR matching and the full SSRF gate matrix
|
|
170
|
+
|
|
171
|
+
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.
|
|
172
|
+
|
|
173
|
+
## 0.1.0 — 2026-05-16
|
|
174
|
+
|
|
175
|
+
Initial scaffold.
|
|
176
|
+
|
|
177
|
+
- `package.json` with subpath exports for `/fetch`, `/ratelimit`, `/crypto`, `/gdpr`
|
|
178
|
+
- `tsup` build config covering all five entry points
|
|
179
|
+
- Stub modules + public interfaces in place; implementations land in 0.2.x
|
|
180
|
+
- `hazo_logs` as required peer; `hazo_jobs` and `hazo_files` as optional peers (gdpr only)
|
|
181
|
+
|
|
182
|
+
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# hazo_secure
|
|
2
2
|
|
|
3
|
-
Security & compliance primitives for Node.js / Next.js apps.
|
|
3
|
+
Security & compliance primitives for Node.js / Next.js apps. Seven independent subpath modules — import only the ones you need.
|
|
4
4
|
|
|
5
5
|
| Subpath | Purpose |
|
|
6
6
|
|---|---|
|
|
@@ -9,6 +9,8 @@ Security & compliance primitives for Node.js / Next.js apps. Four independent su
|
|
|
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
11
|
| `hazo_secure/csrf` | Double-submit CSRF protection for Next.js routes — token generation, cookie header, constant-time verification |
|
|
12
|
+
| `hazo_secure/mask` | PII masking and anonymization transforms (redact, hash, tokenize, fake_name, jitter_date, …) |
|
|
13
|
+
| `hazo_secure/secrets` | Provider-pattern secret resolution — env vars, static map, or custom lookup |
|
|
12
14
|
|
|
13
15
|
## Install
|
|
14
16
|
|
|
@@ -16,7 +18,7 @@ Security & compliance primitives for Node.js / Next.js apps. Four independent su
|
|
|
16
18
|
npm install hazo_secure
|
|
17
19
|
```
|
|
18
20
|
|
|
19
|
-
`
|
|
21
|
+
`hazo_core` is a required peer dep. `hazo_logs`, `hazo_connect`, `hazo_jobs`, `hazo_files`, and `hazo_ui` are optional peers — `hazo_connect` is needed for `ConnectRateLimitStore`; `hazo_jobs` and `hazo_files` are only needed for `hazo_secure/gdpr`.
|
|
20
22
|
|
|
21
23
|
## Quick start
|
|
22
24
|
|
|
@@ -56,6 +58,24 @@ const stored = await encryptField(plaintext, { keys, aad: `user:${userId}` });
|
|
|
56
58
|
const recovered = await decryptField(stored, { keys });
|
|
57
59
|
```
|
|
58
60
|
|
|
61
|
+
### Resolve application secrets
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { EnvSecretsProvider, StaticSecretsProvider, LookupSecretsProvider } from "hazo_secure/secrets";
|
|
65
|
+
|
|
66
|
+
// Reads from env vars: HAZO_SECRET_DB_PASSWORD, HAZO_SECRET_STRIPE_KEY, …
|
|
67
|
+
const secrets = new EnvSecretsProvider();
|
|
68
|
+
const { db_password, stripe_key } = await secrets.resolve(["db_password", "stripe_key"]);
|
|
69
|
+
|
|
70
|
+
// Tests — in-memory map
|
|
71
|
+
const mock = new StaticSecretsProvider({ api_key: "test-key" });
|
|
72
|
+
|
|
73
|
+
// Custom backend (KMS, HashiCorp Vault, etc.)
|
|
74
|
+
const vault = new LookupSecretsProvider(async (name) => fetchFromVault(name));
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`SecretsError` (code `HAZO_SECURE_SECRET_NOT_FOUND`) is thrown when a name can't be resolved. Use a custom prefix: `new EnvSecretsProvider("MY_APP")` → reads `MY_APP_<NAME>`.
|
|
78
|
+
|
|
59
79
|
### Register GDPR exporters
|
|
60
80
|
|
|
61
81
|
```ts
|
|
@@ -152,6 +172,8 @@ await gdpr.anonymiseUser({ userId });
|
|
|
152
172
|
| `hazo_secure/fetch` | ⚠️ pre-flight only | ✅ full protection | Edge: host/IP checks only; Node: + undici connect-time validation |
|
|
153
173
|
| `hazo_secure/crypto` | ❌ | ✅ | Uses `node:crypto` |
|
|
154
174
|
| `hazo_secure/gdpr` | ❌ | ✅ | Async iterables + optional hazo_connect |
|
|
175
|
+
| `hazo_secure/mask` | ✅ | ✅ | Pure transforms — no I/O |
|
|
176
|
+
| `hazo_secure/secrets` | ✅ | ✅ | Pure async — no Node built-ins |
|
|
155
177
|
|
|
156
178
|
**Node.js ≥ 18 required** for all Node-only subpaths.
|
|
157
179
|
|
|
@@ -0,0 +1,85 @@
|
|
|
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/secrets`
|
|
38
|
+
|
|
39
|
+
No setup. Choose a provider at startup:
|
|
40
|
+
|
|
41
|
+
- **`EnvSecretsProvider(prefix?)`** — reads from `${prefix}_${NAME}` env vars (default prefix: `HAZO_SECRET`). Set each secret as an env var on your deployment platform.
|
|
42
|
+
- **`StaticSecretsProvider(map)`** — in-memory; use in tests only.
|
|
43
|
+
- **`LookupSecretsProvider(fn)`** — caller-supplied sync or async lookup; useful for KMS / Vault integrations.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { EnvSecretsProvider } from "hazo_secure/secrets";
|
|
47
|
+
const secrets = new EnvSecretsProvider();
|
|
48
|
+
const value = await secrets.get("db_password"); // reads HAZO_SECRET_DB_PASSWORD
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### `hazo_secure/gdpr`
|
|
52
|
+
|
|
53
|
+
1. Install optional peers if you'll use async export and ZIP delivery:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm install hazo_jobs hazo_files
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
2. Create the registry on app boot:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const gdpr = createGdprRegistry();
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
3. Register per-domain exporters and anonymisers in feature modules
|
|
66
|
+
4. Wire `/api/me/export` and `/api/me/delete` route handlers (project-specific)
|
|
67
|
+
|
|
68
|
+
## Verifying
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Fetch — expect: SafeFetchError, safeFetch, createSecureDispatcher, validateConnectIp
|
|
72
|
+
node --input-type=module -e "import * as m from 'hazo_secure/fetch'; console.log(Object.keys(m).join(', '))"
|
|
73
|
+
|
|
74
|
+
# Ratelimit — expect: MemoryRateLimitStore, ConnectRateLimitStore, withRateLimit, checkRateLimit, createRateLimiter
|
|
75
|
+
node --input-type=module -e "import * as m from 'hazo_secure/ratelimit'; console.log(Object.keys(m).join(', '))"
|
|
76
|
+
|
|
77
|
+
# Crypto — expect: encryptField, decryptField, CryptoError, EnvKeyProvider, StaticKeyProvider, HttpKeyProvider
|
|
78
|
+
node --input-type=module -e "import * as m from 'hazo_secure/crypto'; console.log(Object.keys(m).join(', '))"
|
|
79
|
+
|
|
80
|
+
# GDPR — expect: createGdprRegistry, GdprRegistryError
|
|
81
|
+
node --input-type=module -e "import * as m from 'hazo_secure/gdpr'; console.log(Object.keys(m).join(', '))"
|
|
82
|
+
|
|
83
|
+
# Secrets — expect: SecretsError, SecretsProvider (type), EnvSecretsProvider, StaticSecretsProvider, LookupSecretsProvider
|
|
84
|
+
node --input-type=module -e "import * as m from 'hazo_secure/secrets'; console.log(Object.keys(m).join(', '))"
|
|
85
|
+
```
|
|
@@ -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,5 @@
|
|
|
1
|
+
import { HazoError } from 'hazo_core';
|
|
2
|
+
|
|
1
3
|
interface HttpKeyProviderOptions {
|
|
2
4
|
/**
|
|
3
5
|
* Base HTTPS endpoint for key material.
|
|
@@ -69,9 +71,16 @@ interface EncryptedField {
|
|
|
69
71
|
tag: string;
|
|
70
72
|
aad?: string;
|
|
71
73
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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);
|
|
75
84
|
}
|
|
76
85
|
/**
|
|
77
86
|
* Encrypt a UTF-8 string under the current key.
|
|
@@ -131,8 +140,41 @@ declare class StaticKeyProvider implements KeyProvider {
|
|
|
131
140
|
}>;
|
|
132
141
|
byId(keyId: string): Promise<Uint8Array>;
|
|
133
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Lookup-function-backed `KeyProvider` for config-file (INI) key sources.
|
|
145
|
+
* Reads keys from a caller-supplied synchronous lookup function, decoding
|
|
146
|
+
* each value from base64 to a 32-byte AES-256 key.
|
|
147
|
+
*
|
|
148
|
+
* Expected key naming convention (mirrors hazo_config INI layout):
|
|
149
|
+
* `current` → the current key id (e.g. `"v1"`)
|
|
150
|
+
* `key_<id>` → base64-encoded 32-byte key (e.g. `key_v1=<base64>`)
|
|
151
|
+
*
|
|
152
|
+
* Example backed by hazo_config:
|
|
153
|
+
* ```ts
|
|
154
|
+
* const cfg = getConfig('crypto.myapp'); // hazo_config, not imported here
|
|
155
|
+
* const keys = new LookupKeyProvider(name => cfg.get(name));
|
|
156
|
+
* ```
|
|
157
|
+
*
|
|
158
|
+
* hazo_secure takes no dependency on hazo_config — the caller supplies the
|
|
159
|
+
* lookup, keeping this library config-agnostic. `Buffer.from(value, 'base64')`
|
|
160
|
+
* handles standard base64; keys decoded to anything other than 32 bytes are
|
|
161
|
+
* rejected with `invalid_key`.
|
|
162
|
+
*
|
|
163
|
+
* Decoded keys are memoised per id so the lookup runs once per key per
|
|
164
|
+
* process lifetime.
|
|
165
|
+
*/
|
|
166
|
+
declare class LookupKeyProvider implements KeyProvider {
|
|
167
|
+
private readonly lookup;
|
|
168
|
+
private readonly cache;
|
|
169
|
+
constructor(lookup: (name: string) => string | undefined);
|
|
170
|
+
current(): Promise<{
|
|
171
|
+
keyId: string;
|
|
172
|
+
key: Uint8Array;
|
|
173
|
+
}>;
|
|
174
|
+
byId(keyId: string): Promise<Uint8Array>;
|
|
175
|
+
}
|
|
134
176
|
/** Helper: build a `aad` string with a stable, parser-friendly shape.
|
|
135
177
|
* Convention: `<table>:<entityId>:<field>` — collision-free across tables. */
|
|
136
178
|
declare function aadFor(table: string, entityId: string, field: string): string;
|
|
137
179
|
|
|
138
|
-
export { CryptoError, type EncryptedField, EnvKeyProvider, HttpKeyProvider, type HttpKeyProviderOptions, type KeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField };
|
|
180
|
+
export { CryptoError, type CryptoErrorCode, type EncryptedField, EnvKeyProvider, HttpKeyProvider, type HttpKeyProviderOptions, type KeyProvider, LookupKeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField };
|
package/dist/crypto/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/crypto/index.ts
|
|
2
2
|
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
|
3
|
+
import { HazoError } from "hazo_core";
|
|
3
4
|
|
|
4
5
|
// src/crypto/provider-http.ts
|
|
5
6
|
var KEY_LEN = 32;
|
|
@@ -79,13 +80,17 @@ var HttpKeyProvider = class {
|
|
|
79
80
|
};
|
|
80
81
|
|
|
81
82
|
// src/crypto/index.ts
|
|
82
|
-
var CryptoError = class extends
|
|
83
|
+
var CryptoError = class extends HazoError {
|
|
83
84
|
constructor(message, code) {
|
|
84
|
-
super(
|
|
85
|
-
|
|
85
|
+
super({
|
|
86
|
+
code,
|
|
87
|
+
pkg: "hazo_secure",
|
|
88
|
+
message,
|
|
89
|
+
httpStatus: 500,
|
|
90
|
+
retryable: false
|
|
91
|
+
});
|
|
86
92
|
this.name = "CryptoError";
|
|
87
93
|
}
|
|
88
|
-
code;
|
|
89
94
|
};
|
|
90
95
|
var ALG = "aes-256-gcm";
|
|
91
96
|
var IV_LEN = 12;
|
|
@@ -213,6 +218,44 @@ var StaticKeyProvider = class {
|
|
|
213
218
|
return key;
|
|
214
219
|
}
|
|
215
220
|
};
|
|
221
|
+
var LookupKeyProvider = class {
|
|
222
|
+
constructor(lookup) {
|
|
223
|
+
this.lookup = lookup;
|
|
224
|
+
}
|
|
225
|
+
lookup;
|
|
226
|
+
cache = /* @__PURE__ */ new Map();
|
|
227
|
+
async current() {
|
|
228
|
+
const keyId = this.lookup("current");
|
|
229
|
+
if (!keyId) {
|
|
230
|
+
throw new CryptoError(
|
|
231
|
+
"LookupKeyProvider: no 'current' key id from lookup",
|
|
232
|
+
"missing_env_key"
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const key = await this.byId(keyId);
|
|
236
|
+
return { keyId, key };
|
|
237
|
+
}
|
|
238
|
+
async byId(keyId) {
|
|
239
|
+
if (this.cache.has(keyId)) return this.cache.get(keyId);
|
|
240
|
+
const lookupName = `key_${keyId}`;
|
|
241
|
+
const raw = this.lookup(lookupName);
|
|
242
|
+
if (!raw) {
|
|
243
|
+
throw new CryptoError(
|
|
244
|
+
`LookupKeyProvider: no key material for id ${keyId} (looked up "${lookupName}")`,
|
|
245
|
+
"key_not_found"
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const buf = Buffer.from(raw, "base64");
|
|
249
|
+
if (buf.byteLength !== KEY_LEN2) {
|
|
250
|
+
throw new CryptoError(
|
|
251
|
+
`${lookupName} decodes to ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN2}`,
|
|
252
|
+
"invalid_key"
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
this.cache.set(keyId, buf);
|
|
256
|
+
return buf;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
216
259
|
function aadFor(table, entityId, field) {
|
|
217
260
|
return `${table}:${entityId}:${field}`;
|
|
218
261
|
}
|
|
@@ -220,6 +263,7 @@ export {
|
|
|
220
263
|
CryptoError,
|
|
221
264
|
EnvKeyProvider,
|
|
222
265
|
HttpKeyProvider,
|
|
266
|
+
LookupKeyProvider,
|
|
223
267
|
StaticKeyProvider,
|
|
224
268
|
aadFor,
|
|
225
269
|
decryptField,
|
package/dist/csrf/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { HazoError } from 'hazo_core';
|
|
2
|
+
|
|
1
3
|
interface CsrfPolicy {
|
|
2
4
|
cookieName?: string;
|
|
3
5
|
headerName?: string;
|
|
@@ -14,7 +16,14 @@ interface CsrfPolicy {
|
|
|
14
16
|
};
|
|
15
17
|
}
|
|
16
18
|
type CsrfErrorCode = "csrf_cookie_missing" | "csrf_header_missing" | "csrf_mismatch" | "csrf_malformed";
|
|
17
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Typed error raised by `/csrf`. Extends `HazoError` so it participates in
|
|
21
|
+
* the workspace's correlation-ID auto-attach + envelope shape, while
|
|
22
|
+
* preserving the legacy `(code, message)` constructor + `err.code` short
|
|
23
|
+
* discriminator that 1.0.x consumers already check. All CSRF rejections
|
|
24
|
+
* map to HTTP 403.
|
|
25
|
+
*/
|
|
26
|
+
declare class CsrfError extends HazoError {
|
|
18
27
|
readonly code: CsrfErrorCode;
|
|
19
28
|
constructor(code: CsrfErrorCode, message: string);
|
|
20
29
|
}
|
package/dist/csrf/index.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
// src/csrf/index.ts
|
|
2
|
+
import { HazoError } from "hazo_core";
|
|
2
3
|
var DEFAULT_COOKIE_NAME = "hs_csrf";
|
|
3
4
|
var DEFAULT_HEADER_NAME = "x-csrf-token";
|
|
4
5
|
var DEFAULT_TOKEN_LENGTH = 32;
|
|
5
6
|
var DEFAULT_PROTECTED = ["POST", "PUT", "PATCH", "DELETE"];
|
|
6
|
-
var CsrfError = class extends
|
|
7
|
+
var CsrfError = class extends HazoError {
|
|
7
8
|
constructor(code, message) {
|
|
8
|
-
super(
|
|
9
|
-
|
|
9
|
+
super({
|
|
10
|
+
code,
|
|
11
|
+
pkg: "hazo_secure",
|
|
12
|
+
message,
|
|
13
|
+
httpStatus: 403,
|
|
14
|
+
retryable: false
|
|
15
|
+
});
|
|
10
16
|
this.name = "CsrfError";
|
|
11
17
|
}
|
|
12
|
-
code;
|
|
13
18
|
};
|
|
14
19
|
function resolved(policy) {
|
|
15
20
|
return {
|