haechi 1.3.0 → 1.3.1
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/README.ko.md +12 -1
- package/README.md +12 -1
- package/docs/current/code-review-risk-register-2026-06-16.ko.md +377 -0
- package/docs/current/code-review-risk-register-2026-06-16.md +377 -0
- package/docs/current/configuration.ko.md +2 -1
- package/docs/current/configuration.md +2 -1
- package/docs/current/risk-register-release-gate.ko.md +30 -5
- package/docs/current/risk-register-release-gate.md +30 -5
- package/docs/current/shared-responsibility.ko.md +10 -1
- package/docs/current/shared-responsibility.md +10 -1
- package/docs/current/threat-model.ko.md +3 -0
- package/docs/current/threat-model.md +3 -0
- package/package.json +1 -1
- package/packages/cli/bin/haechi.mjs +92 -3
- package/packages/cli/runtime.mjs +49 -0
- package/packages/core/index.mjs +15 -0
- package/packages/crypto/index.mjs +42 -20
- package/packages/proxy/index.mjs +263 -28
- package/packages/ssrf/index.mjs +60 -4
- package/packages/stream-filter/index.mjs +127 -12
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
# Haechi Risk Register and Release Gates
|
|
2
2
|
|
|
3
3
|
- Status: Living document (tracks core 1.3.x)
|
|
4
|
-
- Date: 2026-06-
|
|
4
|
+
- Date: 2026-06-16
|
|
5
5
|
- Target version: 1.3.x
|
|
6
6
|
- Branch: `main`
|
|
7
7
|
|
|
8
8
|
## 1. Current Assessment
|
|
9
9
|
|
|
10
|
-
Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haechi@0.3.2`) and every gate through
|
|
10
|
+
Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haechi@0.3.2`) and every gate through G8 (1.3.0 backend + detection coverage expansion) are passed; the gate history below is retained as the audit trail. 1.0.0 declared the frozen API contract under strict semver (with a documented deprecation policy and `tests/api-contract.test.mjs` as the freeze guard) and narrowly lifted the dynamic-loading ban for a signed, sandboxed `authProvider` plugin; 1.1.0 added the opt-in `process-isolated` plugin runtime with kernel-enforced capability denial. The previously distribution-blocking conditions for the stable label — 1.0 API stability, the external `cryptoProvider`/KMS reference adapter (`haechi-crypto-kms`), and stream-aware enforcement (`streaming.requestMode: "inspect"`) — are all in place. Haechi remains a self-hosted security toolkit, not a compliance guarantee, and production deployments still own network access control, upstream authentication, and key custody (see §5 of the threat model).
|
|
11
|
+
|
|
12
|
+
**2026-06-16 code-review remediation — shipped in `haechi@1.3.1`:** a full code review opened the risk register at `docs/current/code-review-risk-register-2026-06-16.md`. The review found one P0 credential-boundary leak, four P1 release-blocking issues, and eight P2 hardening/test gaps. **All 13 `P*-CR-*` findings are Resolved (§5.7) and shipped in the `haechi@1.3.1` remediation cut (2026-06-16, attested OIDC publish).** G9 is **Pass**. Operators must upgrade from `haechi@1.3.0` to `1.3.1` to pick up the fixes (notably the P0-CR-001 proxy header-boundary patch).
|
|
11
13
|
|
|
12
14
|
| Category | Judgment | Rationale |
|
|
13
15
|
|---|---|---|
|
|
14
16
|
| GitHub public | Allowed | Security limitations, threat model, and shared responsibility are documented |
|
|
15
|
-
| GitHub release/tag | Allowed |
|
|
16
|
-
| npm stable |
|
|
17
|
-
| Production use | Operator-gated
|
|
17
|
+
| GitHub release/tag | Allowed (`v1.3.1` released) | The `v1.3.1` remediation cut is tagged and released; all §5.7 findings are Resolved and G9 is Pass |
|
|
18
|
+
| npm stable | `haechi@1.3.1` published | The code-review remediation shipped in the `haechi@1.3.1` attested OIDC publish (2026-06-16); the prior `1.3.0` carries the pre-fix behavior |
|
|
19
|
+
| Production use | Operator-gated; upgrade to `1.3.1` | Supported only with operator network controls, authz/authn, and key custody; operators on `haechi@1.3.0` should upgrade to `1.3.1` to pick up the proxy header-boundary fix (P0-CR-001) before routing sensitive third-party upstream traffic through the proxy |
|
|
18
20
|
|
|
19
21
|
## 2. Release Gates
|
|
20
22
|
|
|
@@ -29,6 +31,7 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
|
|
|
29
31
|
| G6 | 1.1.0 plugin capability enforcement (`process-isolated`) | P1-SEC-027 / P1-SEC-028 mitigated; the `process-isolated` runtime (child under `--permission`, zero grants, `data:`-URL load, stdio-ignored, JSON-string IPC) + the fail-closed `--allow-net` feature detection (`netEnforcement:"require-permission"`) + the core `haechi/ssrf` guard + host-mediated key material + the spawn-storm circuit breaker; the fs/net/stdio red-team + SSRF + config tests green (the behavioral suite runs on a `--allow-net` Node and skips fail-closed otherwise); the API freeze stays green (additive `./ssrf` export + additive config keys); core stays zero runtime dependency; core bumped to 1.1.0 (additive + opt-in minor) | Pass |
|
|
30
32
|
| G7 | 1.2.0 Reliability Hardening Track (WS1–WS6) | Detection quality measured + tightened (WS2: a labeled-corpus precision/recall `bench:detection` gate, credential + international-PII coverage, `filters.minConfidence` / `filters.allowlist` with the hard-block-types invariant, NFKC unicode-evasion folding with offset-integrity); WS3 injectable `rateLimiter` seam + bounded fixed-window map; WS4 operability (`/__haechi/live`+`/ready` split, injectable `/metrics`, structured logs + per-request `correlationId`, graceful drain, max-in-flight backpressure, env overlay, hardened Dockerfile/compose/runbook, `configVersion`); WS6 proxy TLS / remote-bind hardening (`proxy.tls` / `proxy.trustForwardedProto`, fail-closed `assertSafeProxyTransport`) + OWASP-LLM/NIST control-mapping whitepaper + RFC 9116 `security.txt` + vulnerability-disclosure path. Every change is additive behind 1.1-preserving defaults (`tests/api-contract.test.mjs` green); the no-plaintext-in-audit invariant extends to telemetry; core stays zero runtime dependency; core bumped to 1.2.0 (additive minor) | Pass |
|
|
31
33
|
| G8 | 1.3.0 backend + detection coverage expansion | New protocol adapters for the **Anthropic Messages API** (`/v1/messages`, content-block + SSE `delta.text` with `event:`-line-preserving re-serialize) and the **Google Gemini API** (model-in-path `:generateContent`/`:streamGenerateContent` via an additive `:method`-suffix route matcher that leaves the exact-match adapters byte-identical); detection coverage expansion — cloud/SaaS provider keys (OpenAI/Anthropic/Google-OAuth/SendGrid/Twilio/npm/Azure, anchored) and international PII (FR/ES/JP + IT/SG/IN/DE/NL national IDs with checksum validators), each hard-block-vs-dial-eligible decision driven by measured collision rates (a non-numeric anchor or implausibly-rare shape is required for hard-block; a bare-digit run over a common length stays allowlist-clearable); a `bench:throughput` proxy load benchmark; the `haechi-ratelimit-redis` shared-store rate-limiter satellite (the WS3 seam's production consumer; the proxy now `await`s `rateLimiter.allow`); `haechi-dashboard` surfaces the per-request `correlationId`. Every change is additive — new `target.type`/detection-type/`privacy.profile` *values*, not new config keys (`configVersion` stays `1`); `tests/api-contract.test.mjs` green; core stays zero runtime dependency; core bumped to 1.3.0 (additive minor) | Pass |
|
|
34
|
+
| G9 | 2026-06-16 full code-review remediation gate (shipped in 1.3.1) | `P0-CR-001` and `P1-CR-002` through `P1-CR-005` resolved or formally accepted; P2 items either resolved or scheduled with explicit non-blocking rationale; linked register updated. **All 13 `P*-CR-*` findings are Resolved (§5.7) and shipped in `haechi@1.3.1` (2026-06-16, attested OIDC publish); core bumped 1.3.0 → 1.3.1 (patch, remediation-only — no API/config surface change, `configVersion` stays `1`).** | Pass (`haechi@1.3.1`, 2026-06-16) |
|
|
32
35
|
|
|
33
36
|
## 3. P0 Distribution-Blocking Risk Status
|
|
34
37
|
|
|
@@ -136,6 +139,26 @@ Additive, accumulating on `main` toward a later `1.2.0` minor; the seam + honest
|
|
|
136
139
|
|---|---|---|---|
|
|
137
140
|
| P1-OPS-010 | Proxy rate limiter is single-process and **not injectable**, and its fixed-window `Map` is **never pruned** — a one-shot identity's slot lingers forever, so a high-cardinality identity stream is unbounded memory growth keyed by identity; and a multi-replica deployment silently weakens the limit (per-process throughput multiplies by the replica count) with no replaceable seam | Mitigated | The rate limiter is now an **injectable collaborator** mirroring `cryptoProvider`/`auditSink`/`tokenVault`: `createRuntime(config, { rateLimiter })` (`packages/cli/runtime.mjs`) supplies it, `assertProvider("rateLimiter", …, ["allow"])` fails closed at construction if it lacks `allow()`, and it is exposed on the returned runtime object; the proxy consults `runtime.rateLimiter` (`packages/proxy/index.mjs`, with a backward-compatible local-default fallback for a hand-built runtime). The default per-process in-memory fixed-window limiter (the documented default; `allow(key, limit) -> boolean`, 429 semantics unchanged) is **self-bounding**: a lazy, amortized sweep evicts fully-expired window slots once the `Map` crosses a size threshold — **no background timer** (so `node --test` does not hang). A multi-replica operator injects a shared-store implementation (e.g. Redis) satisfying the same contract, or enforces the limit at a shared front door. Docs: `configuration.md`(+ko) "Rate limiter injection" seam, `shared-responsibility.md`(+ko) §4. Tests: `tests/rate-limiter.test.mjs` — an injected limiter is the one consulted (deny→429, allow→pass-through), fail-closed on a missing `allow()`, the default limiter prunes aged-out one-shot identities (bounded `Map` via `_size()`), and the fixed-window limit/isolation semantics are unchanged; the existing `tests/proxy-auth.test.mjs` 429 test stays green. **Residual:** core ships **no** built-in distributed limiter (track non-goal §5) — a shared-store implementation is the operator's injection or a future satellite; the default's per-process scope is the documented honest default |
|
|
138
141
|
|
|
142
|
+
## 5.7 2026-06-16 Full Code Review Open Risk Status
|
|
143
|
+
|
|
144
|
+
The authoritative itemized register is `docs/current/code-review-risk-register-2026-06-16.md`. This section is the release-gate summary. **All 13 findings are Resolved and shipped in `haechi@1.3.1`** (2026-06-16): the P0 + four P1s (proxy header-boundary patch, SSRF IPv4-mapped normalization, response-header/streaming bounds, streaming-inspection text fix) and all eight P2s (CR-006 mcp-wrap stderr filter, CR-007 init key-file validation, CR-008 satellite `manifest.bin` check, CR-009 auth-throw regression test, CR-010 process-sandbox quota tests, CR-011 audit middle-tamper tests, CR-012 vault IPv6 tests, CR-013 SSE multi-line `data:`). **G9 is Pass.**
|
|
145
|
+
|
|
146
|
+
| ID | Risk | Status | Required closure evidence |
|
|
147
|
+
|---|---|---|---|
|
|
148
|
+
| P0-CR-001 | Proxy forwards client `Authorization`, `Cookie`, proxy-auth, and similar ambient credentials to the model upstream | Resolved | Default-drop upstream header allowlist in `filteredHeaders()` with a `forwardPolicy` threaded from `createHaechiProxy` (gateway-client auth separated from upstream-provider auth: client `Authorization` dropped when `auth.provider !== none`, forwarded when `none`); always-drop cookie/proxy-auth/hop-by-hop; additive fail-closed `target.forwardHeaders`; `tests/proxy-header-allowlist.test.mjs` proves the gateway bearer is not seen upstream while provider headers (`x-api-key`/`anthropic-version`/`x-goog-api-key`) are; README/threat-model/shared-responsibility/configuration (+ko) updated |
|
|
149
|
+
| P1-CR-002 | SSRF guard misses hex IPv4-mapped IPv6 private addresses such as `::ffff:7f00:1` | Resolved | Each `isBlockedAddress` copy (core `packages/ssrf`, `satellites/auth-jwt`, `satellites/crypto-kms/vault.mjs`) now parses an IPv4-mapped IPv6 address to its 16 octets and normalizes the embedded IPv4 (dotted `::ffff:127.0.0.1` AND hex `::ffff:7f00:1`, bracketed, leading-zero, mixed `::`, case-insensitive) before the private/loopback/link-local/metadata check; a genuinely public mapped address (`::ffff:8.8.8.8` == `::ffff:808:808`) stays allowed and the old vault over-block is gone. The copies stay DELIBERATELY independent (no satellite imports `haechi/ssrf` — that would raise their core peer floor); drift is guarded by the parity tests. Tests: `tests/ssrf.test.mjs` (hex/dotted/bracketed loopback+RFC1918+metadata+public vectors, core-vs-auth-jwt parity), `satellites/auth-jwt/auth-jwt.test.mjs` (mapped-IPv6 construction blocks + public-mapped not-blocked), `satellites/crypto-kms/vault.test.mjs` (extended range table + P2-CR-012 IPv6 loopback test), `satellites/crypto-kms/ssrf-parity.test.mjs` (dotted+hex mapped parity vectors) |
|
|
150
|
+
| P1-CR-003 | Auto-decompressed upstream body can be returned with original compressed response headers | Resolved | Centralized `sanitizeResponseHeaders()` (strips content-encoding/content-length/transfer-encoding/hop-by-hop) applied on every response path — pass-through, forwarded/unprotected, protected, streaming; correct content-length re-set only for a buffered body; `tests/proxy-header-allowlist.test.mjs` gzip pass-through + unprotected response tests prove no stale content-encoding and a readable downstream body |
|
|
151
|
+
| P1-CR-004 | `streaming.requestMode: "pass-through"` buffers the full upstream body without a response-size cap | Resolved | True bounded streaming pass-through (`pipeUpstreamBodyBounded`) with a running byte cap (`responseProtection.maxBytes`) that cancels upstream + tears down the client write on overrun; the unprotected/forwarded raw read also capped (502 over the cap); `tests/proxy-header-allowlist.test.mjs` proves an oversize pass-through stream is bounded/aborted |
|
|
152
|
+
| P1-CR-005 | Streaming inspection raw-passes non-JSON SSE/NDJSON frames, allowing plain-text PII bypass | Resolved | `parseFrame` (`packages/stream-filter/index.mjs`) splits parse-failed frames into a CONTROL allowlist (`[DONE]`, comment-only, empty/keepalive → pass raw) vs a non-JSON CONTENT frame (its `data:` text); `handleFrame` inspects a CONTENT frame as text via a new `protector.protectText` (`packages/core/index.mjs`, single-shot `transformSegment`, DISTINCT from the delta `push`/`flush` buffer so it never corrupts the JSON sliding buffer), re-emits `data: <protected text>` (`serializeTextFrame`), and fails the stream closed on a block action; response-direction marker skip + audit tally preserved; JSON delta path unchanged. Tests: `tests/stream-filter.test.mjs` (plain-text SSE redacted, block action blocks, malformed/partial JSON with PII, NDJSON non-JSON text, control-frame pass-through, marker not re-flagged) + `tests/proxy-streaming.test.mjs` end-to-end plain-text repro |
|
|
153
|
+
| P2-CR-006 | `mcp-wrap` inherits child `stderr` without filtering or audit | Resolved | `haechi mcp-wrap` gains `--stderr filter\|drop\|inherit` (default `filter`): each complete stderr line is protected via `createStreamProtector().protectText` before re-emit (chunk-boundary buffered, block-action dropped, audit-silent), `drop` discards, `inherit` is an explicit opt-in boundary, unknown value fails closed; `tests/mcp-wrap.test.mjs` covers all four modes |
|
|
154
|
+
| P2-CR-007 | Existing key files are not validated by `initLocalKeyFile()` | Resolved | `initLocalKeyFile` existing-file non-force path now validates via the shared `loadKeyFile({ requireActive:true })` (corrupted JSON, missing active key, wrong-length active/retired key all throw); valid files stay non-destructive; `tests/crypto.test.mjs` covers the four cases |
|
|
155
|
+
| P2-CR-008 | Satellite packaging check does not validate `manifest.bin` target files | Resolved | `evaluateSatellitePackaging()` validates every `manifest.bin` target (string + object-map forms) against the packed-file set; `tests/satellite-packaging-gate.test.mjs` adds positive + negative (missing-bin) cases |
|
|
156
|
+
| P2-CR-009 | `authProvider.authenticate()` exception path lacks regression coverage | Resolved | `tests/proxy-auth.test.mjs` injects a throwing provider and asserts fail-closed (not forwarded, generic client error), audit status `haechi_auth_provider_error`, and no raw error/subject/issuer leak; mutation-verified |
|
|
157
|
+
| P2-CR-010 | Process-isolated sandbox quota branches lack parity tests | Resolved | `tests/plugin-process-sandbox.test.mjs` (+ crash fixture) adds isolated-process parity: oversized result denied, over-capacity rejected, timeout terminated, child-crash fail-closed; mutation-verified against the real `process-sandbox.mjs` |
|
|
158
|
+
| P2-CR-011 | Audit chain middle-tamper branches lack focused tests | Resolved | `tests/audit-chain-tamper.test.mjs` writes a real multi-record log and asserts `verifyAuditChain` rejects middle-record content mutation, missing/wrong `previousHash`, and wrong `eventHash`; the tail-truncation limitation stays documented |
|
|
159
|
+
| P2-CR-012 | KMS vault IPv6 loopback carve-out lacks IPv6-focused tests | Resolved | `satellites/crypto-kms/vault.test.mjs` adds a dedicated IPv6 loopback policy test ("…enforces the IPv6 loopback policy (::1, [::1], dotted + hex mapped) — P2-CR-012") covering bare `::1`, bracketed `[::1]`, dotted `::ffff:127.0.0.1`, and hex `::ffff:7f00:1`/`::ffff:7f00:0001` (plus bracketed variants), and asserts a public mapped address (`::ffff:8.8.8.8`/`::ffff:808:808`) is NOT over-blocked; the extended range table and `ssrf-parity.test.mjs` lock the dotted+hex agreement with auth-jwt |
|
|
160
|
+
| P2-CR-013 | SSE multi-line `data:` fields are joined without newline separators | Resolved | `parseFrame` joins multiple `data:` lines with `join("\n")` (spec separator) and strips only the single spec leading space per line; multi-line JSON still `JSON.parse`s, multi-line plain text is reconstructed with newlines for inspection, and `serializeTextFrame` re-emits a multi-line payload as multiple `data:` lines; `tests/stream-filter.test.mjs` covers a multi-line JSON event and a multi-line plain-text event with PII |
|
|
161
|
+
|
|
139
162
|
## 6. P2 Product/Documentation Risk Status
|
|
140
163
|
|
|
141
164
|
| ID | Risk | Status | Resolution evidence |
|
|
@@ -149,6 +172,8 @@ Additive, accumulating on `main` toward a later `1.2.0` minor; the seam + honest
|
|
|
149
172
|
|
|
150
173
|
This checklist is the standing pre-distribution template for every release on the `1.x` stable line; it was first exercised for the `0.3.2` developer preview, whose results are retained below as the reference record.
|
|
151
174
|
|
|
175
|
+
Current 2026-06-16 status: G9 is `Pass` — the code-review remediation shipped in `haechi@1.3.1`. This checklist is cleared for that cut.
|
|
176
|
+
|
|
152
177
|
External npm gate check results (`0.3.2` developer preview, 2026-06-10, post-publish):
|
|
153
178
|
|
|
154
179
|
- `npm whoami`: `raeseoklee`
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|---|---|---|
|
|
10
10
|
| 로컬 개발 | CLI, default config, dev key 생성 | dev key를 운영 환경이나 공유 환경에 재사용하지 않습니다 |
|
|
11
11
|
| 정책 집행 | redact/mask/tokenize/encrypt/block pipeline | 규제 정책과 조직 정책에 맞는 action을 선택합니다 |
|
|
12
|
-
| HTTP proxy | loopback 기본값, remote bind guard, body/response limit | 인증, TLS termination, firewall, upstream auth를
|
|
12
|
+
| HTTP proxy | loopback 기본값, remote bind guard, body/response limit, 기본 차단 upstream 헤더 허용목록(gateway-클라이언트 인증과 upstream-제공자 인증 분리) | 인증, TLS termination, firewall, upstream auth를 담당합니다. upstream 제공자 키는 의도적으로 공급합니다(클라이언트 `Authorization`은 `auth.provider: none`일 때만 전달되며, 그 외에는 `x-api-key` 같은 제공자 키 헤더를 설정하거나 추가 헤더를 `target.forwardHeaders`에 나열합니다) |
|
|
13
13
|
| Streaming | 기본 차단 | pass-through를 사용할 때 보호가 적용되지 않는 위험을 감수합니다 |
|
|
14
14
|
| TokenVault | 암호화 저장, reveal 기본 차단, purge | reveal 승인 절차와 DSAR/retention 운영을 담당합니다 |
|
|
15
15
|
| Audit | 평문 제거, hash chain | append-only storage, backup, 보존 기간, 외부 서명을 담당합니다 |
|
|
@@ -45,3 +45,12 @@ Haechi의 상태 보유 통제는 설계상 단일 프로세스입니다. 로드
|
|
|
45
45
|
- **Audit hash chain + anchor**는 단일 작성자입니다. 각 복제본에 **고유한** `audit.path`(및 anchor 경로)를 주세요. 하나의 audit 파일을 복제본 간에 공유하면 체인이 분기되어 검증 불가 상태가 됩니다.
|
|
46
46
|
- **TokenVault와 auth store**는 whole-file 로컬 저장소입니다 — 단일 호스트에서는 올바르지만 공유 다중 작성자 저장소는 아닙니다. 다중 복제 토큰화에는 공유 `tokenVault`를 주입하세요.
|
|
47
47
|
- 파일 락은 `O_EXCL` + atomic rename에 의존하며 NFS/공유 파일시스템에서는 보장되지 않습니다 — 이 저장소들은 로컬 디스크에 두세요.
|
|
48
|
+
|
|
49
|
+
## 5. Gateway 인증과 upstream 인증 (헤더 전달)
|
|
50
|
+
|
|
51
|
+
Haechi는 **gateway-클라이언트 인증**과 **upstream-제공자 인증**을 분리합니다. proxy는 임의의 클라이언트 헤더를 모델 upstream으로 전달하지 않고 기본 차단 허용목록을 적용합니다(P0-CR-001):
|
|
52
|
+
|
|
53
|
+
- `auth.provider`가 `bearer`/`external`/`plugin`이면 클라이언트의 `Authorization`은 Haechi가 소비한 **gateway credential**이므로 upstream으로 **절대 전달되지 않습니다**. upstream 제공자 키는 별도로 공급하세요 — 클라이언트 요청에 제공자 키 헤더(`x-api-key`, `x-goog-api-key` 등, 모두 허용목록에 포함)를 설정하거나, 자체 credential 주입으로 upstream을 감싸십시오.
|
|
54
|
+
- `auth.provider`가 `none`이면 클라이언트의 `Authorization`은 **upstream 제공자 키**로 간주되어 전달됩니다(OpenAI 호환 pass-through 패턴).
|
|
55
|
+
- `Cookie`, `Set-Cookie`, `Proxy-Authorization`, hop-by-hop 헤더는 항상 폐기되고, 허용목록에 없는 헤더는 기본 폐기됩니다. 특이한 upstream에는 `target.forwardHeaders`(소문자 이름)로 허용목록을 넓히세요 — 항상 폐기되는 credential/hop-by-hop 헤더는 다시 켤 수 없습니다.
|
|
56
|
+
- **운영자 책임:** upstream이 필요한 credential 헤더를 실제로 받는지 확인하고(gateway 인증에서는 gateway가 더 이상 클라이언트 `Authorization`을 중계하지 않습니다), `target.forwardHeaders`는 무분별한 통과 목록이 아니라 검토된 허용목록으로 다루세요.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|---|---|---|
|
|
10
10
|
| Local development | CLI, default config, dev key generation | Do not reuse dev keys in production or shared environments |
|
|
11
11
|
| Policy enforcement | redact/mask/tokenize/encrypt/block pipeline | Select actions appropriate to regulatory and organizational policy |
|
|
12
|
-
| HTTP proxy | Loopback default, remote bind guard, body/response limits | Authentication, TLS termination, firewall, upstream auth |
|
|
12
|
+
| HTTP proxy | Loopback default, remote bind guard, body/response limits, default-drop upstream header allowlist (gateway-client auth separated from upstream-provider auth) | Authentication, TLS termination, firewall, upstream auth; supply the upstream provider key intentionally (client `Authorization` is forwarded only with `auth.provider: none`; otherwise set provider key headers like `x-api-key` or list extras in `target.forwardHeaders`) |
|
|
13
13
|
| Streaming | Blocked by default | Accept the risk of no protection when using pass-through |
|
|
14
14
|
| TokenVault | Encrypted storage, reveal blocked by default, purge | Reveal approval workflow, DSAR/retention operations |
|
|
15
15
|
| Audit | Plaintext removal, hash chain | Append-only storage, backup, retention period, external signing |
|
|
@@ -45,3 +45,12 @@ Haechi's stateful controls are single-process by design. Running 2+ replicas beh
|
|
|
45
45
|
- **Audit hash chain + anchor** are single-writer. Give each replica its **own** `audit.path` (and anchor path); never share one audit file across replicas, or the chain forks into an unverifiable state.
|
|
46
46
|
- **TokenVault and the auth store** are whole-file local stores — correct for one host, but not a shared multi-writer store. For multi-replica tokenization, inject a shared `tokenVault`.
|
|
47
47
|
- File locking relies on `O_EXCL` + atomic rename, which do not hold on NFS / shared filesystems — keep these stores on local disk.
|
|
48
|
+
|
|
49
|
+
## 5. Gateway auth vs upstream auth (header forwarding)
|
|
50
|
+
|
|
51
|
+
Haechi keeps **gateway-client authentication** and **upstream-provider authentication** separate. The proxy does NOT forward arbitrary client headers to the model upstream; it applies a default-drop allowlist (P0-CR-001):
|
|
52
|
+
|
|
53
|
+
- When `auth.provider` is `bearer`/`external`/`plugin`, the client's `Authorization` is the **gateway credential** Haechi consumed and is **never forwarded** upstream. Supply the upstream provider key out-of-band — set the provider key header (`x-api-key`, `x-goog-api-key`, etc., all on the allowlist) on the client request, or front the upstream with your own credential injection.
|
|
54
|
+
- When `auth.provider` is `none`, the client's `Authorization` is treated as the **upstream provider key** and is forwarded (the OpenAI-compatible pass-through pattern).
|
|
55
|
+
- `Cookie`, `Set-Cookie`, `Proxy-Authorization`, and hop-by-hop headers are always dropped; any non-allowlisted header is dropped by default. Use `target.forwardHeaders` (lowercase names) to widen the allowlist for an unusual upstream — it cannot re-enable an always-dropped credential/hop-by-hop header.
|
|
56
|
+
- **Operator responsibility:** confirm your upstream actually receives the credential header it needs (the gateway no longer relays the client `Authorization` under gateway auth), and treat `target.forwardHeaders` as a reviewed allowlist, not a catch-all.
|
|
@@ -33,6 +33,9 @@ Haechi가 보호하려는 주요 자산은 다음과 같습니다.
|
|
|
33
33
|
| 위협 | 영향 | 현재 통제 |
|
|
34
34
|
|---|---|---|
|
|
35
35
|
| 인터넷 노출 proxy | 인증 없는 LLM gateway | non-loopback bind 기본 실패 |
|
|
36
|
+
| gateway credential의 upstream 전달 | Haechi가 소비한 gateway 토큰인 클라이언트 `Authorization`, `Cookie`, `Proxy-Authorization`가 모델 제공자로 전달되어 gateway 비밀이 신뢰 경계를 넘어 유출됩니다 (P0-CR-001) | **기본 차단 upstream 헤더 허용목록.** proxy는 명시적인 제공자/어댑터 헤더 집합(`x-api-key`, `anthropic-version`, `anthropic-beta`, `x-goog-api-key`, `openai-organization`, `openai-beta`, `accept`, `accept-language`, `user-agent`, `content-type`)만 전달합니다. `Cookie`/`Set-Cookie`/`Proxy-Authorization`와 hop-by-hop 헤더는 항상 폐기됩니다. `Authorization`은 `auth.provider !== none`이면(gateway credential이므로) 폐기되고, `auth.provider: none`일 때만(upstream 제공자 키이므로) 전달됩니다. `target.forwardHeaders`는 허용목록을 추가로 넓히지만 항상 폐기되는 헤더를 다시 켤 수는 없습니다(설정 시점 fail-closed) |
|
|
37
|
+
| 압축 해제된 바디에 잔존하는 압축 헤더 | Node `fetch()`가 gzip/br/deflate를 자동 해제하지만, upstream의 `content-encoding`/`content-length`를 유지한 채 전달하면 downstream 클라이언트가 평문 바이트에 `content-encoding: gzip`을 보고 "incorrect header check"로 실패합니다 (P1-CR-003) | **모든 응답 경로에서 중앙화된 `sanitizeResponseHeaders`**(pass-through, 전달/미보호, 보호, streaming): `content-encoding`, `content-length`, `transfer-encoding`, hop-by-hop 헤더를 제거하고, 완전 버퍼링된 바디에 한해 올바른 `content-length`만 다시 설정합니다 |
|
|
38
|
+
| 무제한 streaming pass-through | `streaming.requestMode: "pass-through"`가 크기 제한 없이 전체 upstream 바디를 버퍼링해, 장수명·악의적 스트림이 메모리/연결 자원을 무한정 점유할 수 있었습니다 (P1-CR-004) | **진정한 경계 streaming pass-through**: upstream 바디를 도착하는 대로 실행 바이트 카운트(`responseProtection.maxBytes`)와 함께 클라이언트로 파이핑하며, 한도를 초과하면 upstream 읽기를 취소하고 클라이언트 쓰기를 종료합니다(크기 기준 fail-closed). 동일한 한도가 미보호/전달 버퍼링 읽기에도 적용됩니다(한도 초과 시 502) |
|
|
36
39
|
| streaming 우회 | SSE/NDJSON 평문 유출 | `inspect` 모드는 SSE/NDJSON을 stream-filter합니다. `block`(기본값)은 거부하고, `pass-through`는 명시적으로 감사된 opt-out입니다 |
|
|
37
40
|
| Ollama 암묵 streaming 우회 | `stream` 생략 시 NDJSON 평문 유출 | `/api/chat`·`/api/generate`는 `stream: false`를 명시하지 않으면 streaming으로 간주해 기본 차단합니다 |
|
|
38
41
|
| 비JSON/압축/대용량 응답 | responseProtection 우회 | fail-closed response policy |
|
|
@@ -33,6 +33,9 @@ The primary assets Haechi protects are:
|
|
|
33
33
|
| Threat | Impact | Current Control |
|
|
34
34
|
|---|---|---|
|
|
35
35
|
| Internet-exposed proxy | Unauthenticated LLM gateway | Non-loopback bind fails by default |
|
|
36
|
+
| Gateway credential forwarded upstream | The client `Authorization` (the gateway token Haechi consumed), `Cookie`, or `Proxy-Authorization` is forwarded to the model provider, leaking a gateway secret across the trust boundary (P0-CR-001) | **Default-drop upstream header allowlist.** The proxy forwards only an explicit provider/adapter header set (`x-api-key`, `anthropic-version`, `anthropic-beta`, `x-goog-api-key`, `openai-organization`, `openai-beta`, `accept`, `accept-language`, `user-agent`, `content-type`). `Cookie`/`Set-Cookie`/`Proxy-Authorization` and hop-by-hop headers are always dropped. `Authorization` is dropped when `auth.provider !== none` (it is the gateway credential), and forwarded only when `auth.provider: none` (it is the upstream provider key). `target.forwardHeaders` widens the allowlist additively but cannot re-enable an always-dropped header (fail-closed at config time) |
|
|
37
|
+
| Decompressed body with stale compression headers | Node `fetch()` auto-decompresses gzip/br/deflate, but a forwarded response that keeps the upstream `content-encoding`/`content-length` makes a downstream client see e.g. `content-encoding: gzip` on plain bytes and fail with "incorrect header check" (P1-CR-003) | **Centralized `sanitizeResponseHeaders` on every response path** (pass-through, forwarded/unprotected, protected, streaming): strips `content-encoding`, `content-length`, `transfer-encoding`, and hop-by-hop headers; a correct `content-length` is re-set only for a fully-buffered body |
|
|
38
|
+
| Unbounded streaming pass-through | `streaming.requestMode: "pass-through"` buffered the full upstream body with no size cap, so a long-lived or malicious stream could hold memory/connection resources indefinitely (P1-CR-004) | **True bounded streaming pass-through**: the upstream body is piped to the client as it arrives with a running byte cap (`responseProtection.maxBytes`); exceeding the cap cancels the upstream read and tears down the client write (fail-closed on size). The same cap applies to the unprotected/forwarded buffered-body read (502 over the cap) |
|
|
36
39
|
| Streaming bypass | SSE/NDJSON plaintext leak | `inspect` mode stream-filters SSE/NDJSON; `block` (default) refuses; `pass-through` is an explicit audited opt-out |
|
|
37
40
|
| Ollama implicit streaming bypass | NDJSON plaintext leak when `stream` is omitted | `/api/chat` and `/api/generate` are treated as streaming unless `stream: false` is explicit; blocked by default |
|
|
38
41
|
| Non-JSON / compressed / oversized response | responseProtection bypass | Fail-closed response policy |
|
package/package.json
CHANGED
|
@@ -549,6 +549,8 @@ async function mcpStdioCommand(argv) {
|
|
|
549
549
|
await runMcpStdioFilter({ runtime });
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
const STDERR_MODES = new Set(["filter", "drop", "inherit"]);
|
|
553
|
+
|
|
552
554
|
async function mcpWrapCommand(argv) {
|
|
553
555
|
const separator = argv.indexOf("--");
|
|
554
556
|
if (separator === -1 || !argv[separator + 1]) {
|
|
@@ -558,21 +560,108 @@ async function mcpWrapCommand(argv) {
|
|
|
558
560
|
const command = argv[separator + 1];
|
|
559
561
|
const commandArgs = argv.slice(separator + 2);
|
|
560
562
|
|
|
563
|
+
// --stderr controls how the child's stderr crosses the local-process boundary.
|
|
564
|
+
// filter (default) runs each line through the same Haechi protection as MCP
|
|
565
|
+
// traffic before re-emitting; drop discards it; inherit is the raw passthrough
|
|
566
|
+
// (an explicit, opt-in local-process boundary). Unknown values fail closed.
|
|
567
|
+
const stderrMode = options.stderr === undefined ? "filter" : options.stderr;
|
|
568
|
+
if (!STDERR_MODES.has(stderrMode)) {
|
|
569
|
+
throw new Error(`mcp-wrap --stderr must be one of: filter | drop | inherit (got ${JSON.stringify(stderrMode)})`);
|
|
570
|
+
}
|
|
571
|
+
|
|
561
572
|
const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
|
|
562
573
|
const runtime = createRuntime(config);
|
|
563
574
|
|
|
575
|
+
// "inherit" hands the child's stderr straight to the terminal (raw, unfiltered);
|
|
576
|
+
// filter/drop pipe it so the wrapper can inspect or discard each line.
|
|
564
577
|
const child = spawn(command, commandArgs, {
|
|
565
|
-
stdio: ["pipe", "pipe", "inherit"]
|
|
578
|
+
stdio: ["pipe", "pipe", stderrMode === "inherit" ? "inherit" : "pipe"]
|
|
566
579
|
});
|
|
567
580
|
|
|
568
581
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
569
582
|
process.on(signal, () => child.kill(signal));
|
|
570
583
|
}
|
|
571
584
|
|
|
585
|
+
if (stderrMode === "filter") {
|
|
586
|
+
pipeFilteredStderr({ runtime, child });
|
|
587
|
+
} else if (stderrMode === "drop") {
|
|
588
|
+
// Consume so the child's stderr pipe never fills and stalls the child, but
|
|
589
|
+
// re-emit nothing.
|
|
590
|
+
child.stderr?.resume();
|
|
591
|
+
}
|
|
592
|
+
|
|
572
593
|
const { code } = await wrapMcpChild({ runtime, child });
|
|
573
594
|
process.exitCode = code;
|
|
574
595
|
}
|
|
575
596
|
|
|
597
|
+
// Filter the child's stderr through the SAME protection the wrapper applies to
|
|
598
|
+
// MCP traffic, then re-emit each safe line to the parent process.stderr. Each
|
|
599
|
+
// complete line is protected as text via the runtime's haechi instance (redact/
|
|
600
|
+
// mask rewrite detected secrets/PII in place); a block-action detection drops the
|
|
601
|
+
// line entirely. Partial lines are buffered across chunk boundaries (split on \n;
|
|
602
|
+
// hold the trailing partial, flushed on stream end).
|
|
603
|
+
function pipeFilteredStderr({ runtime, child, stderr = process.stderr }) {
|
|
604
|
+
const source = child.stderr;
|
|
605
|
+
if (!source) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
source.setEncoding("utf8");
|
|
609
|
+
let buffer = "";
|
|
610
|
+
// Serialize async protection so lines re-emit in source order even though
|
|
611
|
+
// protectStderrLine is async.
|
|
612
|
+
let queue = Promise.resolve();
|
|
613
|
+
|
|
614
|
+
function enqueue(line) {
|
|
615
|
+
queue = queue.then(async () => {
|
|
616
|
+
const safe = await protectStderrLine(runtime, line);
|
|
617
|
+
if (safe !== null) {
|
|
618
|
+
stderr.write(`${safe}\n`);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
source.on("data", (chunk) => {
|
|
624
|
+
buffer += chunk;
|
|
625
|
+
let index;
|
|
626
|
+
while ((index = buffer.indexOf("\n")) !== -1) {
|
|
627
|
+
const line = buffer.slice(0, index);
|
|
628
|
+
buffer = buffer.slice(index + 1);
|
|
629
|
+
enqueue(line);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
source.on("end", () => {
|
|
633
|
+
// Flush any trailing partial line (no terminating newline).
|
|
634
|
+
if (buffer.length > 0) {
|
|
635
|
+
enqueue(buffer);
|
|
636
|
+
buffer = "";
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Protect one stderr line as text. Returns the protected line (detected secrets/
|
|
642
|
+
// PII redacted/masked in place), or null when a block-action detection means the
|
|
643
|
+
// line must be dropped (not emitted). Uses the runtime's haechi stream/text
|
|
644
|
+
// protector — the clean single-shot text entrypoint (protectText) that detects,
|
|
645
|
+
// decides, and transforms a complete, self-contained text segment by offset, the
|
|
646
|
+
// same logic the streaming delta channel commits with. A fresh protector per line
|
|
647
|
+
// keeps no cross-line state (we already split on \n and buffer partials above).
|
|
648
|
+
async function protectStderrLine(runtime, line) {
|
|
649
|
+
if (line.length === 0) {
|
|
650
|
+
return line;
|
|
651
|
+
}
|
|
652
|
+
const protector = runtime.haechi.createStreamProtector({
|
|
653
|
+
protocol: "mcp-stdio",
|
|
654
|
+
operation: "stderr",
|
|
655
|
+
direction: "response",
|
|
656
|
+
mode: runtime.config.policy.mode ?? runtime.config.mode
|
|
657
|
+
});
|
|
658
|
+
const result = await protector.protectText(line);
|
|
659
|
+
if (result.blocked) {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
return result.text;
|
|
663
|
+
}
|
|
664
|
+
|
|
576
665
|
function parseOptions(argv) {
|
|
577
666
|
const options = {};
|
|
578
667
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -675,9 +764,9 @@ const COMMAND_HELP = {
|
|
|
675
764
|
summary: "Filter MCP JSON-RPC traffic on stdin/stdout (one direction)."
|
|
676
765
|
},
|
|
677
766
|
"mcp-wrap": {
|
|
678
|
-
usage: "haechi mcp-wrap [--config haechi.config.json] -- <command> [args...]",
|
|
767
|
+
usage: "haechi mcp-wrap [--config haechi.config.json] [--stderr filter|drop|inherit] -- <command> [args...]",
|
|
679
768
|
summary: "Wrap an MCP server with bidirectional stdio protection.",
|
|
680
|
-
detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs."
|
|
769
|
+
detail: "Spawns <command>, applies the method allowlist + params protection client→server, and result protection + injection heuristics server→client. Drop-in for MCP client configs. --stderr controls the child's stderr: filter (default) protects each line with the same policy before re-emitting, drop discards it, inherit passes it through raw (an explicit, opt-in local-process boundary). filter follows the configured policy mode — in dry-run/report-only it detects but does not transform (like the rest of the pipeline), so set policy.mode=enforce for stderr redaction to take effect."
|
|
681
770
|
},
|
|
682
771
|
auth: {
|
|
683
772
|
usage: "haechi auth add --type user|service|agent [--scope k:v ...] [--label k=v ...]\n haechi auth list [--config haechi.config.json]\n haechi auth revoke <id> [--config haechi.config.json]",
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -598,6 +598,7 @@ export function normalizeConfig(config) {
|
|
|
598
598
|
if (merged.auth.provider === "plugin") {
|
|
599
599
|
validatePluginAuthConfig(merged);
|
|
600
600
|
}
|
|
601
|
+
validateForwardHeaders(merged.target);
|
|
601
602
|
createProtocolAdapter(merged.target);
|
|
602
603
|
return merged;
|
|
603
604
|
}
|
|
@@ -936,6 +937,54 @@ function validatePluginAuthConfig(merged) {
|
|
|
936
937
|
}
|
|
937
938
|
}
|
|
938
939
|
|
|
940
|
+
// P0-CR-001 — additive escape hatch for an unusual upstream that needs a header
|
|
941
|
+
// the built-in allowlist does not cover. `target.forwardHeaders` is an OPTIONAL
|
|
942
|
+
// array of extra lowercase header NAMES to forward to the upstream. Fail-closed:
|
|
943
|
+
// it must be an array of non-empty strings, and it may NOT name a header that the
|
|
944
|
+
// proxy always drops (ambient client credentials + hop-by-hop control headers) —
|
|
945
|
+
// an operator cannot re-enable a gateway-credential leak through it. Absent =
|
|
946
|
+
// the built-in default-drop allowlist alone (byte-identical to prior behavior).
|
|
947
|
+
const FORWARD_HEADERS_FORBIDDEN = new Set([
|
|
948
|
+
"host",
|
|
949
|
+
"content-length",
|
|
950
|
+
"content-type",
|
|
951
|
+
"authorization",
|
|
952
|
+
"cookie",
|
|
953
|
+
"set-cookie",
|
|
954
|
+
"proxy-authorization",
|
|
955
|
+
"connection",
|
|
956
|
+
"keep-alive",
|
|
957
|
+
"te",
|
|
958
|
+
"trailer",
|
|
959
|
+
"transfer-encoding",
|
|
960
|
+
"upgrade"
|
|
961
|
+
]);
|
|
962
|
+
|
|
963
|
+
function validateForwardHeaders(target) {
|
|
964
|
+
if (target.forwardHeaders === undefined || target.forwardHeaders === null) {
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (!Array.isArray(target.forwardHeaders)) {
|
|
968
|
+
throw new Error("target.forwardHeaders must be an array of lowercase header names");
|
|
969
|
+
}
|
|
970
|
+
const normalized = [];
|
|
971
|
+
for (const name of target.forwardHeaders) {
|
|
972
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
973
|
+
throw new Error("target.forwardHeaders entries must be non-empty strings");
|
|
974
|
+
}
|
|
975
|
+
const lower = name.trim().toLowerCase();
|
|
976
|
+
if (lower !== name) {
|
|
977
|
+
throw new Error(`target.forwardHeaders entries must be lowercase header names (got: ${JSON.stringify(name)})`);
|
|
978
|
+
}
|
|
979
|
+
if (FORWARD_HEADERS_FORBIDDEN.has(lower)) {
|
|
980
|
+
throw new Error(`target.forwardHeaders may not include the always-dropped header ${JSON.stringify(lower)} (ambient credentials and hop-by-hop headers are never forwarded)`);
|
|
981
|
+
}
|
|
982
|
+
normalized.push(lower);
|
|
983
|
+
}
|
|
984
|
+
// Persist the validated, de-duplicated list back onto the normalized target.
|
|
985
|
+
target.forwardHeaders = [...new Set(normalized)];
|
|
986
|
+
}
|
|
987
|
+
|
|
939
988
|
function resolveAuthProvider(config, providers, cryptoProvider, auditSink) {
|
|
940
989
|
if (config.auth.provider === "external") {
|
|
941
990
|
if (typeof providers.authProvider?.authenticate !== "function") {
|
package/packages/core/index.mjs
CHANGED
|
@@ -148,6 +148,21 @@ export function createHaechi({ filterEngine, policyEngine, cryptoProvider, audit
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
return {
|
|
151
|
+
// Single-shot text protection for a complete, self-contained text payload
|
|
152
|
+
// (P1-CR-005): a parse-failed CONTENT frame whose data: text is NOT JSON
|
|
153
|
+
// (plain text, malformed/partial JSON, provider-specific text). It detects,
|
|
154
|
+
// decides, tallies, and either returns { text } or { blocked: true } — the
|
|
155
|
+
// SAME transformSegment logic the delta channel commits with. CRITICALLY it
|
|
156
|
+
// does NOT touch the cross-frame `pending` buffer, so inspecting a non-JSON
|
|
157
|
+
// frame's text cannot corrupt the JSON delta channel's sliding-buffer state.
|
|
158
|
+
// Per-frame inspection only: cross-frame buffering of arbitrary non-JSON
|
|
159
|
+
// frames is out of scope (the delta channel keeps its own buffer).
|
|
160
|
+
async protectText(text) {
|
|
161
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
162
|
+
return { text: text ?? "", blocked: false };
|
|
163
|
+
}
|
|
164
|
+
return transformSegment(text);
|
|
165
|
+
},
|
|
151
166
|
// Protect string leaves of a parsed frame OTHER than the incremental
|
|
152
167
|
// delta text (e.g. tool-call arguments). Returns the mutated object.
|
|
153
168
|
async protectFrameExtras(value) {
|
|
@@ -4,6 +4,37 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
4
4
|
|
|
5
5
|
const ALG = "AES-256-GCM";
|
|
6
6
|
|
|
7
|
+
// Single source of truth for parsing + validating an on-disk local key file.
|
|
8
|
+
// Both the provider's loadKeys() and initLocalKeyFile() (existing-file path)
|
|
9
|
+
// go through here so the 32-byte key invariant is enforced once. Throws a
|
|
10
|
+
// specific error per defect so a corrupted-but-present file is caught at init
|
|
11
|
+
// time instead of failing later during encrypt/decrypt/token/bundle.
|
|
12
|
+
//
|
|
13
|
+
// requireActive: init demands an explicit status:"active" key; the provider
|
|
14
|
+
// keeps its historical fallback to keys[0] when none is marked active.
|
|
15
|
+
async function loadKeyFile(keyFile, { requireActive = false } = {}) {
|
|
16
|
+
const raw = JSON.parse(await readFile(keyFile, "utf8"));
|
|
17
|
+
if (!raw.keys?.length) {
|
|
18
|
+
throw new Error(`No keys found in ${keyFile}`);
|
|
19
|
+
}
|
|
20
|
+
const byKid = new Map();
|
|
21
|
+
for (const entry of raw.keys) {
|
|
22
|
+
const key = Buffer.from(entry.k, "base64url");
|
|
23
|
+
if (key.length !== 32) {
|
|
24
|
+
throw new Error("AES-256-GCM local key must be 32 bytes");
|
|
25
|
+
}
|
|
26
|
+
byKid.set(entry.kid, { kid: entry.kid, key });
|
|
27
|
+
}
|
|
28
|
+
const activeEntry = raw.keys.find((key) => key.status === "active") ?? (requireActive ? null : raw.keys[0]);
|
|
29
|
+
if (!activeEntry) {
|
|
30
|
+
throw new Error("No active key found in local key file");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
active: byKid.get(activeEntry.kid),
|
|
34
|
+
byKid
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
7
38
|
export function createLocalCryptoProvider({ keyFile }) {
|
|
8
39
|
if (!keyFile) {
|
|
9
40
|
throw new Error("Local crypto provider requires keyFile");
|
|
@@ -15,23 +46,7 @@ export function createLocalCryptoProvider({ keyFile }) {
|
|
|
15
46
|
if (cachedKeys) {
|
|
16
47
|
return cachedKeys;
|
|
17
48
|
}
|
|
18
|
-
|
|
19
|
-
if (!raw.keys?.length) {
|
|
20
|
-
throw new Error(`No keys found in ${keyFile}`);
|
|
21
|
-
}
|
|
22
|
-
const byKid = new Map();
|
|
23
|
-
for (const entry of raw.keys) {
|
|
24
|
-
const key = Buffer.from(entry.k, "base64url");
|
|
25
|
-
if (key.length !== 32) {
|
|
26
|
-
throw new Error("AES-256-GCM local key must be 32 bytes");
|
|
27
|
-
}
|
|
28
|
-
byKid.set(entry.kid, { kid: entry.kid, key });
|
|
29
|
-
}
|
|
30
|
-
const activeEntry = raw.keys.find((key) => key.status === "active") ?? raw.keys[0];
|
|
31
|
-
cachedKeys = {
|
|
32
|
-
active: byKid.get(activeEntry.kid),
|
|
33
|
-
byKid
|
|
34
|
-
};
|
|
49
|
+
cachedKeys = await loadKeyFile(keyFile);
|
|
35
50
|
return cachedKeys;
|
|
36
51
|
}
|
|
37
52
|
|
|
@@ -102,15 +117,22 @@ export async function initLocalKeyFile(keyFile, { force = false } = {}) {
|
|
|
102
117
|
await mkdir(dirname(keyFile), { recursive: true });
|
|
103
118
|
|
|
104
119
|
let existing = null;
|
|
120
|
+
let fileExists = true;
|
|
105
121
|
try {
|
|
106
122
|
existing = JSON.parse(await readFile(keyFile, "utf8"));
|
|
107
|
-
if (!force) {
|
|
108
|
-
return { created: false, keyFile };
|
|
109
|
-
}
|
|
110
123
|
} catch (error) {
|
|
111
124
|
if (error.code !== "ENOENT") {
|
|
112
125
|
throw error;
|
|
113
126
|
}
|
|
127
|
+
fileExists = false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (fileExists && !force) {
|
|
131
|
+
// A present key file must be usable, not merely present: validate the
|
|
132
|
+
// active key (base64url, 32 bytes) and every retired key before reporting
|
|
133
|
+
// success, so a corrupted file is rejected here rather than at first use.
|
|
134
|
+
await loadKeyFile(keyFile, { requireActive: true });
|
|
135
|
+
return { created: false, keyFile };
|
|
114
136
|
}
|
|
115
137
|
|
|
116
138
|
// Rotating with --force must not orphan existing envelopes/token vault
|