haechi 1.5.0 → 1.7.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/README.ko.md CHANGED
@@ -229,7 +229,7 @@ JWT/JWKS 인증과 KMS 기반 key custody(및 기타 선택 기능)는 **`haechi
229
229
 
230
230
  ## 위성 패키지
231
231
 
232
- 선택 기능은 **npm에 독립 발행되는 `haechi-*` 패키지**로 제공됩니다 — 각각 core와 별도로 버저닝되고, 기본적으로 `node:` 전용이며(KMS나 Redis 클라이언트 같은 무거운 SDK는 optional peer), `haechi` peer 범위를 `>=0.8.0 <2.0.0`으로 선언합니다(상한이 core major를 따라가므로 core minor가 위성 설치를 깨뜨리지 않습니다).
232
+ 선택 기능은 **npm에 독립 발행되는 `haechi-*` 패키지**로 제공됩니다 — 각각 core와 별도로 버저닝되고, 기본적으로 `node:` 전용이며(KMS나 Redis 클라이언트 같은 무거운 SDK는 optional peer), core major를 추적하는 `haechi` peer 범위를 선언합니다. 대부분의 위성은 `>=0.8.0 <2.0.0`를 유지하고, `haechi-crypto-kms@0.3.0`은 core v2 crypto-AAD helper를 공유하므로 `>=1.7.0 <2.0.0`을 요구합니다.
233
233
 
234
234
  **위성과 함께 core를 반드시 설치하세요** — `haechi`는 **번들되지 않은 peer dependency**이므로, 위성만으로는 동작하지 않습니다:
235
235
 
@@ -377,3 +377,11 @@ Haechi는 의도적으로 범위를 좁혔습니다. 아래는 숨기지 않고
377
377
  1.3.0은 백엔드와 탐지를 확장합니다. **Anthropic Messages API**와 **Google Gemini API**용 프로토콜 adapter; 클라우드/SaaS provider-key 탐지와 국제 PII(FR/ES/JP/IT/SG/IN/DE/NL national ID, checksum 검증, hard-block-vs-allowlist-clearable 결정은 측정된 collision rate로 결정); proxy 처리량 벤치마크; 그리고 `haechi-ratelimit-redis` 공유 저장소 rate-limiter 위성을 추가합니다. 모두 additive입니다(새 `target.type`/탐지 type/profile *값*, `configVersion`은 `1`로 유지).
378
378
 
379
379
  1.3.1 → 1.3.3은 보안 교정과 강화 **패치**입니다(API/config 변경 없음). 1.3.1과 1.3.2는 두 차례의 외부 코드 리뷰 라운드를 닫습니다 — proxy 헤더 경계 credential 유출, hex IPv4-mapped IPv6 SSRF, 응답 헤더/스트리밍 경계, 비JSON 스트리밍 검사(1.3.1); proxy upstream-reader 연결 끊김 시 취소, token-vault audit 로그 위생, 플러그인 IPC 응답 경계(1.3.2). 1.3.3은 응답 방향 마커 skip을 강화하고(모델이 비밀을 가짜 `[TOKEN:…]`로 감싸 스캔을 회피할 수 없음) cosign으로 서명된 GHCR 컨테이너 이미지를 추가합니다.
380
+
381
+ 1.4.0은 서명된 플러그인 trust gate를 위한 1차 저작 CLI를 추가합니다: `haechi plugin-keygen`(Ed25519 키쌍 — 개인키 `0600`, 공개키가 trust anchor), `plugin-sign`(entry 파일의 정확한 바이트에 서명; `authProvider`는 `readsCredentials`를 선언해야 함), `plugin-verify`(런타임 검증을 fail-closed로 실행, `--allow-capability`). 새 [`plugin-signing-and-trust.md`](docs/current/plugin-signing-and-trust.md) 런북이 keygen → sign → `auth.plugin.trustAnchors` 배선 → verify → 회전/pin/revoke 라이프사이클을 다룹니다. additive CLI 표면(strict semver 하의 **마이너**); config 변경 없음(`configVersion`은 `1` 유지).
382
+
383
+ 1.5.0은 **fleet-readiness** 트랙을 시작합니다. audit sink와 token vault가 주입 가능한 **store 시임**(`createAuditSink({ store })` / `createTokenVault({ store })` + 기본 `createFileAuditStore`/`createFileTokenStore`)을 갖게 되어, 공유 저장소가 sha256 audit 해시 체인과 token vault를 **여러 replica** 전반에서 뒷받침할 수 있습니다 — [Known limitations](#known-limitations)에서 짚은 프로세스별 / 단일 writer 한계를 해소합니다. 파일 기반 기본값은 바이트 동일하고 `createJsonlAuditSink`/`createLocalTokenVault`는 하위호환 래퍼로 유지됩니다. `haechi-store-redis` 위성(두 시임의 Redis 어댑터)이 운영 소비자입니다. additive(**마이너**); `configVersion`은 `1` 유지; core는 zero runtime dependency 유지.
384
+
385
+ 1.6.0은 **crypto 봉투**를 강화합니다. 로컬 AES-256-GCM provider가 이제 키별 nonce 예산을 강제합니다 — 랜덤 96-bit IV는 키당 한정된 횟수의 암호화까지만 안전하므로(NIST SP 800-38D §8.3), provider가 키별 암호화 횟수를 세어 키 파일에 영속화하고, 50%에서 경고하며, **한도에서 fail-closed**(`haechi init --force`로 회전)합니다. 읽기 전용 키 파일은 프로세스 단위 한도로 degrade합니다. `haechi status`가 예산을 노출하고(`keys.nonceBudget`, 사용 %), `haechi/crypto`는 `readNonceBudget`를 export합니다. 명명된 `gate:security` CI 잡이 횡단 보안 invariant(audit 평문 없음, hard-block 비억제, AAD/AEAD 바인딩, nonce 예산, privacy-profile strengthen-only)를 독립 required check로 실행합니다. additive(**마이너**); `configVersion`은 `1` 유지; core는 zero runtime dependency 유지.
386
+
387
+ 1.7.0은 다음 crypto-envelope hardening 단계를 닫습니다. 새 envelope는 `v:2`, `aadEncoding:"nfkc-json-v2"`를 사용하므로 crypto AAD가 NFKC-normalized string value/object key를 포함한 정렬 canonical JSON(`canonicalizeCryptoAad`)으로 고정됩니다. Legacy v1 envelope는 기존 AAD canonicalization으로 계속 복호화됩니다. NFKC 때문에 같은 object level에서 key collision이 생기면 fail-closed하고, token-vault ciphertext는 token `expiresAt`를 envelope에 담아 stale ciphertext를 crypto 계층에서도 거부합니다. `haechi-crypto-kms@0.3.0`도 같은 core helper를 사용하며 `haechi >=1.7.0`를 요구합니다. additive(**마이너**); `configVersion`은 `1` 유지; core는 zero runtime dependency 유지.
package/README.md CHANGED
@@ -229,7 +229,7 @@ JWT/JWKS auth and KMS-backed key custody (and other optional capabilities) ship
229
229
 
230
230
  ## Satellite packages
231
231
 
232
- Optional capabilities ship as independently-published **`haechi-*` packages on npm** — each versioned separately from core, `node:`-only by default (heavy SDKs like a KMS or Redis client are optional peers), and each declaring a `haechi` peer range of `>=0.8.0 <2.0.0` (the upper bound tracks the core major, so a core minor never breaks a satellite install).
232
+ Optional capabilities ship as independently-published **`haechi-*` packages on npm** — each versioned separately from core, `node:`-only by default (heavy SDKs like a KMS or Redis client are optional peers), and each declaring a `haechi` peer range that tracks the core major. Most satellites keep `>=0.8.0 <2.0.0`; `haechi-crypto-kms@0.3.0` requires `>=1.7.0 <2.0.0` because it shares the core v2 crypto-AAD helper.
233
233
 
234
234
  **Install the core alongside any satellite** — `haechi` is a **peer dependency, not bundled**, so a satellite does nothing on its own:
235
235
 
@@ -381,3 +381,7 @@ Haechi is deliberately scoped. These are real, current limitations — listed op
381
381
  1.4.0 adds the first-party authoring CLI for the signed-plugin trust gate: `haechi plugin-keygen` (Ed25519 keypair — private key `0600`, public key is the trust anchor), `plugin-sign` (signs the exact entry-file bytes; `authProvider` must declare `readsCredentials`), and `plugin-verify` (runs the runtime's verification, fail-closed, with `--allow-capability`). A new [`plugin-signing-and-trust.md`](docs/current/plugin-signing-and-trust.md) runbook covers the keygen → sign → wire `auth.plugin.trustAnchors` → verify → rotate/pin/revoke lifecycle. Additive CLI surface (a **minor** under strict semver); no config change (`configVersion` stays `1`).
382
382
 
383
383
  1.5.0 begins the **fleet-readiness** track: the audit sink and token vault gain an injectable **store seam** (`createAuditSink({ store })` / `createTokenVault({ store })` + the default `createFileAuditStore`/`createFileTokenStore`), so a shared store can back the sha256 audit hash chain and the token vault across **multiple replicas** — closing the per-process / single-writer limitations called out under [Known limitations](#known-limitations). The file-backed defaults are byte-identical, and `createJsonlAuditSink`/`createLocalTokenVault` stay as back-compat wrappers. A `haechi-store-redis` satellite (the Redis adapters for both seams) is the production consumer. Additive (a **minor**); `configVersion` stays `1`; core stays zero runtime dependency.
384
+
385
+ 1.6.0 hardens the **crypto envelope**: the local AES-256-GCM provider now enforces a per-key nonce budget — random 96-bit IVs are only safe for a bounded number of encryptions per key (NIST SP 800-38D §8.3), so the provider counts encryptions per key, persists the count to the key file, warns at 50%, and **fails closed at the limit** (rotate with `haechi init --force`); a read-only key file degrades to a per-process limit. `haechi status` surfaces the budget (`keys.nonceBudget`, used %) and `haechi/crypto` exports `readNonceBudget`. A named `gate:security` CI job runs the cross-cutting security invariants (no plaintext in audit, hard-block non-suppressible, AAD/AEAD binding, the nonce budget, privacy-profile strengthen-only) as an independently required check. Additive (a **minor**); `configVersion` stays `1`; core stays zero runtime dependency.
386
+
387
+ 1.7.0 completes the next crypto-envelope hardening step: new envelopes are `v:2` with `aadEncoding:"nfkc-json-v2"`, so crypto AAD uses sorted canonical JSON with NFKC-normalized string values and object keys (`canonicalizeCryptoAad`). Legacy v1 envelopes still decrypt with the old AAD canonicalization. AAD key collisions introduced by NFKC fail closed, and token-vault ciphertext now carries the token `expiresAt` into the envelope so stale ciphertext is rejected by the crypto layer as defense-in-depth. `haechi-crypto-kms@0.3.0` uses the same core helper and requires `haechi >=1.7.0`. Additive (a **minor**); `configVersion` stays `1`; core stays zero runtime dependency.
@@ -28,7 +28,7 @@
28
28
  | `haechi` / `haechi/core` — `createHaechi().protectJson`, `createHaechi().createStreamProtector`, `collectStringEntries`, `pathToString`, `safePathToString`, `shapeOnly`, `summarize` | **FROZEN** (breaking change = major) |
29
29
  | `haechi/runtime` — `createRuntime`, `normalizeConfig` (config shape), `defaultConfig`, `loadConfig`, `writeDefaultConfig`, `isValidPort`, `DEFAULT_CONFIG_PATH` | **FROZEN** |
30
30
  | `haechi/auth` — `authProvider` 계약, `buildIdentity`, `buildExternalIdentity`, `validateLabels`, `createBearerAuthProvider`, token store (`readAuthStore`, `addToken`, `listTokens`, `revokeToken`), `DEFAULT_ALLOWED_LABEL_KEYS` | **FROZEN** |
31
- | `haechi/crypto` — `cryptoProvider` 계약, `assertCryptoProviderConformance`, `canonicalize`, `createLocalCryptoProvider`, `initLocalKeyFile` | **FROZEN** |
31
+ | `haechi/crypto` — `cryptoProvider` 계약, `assertCryptoProviderConformance`, `canonicalize`, `canonicalizeCryptoAad`, `CRYPTO_AAD_ENCODING_V2`, `createLocalCryptoProvider`, `initLocalKeyFile`, `readNonceBudget` | **FROZEN** |
32
32
  | `haechi/audit` — audit **event schema** (§2.3), `verifyAuditChain`, `sanitizeAudit`, `createJsonlAuditSink`, `readAuditSummary`, `FORBIDDEN_KEYS`, 그리고 1.5.0 주입 가능한 store 시임 `createAuditSink`, `createFileAuditStore`, `buildIntegrityRecord` | **FROZEN** |
33
33
  | `haechi/policy` — `buildPolicy`, `createPolicyEngine`, `createPolicyProfiles`, `validatePolicy`, `ACTION_STRENGTH` (action ordering) | **FROZEN** |
34
34
  | `haechi/filter` — `createDefaultFilterEngine`, `detectEntry`, 그리고 **rule/detection shape** | **FROZEN** |
@@ -110,10 +110,10 @@ audit event(`packages/core/index.mjs`의 `buildAuditEvent`가 생성하고 `pack
110
110
  위성(예: `haechi-crypto-kms`, `haechi-auth-jwt`, `haechi-dashboard`, `haechi-auth-oidc`)은 core와 **독립적으로** 버저닝합니다 — 위성 릴리스가 `haechi`를 bump하지 않고, 그 반대도 마찬가지입니다.
111
111
 
112
112
  - **pre-1.0:** 위성은 npm semver를 따르며 `0.x` **minor** bump가 breaking change를 담을 수 있습니다. `major.minor`로 핀합니다(예: `haechi-crypto-kms@~0.2`). 각자 자체 `1.0.0`까지 pre-stable입니다.
113
- - **core 호환성**은 `peerDependencies` 범위(`"haechi": ">=0.8.0 <2.0.0"`)로 표현합니다 위성은 소비자가 설치한 단일 `haechi`를 재사용하므로 crypto/identity 표면이 하나입니다. `haechi-auth-oidc`는 추가로 `haechi-auth-jwt`(`">=0.2.0 <2.0.0"`)에 peer-depend하여 둘이 audit되는 단일 JWS/JWKS 검증 경로를 공유합니다. 위성의 `haechi` peer-dependency **상한은 반드시 core MAJOR를 추적해야 하며**(`<2.0.0`), 다음 minor 미만으로 고정해서는 안 됩니다 — core의 minor/major 호환 범프가 위성 설치를 깨뜨리지 않도록 하기 위함입니다. `release:preflight` 게이트(`scripts/check-satellite-peer-ranges.mjs`)가 이를 자동으로 강제합니다.
113
+ - **core 호환성**은 `peerDependencies` 범위로 표현합니다 — 위성은 소비자가 설치한 단일 `haechi`를 재사용하므로 crypto/identity 표면이 하나입니다. 대부분의 1.x 위성은 `"haechi": ">=0.8.0 <2.0.0"`를 유지하지만, `haechi-crypto-kms@0.3.0`은 core의 `canonicalizeCryptoAad` export를 사용하므로 `"haechi": ">=1.7.0 <2.0.0"`를 요구합니다. `haechi-auth-oidc`는 추가로 `haechi-auth-jwt`(`">=0.3.0 <2.0.0"`)에 peer-depend하여 둘이 audit되는 단일 JWS/JWKS 검증 경로를 공유합니다. 위성의 `haechi` peer-dependency **상한은 반드시 core MAJOR를 추적해야 하며**(`<2.0.0`), 다음 minor 미만으로 고정해서는 안 됩니다 — core의 minor/major 호환 범프가 위성 설치를 깨뜨리지 않도록 하기 위함입니다. `release:preflight` 게이트(`scripts/check-satellite-peer-ranges.mjs`)가 이를 자동으로 강제합니다.
114
114
  - **무거운 백엔드는 optional peer입니다.** `haechi-crypto-kms`는 SDK 백엔드(`@aws-sdk/client-kms`, 그리고 0.2.0의 `@google-cloud/kms`, `@azure/keyvault-keys`, `@azure/identity`)를 `peerDependencies` + `peerDependenciesMeta.optional`로 선언하고 lazy import하므로, 해당 경로를 쓰지 않는 소비자는 설치하지 않고 core는 zero-dependency를 유지합니다. `./vault` 백엔드는 `node:` `fetch`만 사용합니다(optional peer 없음). 위성의 배포 tarball은 항상 **runtime `dependencies` 0**을 선언합니다(CI `check-satellite-packaging`로 강제).
115
115
  - **pre-1.0 위성 export**는 preview이며 각 위성의 자체 `1.0.0` 전에 변경될 수 있습니다.
116
- - `haechi-crypto-kms` (0.8 → 0.2.0): `createKmsCryptoProvider`, `createInMemoryKms`, `./aws`의 `createAwsKmsClient`, 그리고 0.2.0 subpath `./gcp`(`createGcpKmsClient`), `./azure`(`createAzureKmsClient`), `./vault`(`createVaultKmsClient`).
116
+ - `haechi-crypto-kms` (0.8 → 0.3.0): `createKmsCryptoProvider`, `createInMemoryKms`, `./aws`의 `createAwsKmsClient`, 0.2.0 subpath `./gcp`(`createGcpKmsClient`), `./azure`(`createAzureKmsClient`), `./vault`(`createVaultKmsClient`), 그리고 0.3.0의 core v2 crypto-AAD parity(`haechi >=1.7.0` peer floor).
117
117
  - `haechi-auth-jwt` (0.2.0): `createJwtAuthProvider`(0.8, behavior-preserving)와 추가된 `createJwtVerifier`(재사용 가능한 JWS 검증 primitive), `isBlockedAddress`(SSRF 범위 술어, `haechi-auth-oidc`가 재사용).
118
118
  - `haechi-dashboard` (0.1.0, 신규): `createDashboardServer`, `normalizeDashboardConfig`.
119
119
  - `haechi-auth-oidc` (0.1.0, 신규): `createOidcSessionBroker`, `normalizeOidcConfig`.
@@ -28,7 +28,7 @@ Every `package.json` `exports` subpath and the CLI is classed. There is no silen
28
28
  | `haechi` / `haechi/core` — `createHaechi().protectJson`, `createHaechi().createStreamProtector`, `collectStringEntries`, `pathToString`, `safePathToString`, `shapeOnly`, `summarize` | **FROZEN** (breaking change = major) |
29
29
  | `haechi/runtime` — `createRuntime`, `normalizeConfig` (config shape), `defaultConfig`, `loadConfig`, `writeDefaultConfig`, `isValidPort`, `DEFAULT_CONFIG_PATH` | **FROZEN** |
30
30
  | `haechi/auth` — the `authProvider` contract, `buildIdentity`, `buildExternalIdentity`, `validateLabels`, `createBearerAuthProvider`, the token store (`readAuthStore`, `addToken`, `listTokens`, `revokeToken`), `DEFAULT_ALLOWED_LABEL_KEYS` | **FROZEN** |
31
- | `haechi/crypto` — the `cryptoProvider` contract, `assertCryptoProviderConformance`, `canonicalize`, `createLocalCryptoProvider`, `initLocalKeyFile` | **FROZEN** |
31
+ | `haechi/crypto` — the `cryptoProvider` contract, `assertCryptoProviderConformance`, `canonicalize`, `canonicalizeCryptoAad`, `CRYPTO_AAD_ENCODING_V2`, `createLocalCryptoProvider`, `initLocalKeyFile`, `readNonceBudget` | **FROZEN** |
32
32
  | `haechi/audit` — the audit **event schema** (§2.3), `verifyAuditChain`, `sanitizeAudit`, `createJsonlAuditSink`, `readAuditSummary`, `FORBIDDEN_KEYS`, plus the 1.5.0 injectable store seam `createAuditSink`, `createFileAuditStore`, `buildIntegrityRecord` | **FROZEN** |
33
33
  | `haechi/policy` — `buildPolicy`, `createPolicyEngine`, `createPolicyProfiles`, `validatePolicy`, `ACTION_STRENGTH` (action ordering) | **FROZEN** |
34
34
  | `haechi/filter` — `createDefaultFilterEngine`, `detectEntry`, and the **rule/detection shape** | **FROZEN** |
@@ -110,10 +110,10 @@ A migration note is added to `docs/current/release-*.md` or the README whenever
110
110
  Satellites (e.g. `haechi-crypto-kms`, `haechi-auth-jwt`, `haechi-dashboard`, `haechi-auth-oidc`) version **independently** of core — a satellite release never bumps `haechi`, and vice versa.
111
111
 
112
112
  - **Pre-1.0:** satellites follow npm semver where a `0.x` **minor** bump may carry breaking changes; pin `major.minor` (e.g. `haechi-crypto-kms@~0.2`). Each is pre-stable until its own `1.0.0`.
113
- - **Core compatibility** is expressed as a `peerDependencies` range (`"haechi": ">=0.8.0 <2.0.0"`) — a satellite reuses the consumer's single installed `haechi`, so there is one crypto/identity surface. `haechi-auth-oidc` additionally peer-depends on `haechi-auth-jwt` (`">=0.2.0 <2.0.0"`) so the two share one audited JWS/JWKS verification path. The satellite `haechi` peer-dependency **upper bound must track the core MAJOR** (`<2.0.0`), never be pinned below the next minor, so a core minor- or major-compatible bump never breaks satellite installs; the `release:preflight` gate (`scripts/check-satellite-peer-ranges.mjs`) enforces this automatically.
113
+ - **Core compatibility** is expressed as a `peerDependencies` range — a satellite reuses the consumer's single installed `haechi`, so there is one crypto/identity surface. Most 1.x satellites keep `"haechi": ">=0.8.0 <2.0.0"`, but `haechi-crypto-kms@0.3.0` requires `"haechi": ">=1.7.0 <2.0.0"` because it imports core's `canonicalizeCryptoAad` export. `haechi-auth-oidc` additionally peer-depends on `haechi-auth-jwt` (`">=0.3.0 <2.0.0"`) so the two share one audited JWS/JWKS verification path. The satellite `haechi` peer-dependency **upper bound must track the core MAJOR** (`<2.0.0`), never be pinned below the next minor, so a core minor- or major-compatible bump never breaks satellite installs; the `release:preflight` gate (`scripts/check-satellite-peer-ranges.mjs`) enforces this automatically.
114
114
  - **Heavy backends are optional peers.** `haechi-crypto-kms` declares its SDK backends (`@aws-sdk/client-kms`, and in 0.2.0 `@google-cloud/kms`, `@azure/keyvault-keys`, `@azure/identity`) under `peerDependencies` + `peerDependenciesMeta.optional` and imports them lazily, so consumers who do not use a given path never install it and core stays zero-dependency. The `./vault` backend uses `node:` `fetch` only (no optional peer). A satellite's published tarball always declares **zero runtime `dependencies`** (CI-gated by `check-satellite-packaging`).
115
115
  - **Pre-1.0 satellite exports** are preview and may change before each satellite's own `1.0.0`:
116
- - `haechi-crypto-kms` (0.8 → 0.2.0): `createKmsCryptoProvider`, `createInMemoryKms`, the `./aws` `createAwsKmsClient`, and the new 0.2.0 subpaths `./gcp` (`createGcpKmsClient`), `./azure` (`createAzureKmsClient`), `./vault` (`createVaultKmsClient`).
116
+ - `haechi-crypto-kms` (0.8 → 0.3.0): `createKmsCryptoProvider`, `createInMemoryKms`, the `./aws` `createAwsKmsClient`, the 0.2.0 subpaths `./gcp` (`createGcpKmsClient`), `./azure` (`createAzureKmsClient`), `./vault` (`createVaultKmsClient`), and 0.3.0 v2 crypto-AAD parity with core (`haechi >=1.7.0` peer floor).
117
117
  - `haechi-auth-jwt` (0.2.0): `createJwtAuthProvider` (0.8, behavior-preserving) plus the additive `createJwtVerifier` (reusable JWS verifier primitive) and `isBlockedAddress` (SSRF range predicate, reused by `haechi-auth-oidc`).
118
118
  - `haechi-dashboard` (0.1.0, new): `createDashboardServer`, `normalizeDashboardConfig`.
119
119
  - `haechi-auth-oidc` (0.1.0, new): `createOidcSessionBroker`, `normalizeOidcConfig`.
@@ -1,6 +1,6 @@
1
1
  # Haechi `configVersion` & 업그레이드 노트
2
2
 
3
- - 상태: Living document (코어 1.5.x 추적)
3
+ - 상태: Living document (코어 1.7.x 추적)
4
4
 
5
5
  `configVersion`는 `haechi.config.json`(및 `haechi.config.example.json`) 최상위에 찍히는 단일 정수입니다. 향후 호환성을 깨는 설정 스키마 변경이 구체적으로 게이트할 수 있는 **버전 앵커**로서, 다른 Haechi 빌드가 쓴 설정을 조용히 잘못 읽는 일을 막습니다.
6
6
 
@@ -21,7 +21,7 @@
21
21
 
22
22
  | `configVersion` | 코어 라인 | 노트 |
23
23
  |---|---|---|
24
- | `1` | 1.0 – 1.3.x | 최초 스탬프. 모든 키는 1.0 frozen 설정 표면(`api-stability.md` §2.4)에 대해 additive입니다. 1.1.x의 additive 키(`logging`, `metrics`, WS4-B의 `limits.maxInFlight` / `limits.shutdownGraceMs` / `limits.requestTimeoutMs` / `limits.headersTimeoutMs`, 그리고 `configVersion` 자체)와 1.2.0 신뢰성 강화 키(`filters.minConfidence` / `filters.allowlist`, `proxy.tls` / `proxy.trustForwardedProto`)는 모두 이전 동작을 기본값으로 합니다. 1.3.0의 추가는 새 키가 아니라 새 *값*입니다 — `target.type`의 `anthropic`/`gemini`, 추가 탐지 타입, `asia-pdpa`/`jp-appi` `privacy.profile` 따라서 설정 스키마(및 `configVersion`)는 변경되지 않습니다. 마이그레이션 불필요. |
24
+ | `1` | 1.0 – 1.7.x | 최초 스탬프. 모든 키는 1.0 frozen 설정 표면(`api-stability.md` §2.4)에 대해 additive입니다. 1.1.x의 additive 키(`logging`, `metrics`, WS4-B의 `limits.maxInFlight` / `limits.shutdownGraceMs` / `limits.requestTimeoutMs` / `limits.headersTimeoutMs`, 그리고 `configVersion` 자체)와 1.2.0 신뢰성 강화 키(`filters.minConfidence` / `filters.allowlist`, `proxy.tls` / `proxy.trustForwardedProto`)는 모두 이전 동작을 기본값으로 합니다. 1.3.0의 추가는 새 키가 아니라 새 *값*입니다 — `target.type`의 `anthropic`/`gemini`, 추가 탐지 타입, `asia-pdpa`/`jp-appi` `privacy.profile` 값입니다. 1.4.0 plugin-signing CLI, 1.5.0 store 시임, 1.6.0 nonce-budget 가시성, 1.7.0 v2 crypto-AAD/freshness 변경은 모두 CLI/API/envelope 동작이며 config key가 아닙니다. 따라서 설정 스키마(및 `configVersion`)는 변경되지 않습니다. 마이그레이션 불필요. |
25
25
 
26
26
  ## 업그레이드
27
27
 
@@ -1,6 +1,6 @@
1
1
  # Haechi `configVersion` & Upgrade Notes
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
3
+ - Status: Living document (tracks core 1.7.x)
4
4
 
5
5
  `configVersion` is a single integer stamped at the top of `haechi.config.json`
6
6
  (and `haechi.config.example.json`). It is a **versioned anchor** so a future
@@ -34,7 +34,7 @@ the "policies only get stronger / fail closed" invariant intact.
34
34
 
35
35
  | `configVersion` | Core line | Notes |
36
36
  |---|---|---|
37
- | `1` | 1.0 – 1.4.x | Initial stamp. All keys are additive over the 1.0 frozen config surface (`api-stability.md` §2.4). The 1.1.x additive keys (`logging`, `metrics`, the WS4-B `limits.maxInFlight` / `limits.shutdownGraceMs` / `limits.requestTimeoutMs` / `limits.headersTimeoutMs`, `configVersion` itself) and the 1.2.0 Reliability-Hardening keys (`filters.minConfidence` / `filters.allowlist`, `proxy.tls` / `proxy.trustForwardedProto`) all default to prior behavior. The 1.3.0 additions are new *values*, not new keys — `target.type` `anthropic`/`gemini`, additional detection types, and the `asia-pdpa`/`jp-appi` `privacy.profile` values. The 1.4.0 plugin-signing CLI (`plugin-keygen`/`plugin-sign`/`plugin-verify`) is **CLI surface, not config** — it adds no config keys. The 1.5.0 store seams (`createAuditSink`/`createTokenVault` + the file-store defaults) are an **injected-provider** surface (wired via `createRuntime(config, providers)`), not config keys. So the config schema (and `configVersion`) is unchanged. No migration needed. |
37
+ | `1` | 1.0 – 1.7.x | Initial stamp. All keys are additive over the 1.0 frozen config surface (`api-stability.md` §2.4). The 1.1.x additive keys (`logging`, `metrics`, the WS4-B `limits.maxInFlight` / `limits.shutdownGraceMs` / `limits.requestTimeoutMs` / `limits.headersTimeoutMs`, `configVersion` itself) and the 1.2.0 Reliability-Hardening keys (`filters.minConfidence` / `filters.allowlist`, `proxy.tls` / `proxy.trustForwardedProto`) all default to prior behavior. The 1.3.0 additions are new *values*, not new keys — `target.type` `anthropic`/`gemini`, additional detection types, and the `asia-pdpa`/`jp-appi` `privacy.profile` values. The 1.4.0 plugin-signing CLI (`plugin-keygen`/`plugin-sign`/`plugin-verify`) is **CLI surface, not config**. The 1.5.0 store seams (`createAuditSink`/`createTokenVault` + the file-store defaults) are an **injected-provider** surface. The 1.6.0 nonce-budget visibility and 1.7.0 v2 crypto-AAD/freshness changes are crypto envelope/API behavior, not config keys. So the config schema (and `configVersion`) is unchanged. No migration needed. |
38
38
 
39
39
  ## Upgrading
40
40
 
@@ -1,6 +1,6 @@
1
1
  # Haechi 설정 레퍼런스
2
2
 
3
- - 문서 상태: Living document(core 1.5.x 추적)
3
+ - 문서 상태: Living document(core 1.7.x 추적)
4
4
 
5
5
  `haechi init`은 `haechi.config.json`을 생성하며, 비밀 정보를 포함하지 않는 템플릿은 `haechi.config.example.json`에 있습니다. 모든 커맨드는 `--config <path>`로 설정 파일을 읽습니다(기본값: `haechi.config.json`). 설정은 **fail-closed 방식으로 검증**됩니다. 알 수 없는 provider, 범위를 벗어난 숫자, 잘못된 형식의 값은 자동으로 무시되지 않고 로드 시점에 오류를 발생시킵니다. `haechi config`는 이 레퍼런스를 출력하며, `haechi status`는 특정 설정 파일의 *실제 적용* 상태를 출력합니다.
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Haechi Configuration Reference
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
3
+ - Status: Living document (tracks core 1.7.x)
4
4
 
5
5
  `haechi init` writes `haechi.config.json`; a non-secret template is at `haechi.config.example.json`. Every command reads it with `--config <path>` (default `haechi.config.json`). Configuration is **validated fail-closed**: unknown providers, out-of-range numbers, and malformed values throw at load time rather than degrading silently. `haechi config` prints this reference; `haechi status` prints the *effective* state of a given config.
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Haechi 운영 런북 (Day-2)
2
2
 
3
- - 상태: Living document (코어 1.5.x 추적)
3
+ - 상태: Living document (코어 1.7.x 추적)
4
4
 
5
5
  Haechi를 프로덕션에서 운영하기 위한 실무 가이드입니다: 배포, 환경변수 오버레이를 통한 설정, health/readiness/metrics 모니터링, 우아한 종료, 백프레셔 튜닝, 그리고 해시 체인을 깨지 않는 audit 로그 회전입니다.
6
6
 
@@ -171,7 +171,37 @@ HAECHI_OLLAMA_URL=http://OLLAMA_HOST:11434 HAECHI_OLLAMA_MODEL=<pulled-model> \
171
171
  게이트가 필요하면, 해당 네트워크에 self-hosted 러너를 등록하고 그곳에서 스위트를
172
172
  트리거하십시오. GitHub 호스팅 러너는 사설 LAN에 도달할 수 없습니다.
173
173
 
174
- ## 9. 빠른 참조
174
+ ## 9. 회전 & AES-256-GCM nonce 예산
175
+
176
+ 로컬 AES-256-GCM crypto provider(`keys.provider: local`)는 모든 `encrypt`
177
+ 세그먼트를 랜덤 96-bit IV로 암호화합니다. 랜덤 IV는 키당 암호화 횟수가
178
+ 한정될 때만 안전하며 — birthday bound에 따라 NIST SP 800-38D §8.3은 랜덤-IV
179
+ 호출을 **키당 2^32회**로 제한합니다. provider가 이를 자동 강제합니다:
180
+
181
+ - 키(`kid`)별 암호화 횟수를 세어 미리 예약한 윈도우 단위로 키 파일
182
+ (`keys.keyFile`)에 영속화 → 재시작을 넘겨도 예산 유지.
183
+ - **50%** 지점에서 1회 프로세스 경고(`code: HAECHI_NONCE_BUDGET`) — 회전 예약 신호.
184
+ - 한도 도달 시 **fail-closed**: `encrypt`가 throw하고 프록시는 (GCM에 치명적인)
185
+ IV 충돌을 무릅쓰는 대신 에러를 반환합니다.
186
+
187
+ **한도 전에**(그리고 평소 키 회전 주기에) 회전하세요:
188
+
189
+ ```bash
190
+ haechi init --force # 새 active 키 발급; 기존 키는 삭제가 아니라 RETIRED
191
+ ```
192
+
193
+ `--force`는 현재 키를 retire하고(기존 봉투·token-vault 레코드 복호화를 위해
194
+ `kid`로 계속 주소 지정 가능) 새 active 키를 새 예산으로 시작합니다. 봉투가
195
+ 고아가 되지 않습니다.
196
+
197
+ **읽기 전용 키 파일:** `keys.keyFile`이 읽기 전용으로 마운트되면 예산을 영속화할
198
+ 수 없어, provider가 1회 경고(`code: HAECHI_NONCE_BUDGET_NOPERSIST`) 후
199
+ **프로세스 단위** 한도로 fallback합니다(재시작 간 보호 없음). 키 파일을 프록시가
200
+ 쓸 수 있게 두거나, 정해진 주기로 키를 회전하세요. **운영 custody:** 외부
201
+ `cryptoProvider`(`haechi-crypto-kms` 위성)를 사용하세요 — KMS/HSM이 자체 nonce
202
+ 규율을 가지므로 이 소프트웨어 예산은 적용되지 않습니다.
203
+
204
+ ## 10. 빠른 참조
175
205
 
176
206
  | 작업 | 커맨드 |
177
207
  |---|---|
@@ -1,6 +1,6 @@
1
1
  # Haechi Operations Runbook (Day-2)
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
3
+ - Status: Living document (tracks core 1.7.x)
4
4
 
5
5
  A practical guide to running Haechi in production: deploy, configure via the
6
6
  env-var overlay, monitor with health/readiness/metrics, shut down gracefully,
@@ -263,7 +263,40 @@ your own host/IP (do not commit it). For a continuously-exercised real-backend
263
263
  gate, register a self-hosted runner on that network and trigger the suite there;
264
264
  GitHub-hosted runners cannot reach a private LAN.
265
265
 
266
- ## 9. Quick reference
266
+ ## 9. Key rotation & the AES-256-GCM nonce budget
267
+
268
+ The local AES-256-GCM crypto provider (`keys.provider: local`) encrypts every
269
+ `encrypt`-action segment under a random 96-bit IV. Random IVs stay safe only up
270
+ to a bounded number of encryptions per key — by the birthday bound, NIST
271
+ SP 800-38D §8.3 caps random-IV invocations at **2^32 per key**. The provider
272
+ enforces this automatically:
273
+
274
+ - It **counts encryptions per key** (`kid`) and persists the count to the key
275
+ file (`keys.keyFile`) in pre-reserved windows, so the budget survives restarts.
276
+ - At **50%** of the budget it emits a one-time process warning
277
+ (`code: HAECHI_NONCE_BUDGET`) — your cue to schedule a rotation.
278
+ - At the limit it **fails closed**: `encrypt` throws and the proxy returns an
279
+ error rather than risk an IV collision (which would be catastrophic for GCM).
280
+
281
+ **Rotate before you hit the limit** (and on your normal key-rotation cadence):
282
+
283
+ ```bash
284
+ haechi init --force # mint a fresh active key; prior keys are RETIRED, not deleted
285
+ ```
286
+
287
+ `--force` retires the current key (it stays `kid`-addressable so existing
288
+ envelopes and token-vault records still decrypt) and starts a new active key
289
+ with a fresh budget. No envelopes are orphaned.
290
+
291
+ **Read-only key file:** if `keys.keyFile` is mounted read-only, the budget cannot
292
+ be persisted; the provider warns once (`code: HAECHI_NONCE_BUDGET_NOPERSIST`) and
293
+ falls back to a **per-process** limit (cross-restart protection is off). Either
294
+ leave the key file writable by the proxy, or rotate keys on a fixed schedule.
295
+ **Production custody:** use an external `cryptoProvider` (the `haechi-crypto-kms`
296
+ satellite) — a KMS/HSM owns its own nonce discipline and this software budget
297
+ does not apply.
298
+
299
+ ## 10. Quick reference
267
300
 
268
301
  | Task | Command |
269
302
  |---|---|
@@ -1,6 +1,6 @@
1
1
  # 플러그인 서명 & 신뢰 앵커 큐레이션
2
2
 
3
- - 상태: Living document (코어 1.5.x 추적)
3
+ - 상태: Living document (코어 1.7.x 추적)
4
4
  - 날짜: 2026-06-17
5
5
 
6
6
  Haechi의 기본은 **dependency injection**입니다 — `createRuntime(config, providers)`에
@@ -1,6 +1,6 @@
1
1
  # Plugin Signing & Trust-Anchor Curation
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
3
+ - Status: Living document (tracks core 1.7.x)
4
4
  - Date: 2026-06-17
5
5
 
6
6
  Haechi's default is **dependency injection** — you pass an `authProvider` to
@@ -1,6 +1,6 @@
1
1
  # Haechi Release Process
2
2
 
3
- - 문서 상태: Living document (core 1.5.x 추적)
3
+ - 문서 상태: Living document (core 1.7.x 추적)
4
4
  - 작성일: 2026-06-10
5
5
 
6
6
  ## 1. 로컬 릴리즈 검증
@@ -74,6 +74,7 @@ npm audit signatures
74
74
  | `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
75
75
  | `.github/workflows/auth-oidc-publish.yml` | `haechi-auth-oidc` | `auth-oidc-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
76
76
  | `.github/workflows/ratelimit-redis-publish.yml` | `haechi-ratelimit-redis` | `ratelimit-redis-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
77
+ | `.github/workflows/store-redis-publish.yml` | `haechi-store-redis` | `store-redis-v<semver>` | satellite publish, 동일한 서명 아티팩트 경로 |
77
78
 
78
79
  각 publish 워크플로는 `release: published`에서 트리거되지만 **가드**되어 둘이 교차 발화하지 않습니다. core job은 `v`로 시작하는 태그에서만 실행되고(그리고 `^v[0-9]+\.[0-9]+\.[0-9]+$` 재검증), satellite job은 `crypto-kms-v…`에서만 실행됩니다(그리고 `^crypto-kms-v[0-9]+\.[0-9]+\.[0-9]+$` 재검증 **및** 태그 버전이 satellite `package.json` 버전과 일치하는지 검증). npmjs.com Trusted Publisher는 각 패키지의 **특정 워크플로 파일명**에 바인딩됩니다 — 워크플로 파일 rename은 npm 설정을 갱신할 때까지 OIDC publish를 깨뜨립니다.
79
80
 
@@ -103,6 +104,7 @@ Satellite는 npm workspaces 모노레포의 `satellites/*`에 살며 core와 **
103
104
  | `haechi-dashboard` | `dashboard-v<semver>` | `dashboard-publish.yml` | `satellites/dashboard/package.json` |
104
105
  | `haechi-auth-oidc` | `auth-oidc-v<semver>` | `auth-oidc-publish.yml` | `satellites/auth-oidc/package.json` |
105
106
  | `haechi-ratelimit-redis` | `ratelimit-redis-v<semver>` | `ratelimit-redis-publish.yml` | `satellites/ratelimit-redis/package.json` |
107
+ | `haechi-store-redis` | `store-redis-v<semver>` | `store-redis-publish.yml` | `satellites/store-redis/package.json` |
106
108
 
107
109
  **satellite 릴리스 검증** (core와 동일한 신뢰 앵커):
108
110
 
@@ -117,6 +119,15 @@ npm view haechi-crypto-kms --json # dist.attestations 존재 확인; access "p
117
119
 
118
120
  **`haechi-ratelimit-redis`(부트스트랩 2026-06-16):** 공유 저장소 rate-limiter satellite는 위의 두 단계 부트스트랩을 따랐습니다. `0.1.0`은 이름을 확보한 **수동 첫 발행**(로컬 패스키 web 인증, `--provenance=false`)이므로 **비증명**입니다(§2에 기록). 이후 Trusted Publisher(`ratelimit-redis-publish.yml`)를 설정했고, `0.1.1`부터의 모든 버전은 `ratelimit-redis-v<semver>` 태그 → 워크플로로 provenance와 함께 발행됩니다. `redis` 클라이언트는 **optional peer dependency**이며 번들된 Redis 어댑터를 쓰는 소비자만 import합니다(store/client는 주입됩니다). 따라서 core는 zero-dependency로 유지됩니다.
119
121
 
122
+ **`haechi-store-redis`(새 unscoped 이름 — 부트스트랩 대기 중):** 공유 저장소 audit-sink + token-vault satellite(1.5.0 store-seam 소비자)는 동일한 두 단계 부트스트랩을 따릅니다. publish 워크플로 `store-redis-publish.yml`(태그 `store-redis-v<semver>`)은 이미 마련되어 있으나 아직 이름이 확보되지 않았으므로, FIRST publish는 `0.1.0`을 생성하는 maintainer의 수동 `--provenance=false` 부트스트랩이어야 합니다.
123
+
124
+ ```bash
125
+ npm login --auth-type=web
126
+ cd satellites/store-redis && npm publish --auth-type=web --provenance=false
127
+ ```
128
+
129
+ 그런 다음 npmjs.com에서 Trusted Publisher를 설정하고(`raeseoklee/haechi` + 워크플로 파일명 `store-redis-publish.yml` 연결), `0.1.1`부터의 모든 버전은 `store-redis-v<semver>` 태그 → 워크플로로 provenance와 함께 발행됩니다. `redis`는 **optional peer dependency**입니다(클라이언트는 주입됩니다). 따라서 core는 zero-dependency로 유지됩니다. `0.1.0`이 부트스트랩되기 전까지 이 satellite는 repo에 소스로만 존재합니다.
130
+
120
131
  ## 6. 배포 차단 조건
121
132
 
122
133
  다음 중 하나라도 실패하면 npm publish를 하지 않습니다.
@@ -1,6 +1,6 @@
1
1
  # Haechi Release Process
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
3
+ - Status: Living document (tracks core 1.7.x)
4
4
  - Date: 2026-06-10
5
5
 
6
6
  ## 1. Local Release Verification
@@ -74,6 +74,7 @@ npm audit signatures
74
74
  | `.github/workflows/dashboard-publish.yml` | `haechi-dashboard` | `dashboard-v<semver>` | satellite publish, same signed-artifacts path |
75
75
  | `.github/workflows/auth-oidc-publish.yml` | `haechi-auth-oidc` | `auth-oidc-v<semver>` | satellite publish, same signed-artifacts path |
76
76
  | `.github/workflows/ratelimit-redis-publish.yml` | `haechi-ratelimit-redis` | `ratelimit-redis-v<semver>` | satellite publish, same signed-artifacts path |
77
+ | `.github/workflows/store-redis-publish.yml` | `haechi-store-redis` | `store-redis-v<semver>` | satellite publish, same signed-artifacts path |
77
78
 
78
79
  Each publish workflow triggers on `release: published` but is **guarded** so the two never cross-fire: the core job runs only for tags starting `v` (and re-validates `^v[0-9]+\.[0-9]+\.[0-9]+$`); the satellite job runs only for `crypto-kms-v…` (and re-validates `^crypto-kms-v[0-9]+\.[0-9]+\.[0-9]+$` **and** that the tag version equals the satellite's `package.json` version). The npmjs.com Trusted Publisher for each package is bound to its **specific workflow filename** — renaming a workflow file breaks its OIDC publish until the npm config is updated.
79
80
 
@@ -103,6 +104,7 @@ A Trusted Publisher **cannot** be configured for a name that does not exist yet
103
104
  | `haechi-dashboard` | `dashboard-v<semver>` | `dashboard-publish.yml` | `satellites/dashboard/package.json` |
104
105
  | `haechi-auth-oidc` | `auth-oidc-v<semver>` | `auth-oidc-publish.yml` | `satellites/auth-oidc/package.json` |
105
106
  | `haechi-ratelimit-redis` | `ratelimit-redis-v<semver>` | `ratelimit-redis-publish.yml` | `satellites/ratelimit-redis/package.json` |
107
+ | `haechi-store-redis` | `store-redis-v<semver>` | `store-redis-publish.yml` | `satellites/store-redis/package.json` |
106
108
 
107
109
  **Verify a satellite release** (same anchors as core):
108
110
 
@@ -117,6 +119,15 @@ npm view haechi-crypto-kms --json # dist.attestations present; access "public"
117
119
 
118
120
  **`haechi-ratelimit-redis` (bootstrapped 2026-06-16):** the shared-store rate-limiter satellite followed the two-phase bootstrap above. `0.1.0` was the **manual first publish** (local passkey web auth, `--provenance=false`) that claimed the name — so it is **unattested** (recorded in §2). The Trusted Publisher (`ratelimit-redis-publish.yml`) was then configured, and every version from `0.1.1` on is published via the `ratelimit-redis-v<semver>` tag → workflow with provenance. The `redis` client is an **optional peer dependency**, imported only by consumers using the bundled Redis adapter (the store/client is injected), so core stays zero-dependency.
119
121
 
122
+ **`haechi-store-redis` (new unscoped name — bootstrap pending):** the shared-store audit-sink + token-vault satellite (the 1.5.0 store-seam consumer) follows the same two-phase bootstrap. The publish workflow `store-redis-publish.yml` (tag `store-redis-v<semver>`) is already in place, but the name has not been claimed yet, so the FIRST publish must be the maintainer's manual `--provenance=false` bootstrap that creates `0.1.0`:
123
+
124
+ ```bash
125
+ npm login --auth-type=web
126
+ cd satellites/store-redis && npm publish --auth-type=web --provenance=false
127
+ ```
128
+
129
+ Then configure the Trusted Publisher on npmjs.com (link `raeseoklee/haechi` + the workflow filename `store-redis-publish.yml`), and every version from `0.1.1` on publishes via the `store-redis-v<semver>` tag → workflow with provenance. `redis` is an **optional peer dependency** (the client is injected), so core stays zero-dependency. Until `0.1.0` is bootstrapped, the satellite lives in the repo as source only.
130
+
120
131
  ## 6. Deployment block conditions
121
132
 
122
133
  npm publish is not performed if any of the following fail.
@@ -1,9 +1,9 @@
1
1
  # Haechi 리스크 레지스터 및 릴리스 게이트
2
2
 
3
- - 문서 상태: Living document(core 1.5.x 추적)
4
- - 작성일: 2026-06-16
5
- - 기준 버전: 1.5.x
6
- - 기준 브랜치: `main`
3
+ - 문서 상태: Living document(core 1.7.x 추적)
4
+ - 작성일: 2026-06-23
5
+ - 기준 버전: 1.7.x
6
+ - 기준 브랜치: `irae/prepare-1.7.0`
7
7
 
8
8
  ## 1. 현재 판단
9
9
 
@@ -14,9 +14,9 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
14
14
  | 구분 | 판단 | 이유 |
15
15
  |---|---|---|
16
16
  | GitHub public | 허용 | 보안 한계, threat model, shared responsibility가 문서화됨 |
17
- | GitHub release/tag | 허용 (`v1.5.0` 릴리스됨) | `v1.5.0`이 현재 릴리스(additive minor 수평 확장을 위한 주입 가능한 audit/token-vault 저장소 시임); §5.7 / §5.8 항목은 모두 Resolved 유지, G9–G12는 Pass |
18
- | npm stable | `haechi@1.5.0` publish됨 | `1.5.0`은 `1.4.x` 기준 위에 `createAuditSink`/`createTokenVault` 저장소 시임(파일 기본값 바이트 동일)을 더한 attested OIDC publish; config/API 파괴 없음(`configVersion`은 `1` 유지) |
19
- | production use | 운영자 게이트; `1.5.0`로 업그레이드 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; 여러 replica를 운영하는 운영자는 공유 저장소(`haechi-store-redis` 위성)를 주입해 audit 해시 체인과 token vault가 플릿 전체에서 유지되도록 해야 함 |
17
+ | GitHub release/tag | `v1.7.0` 컷 준비됨 (태그 → attested publish) | `v1.7.0`은 배포 완료된 `v1.6.0` 위의 additive minor입니다. crypto envelope가 versioned NFKC AAD(`nfkc-json-v2`)를 사용하고, legacy `v1` envelope는 계속 복호화되며, token-vault ciphertext에는 envelope freshness가 적용됩니다. G9–G14최종 release preflight 전 기준 Pass |
18
+ | npm stable | `haechi@1.6.0` 배포 완료; 다음 목표 `haechi@1.7.0` | `1.7.0`은 frozen `haechi/crypto` export(`canonicalizeCryptoAad`, `CRYPTO_AAD_ENCODING_V2`)를 추가하고 `haechi-crypto-kms@0.3.0`도 같은 v2 AAD helper로 맞춥니다. additive export만, config/API 파괴 없음(`configVersion`은 `1` 유지) |
19
+ | production use | 운영자 게이트; `1.7.0` 업그레이드 준비 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; 여러 replica를 운영하는 운영자는 공유 저장소(`haechi-store-redis` 위성)를 주입해 audit 해시 체인과 token vault가 플릿 전체에서 유지되도록 해야 함 |
20
20
 
21
21
  ## 2. 릴리스 게이트
22
22
 
@@ -35,6 +35,8 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
35
35
  | G10 | 2026-06-16 코드리뷰 round 2 (CR2) 보완 게이트 | CR2 등록부(`code-review-risk-register-2026-06-16-round2.md`, §5.8)는 **P0/P1을 발견하지 못했습니다**; 세 개의 P2(`CR2-001` 프록시 upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply 경계)와 P3 묶음(`CR2-004..008`)이 모두 **Resolved이며 `haechi@1.3.2`로 발행되었고**(`CR2-009` won't-fix, `CR2-010` accepted) 연결된 등록부가 갱신되었습니다. | Pass (`haechi@1.3.2`, 2026-06-16) |
36
36
  | G11 | 1.4.0 signed-plugin 저작 CLI | 1.0 Ed25519 trust gate를 위한 1차 저작 CLI — `plugin-keygen`(개인키 `0600`, 공개키 = trust anchor), `plugin-sign`(정확한 entry 바이트 바인딩), `plugin-verify`(런타임 동등 검증, fail-closed, `--allow-capability`); 개인키가 stdout/audit로 유출되지 않음; 적대적 검증 완료; `plugin-signing-and-trust.md` 큐레이션 런북이 P1-SEC-025 "운영자가 앵커를 큐레이션해야 함" 잔여를 해소. additive CLI 표면(config/API 파괴 없음, `configVersion`은 `1` 유지); `tests/api-contract.test.mjs` green; 코어는 zero runtime dependency 유지; 코어 1.3.3 → 1.4.0(additive minor)로 bump. | Pass (`haechi@1.4.0`, 2026-06-17) |
37
37
  | G12 | 1.5.0 수평 확장 저장소 시임 | audit sink와 token vault가 주입 가능한 **store**를 갖게 되어, 공유 저장소가 sha256 해시 체인 + token vault를 replica 전반에서 뒷받침할 수 있음(프로세스별 / 단일 writer 플릿 한계를 해소). `createAuditSink({store})` / `createTokenVault({store})` + 기본 `createFileAuditStore`/`createFileTokenStore`; 보안에 결정적인 chaining / `sanitizeAudit` / reveal governance / retention은 코어에 남고, store는 배타적 read-previous+persist(audit) / mutate+read(vault) 프리미티브만 추상화. 적대적 검증 완료: 파일 기본값 바이트 동일, chain 연산은 이전과 diff 동일, 비파일 store에서도 시임 동작, 동시 append/tokenize가 비분기·무손실 유지, CR2-002 audit-no-plaintext 유지. 새 export는 `api-stability.md` + `tests/api-contract.test.mjs`에 frozen; `createJsonlAuditSink`/`createLocalTokenVault`는 하위호환 래퍼; 코어는 zero runtime dependency 유지; 코어 1.4.0 → 1.5.0(additive minor)로 bump. `haechi-store-redis` 위성이 운영 소비자. | Pass (`haechi@1.5.0`, 2026-06-17) |
38
+ | G13 | 1.6.0 crypto 봉투 무결성 — nonce budget + 보안 CI 게이트 | 스코핑 결과 gap review의 "AAD canonicalization"(GAP-P0-001)은 sorted canonical JSON으로 부분 해소되어 있었고, nonce 절반(GAP-P0-002)을 닫음: 로컬 AES-256-GCM provider가 `kid`별 암호화 횟수를 세어 미리 예약한 윈도우 단위로 키 파일에 영속화(소비 전 기록 → 재시작은 과대집계, 재사용으로의 과소집계 불가)하고, 50%에서 1회 경고, **2^32에서 fail-closed**(NIST SP 800-38D §8.3)하며 `init --force` 회전을 안내. 읽기 전용 키 파일은 프로세스 단위 한도로 degrade(경고 `HAECHI_NONCE_BUDGET_NOPERSIST`); 다중 프로세스 공유는 범위 밖 잔여(단일 writer 레퍼런스 provider; 운영 custody = KMS). 운영자 가시성은 frozen `haechi/crypto` `readNonceBudget` export + `haechi status`(`keys.nonceBudget` 사용%). 또한 GAP-P0-012로 명명된 `gate:security` CI 잡이 횡단 보안 invariant를 검사. core 1.5.0 → 1.6.0(additive minor); zero runtime dependency; `configVersion`은 `1` 유지. | Pass (`haechi@1.6.0`, 2026-06-18) |
39
+ | G14 | 1.7.0 crypto envelope v2 — NFKC AAD + freshness + KMS parity | 남은 crypto-envelope canonicalization/freshness 절반을 닫음: 새 local-provider envelope는 `v:2`, `aadEncoding:"nfkc-json-v2"`, `createdAt`, optional `expiresAt`를 사용합니다. decrypt는 envelope 버전에 따라 canonicalization을 선택하므로 legacy `v1`/무표기 envelope는 계속 읽을 수 있고, v2는 `canonicalizeCryptoAad()`(정렬 canonical JSON + NFKC-normalized string value/object key)를 해시합니다. 같은 object level에서 NFKC key collision이 나면 두 AAD binding을 조용히 합치지 않고 fail-closed합니다. token-vault ciphertext는 token `expiresAt`를 envelope에 전달해 vault retention check에 더해 crypto 계층에서도 stale ciphertext를 거부합니다. `haechi-crypto-kms@0.3.0`은 동일 helper를 import하고 core peer floor를 `>=1.7.0 <2.0.0`으로 올립니다. NFKC AAD cross-implementation parity 테스트 포함. Additive frozen exports: `canonicalizeCryptoAad`, `CRYPTO_AAD_ENCODING_V2`; `configVersion`은 `1` 유지; core는 zero runtime dependency 유지. 잔여: stream sequence AAD / replay cache는 streaming encryption이 아직 독립 복호화 가능한 stream envelope를 내보내지 않으므로 후속으로 둡니다. | Pass (컷 준비됨; final preflight pending) |
38
40
 
39
41
  ## 3. P0 배포 차단 리스크 상태
40
42
 
@@ -112,7 +114,7 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
112
114
 
113
115
  | ID | 리스크 | 상태 | 해소 증거 |
114
116
  |---|---|---|---|
115
- | P1-SEC-026 | OIDC broker 세션/로그인 보안: `haechi-auth-oidc`의 login CSRF, authorization-code injection, open-redirect, session fixation, mix-up(잘못된 IdP/RP) | Mitigated | `satellites/auth-oidc/index.mjs`: state-first `/auth/callback`(pre-auth 쿠키 바인딩 pending record를 atomic `take()` + egress 이전 constant-time `state` 비교), PKCE S256, callback에서 새 세션 id 발급(fixation 없음), `returnToAllowlist`(open-redirect 없음), issuer/endpoint pinning + RFC 9207 `iss` 검사 + 공유 `createJwtVerifier` 경유 ID-token `aud`/`azp` 프로파일(mix-up), CSRF-gated non-GET logout. `satellites/auth-oidc/auth-oidc.test.mjs`가 각 deny 케이스 검증; scope §6 adversarial review. **잔여:** multi-origin IdP는 범위 |
117
+ | P1-SEC-026 | OIDC broker 세션/로그인 보안: `haechi-auth-oidc`의 login CSRF, authorization-code injection, open-redirect, session fixation, mix-up(잘못된 IdP/RP) | Mitigated | `satellites/auth-oidc/index.mjs`: state-first `/auth/callback`(pre-auth 쿠키 바인딩 pending record를 atomic `take()` + egress 이전 constant-time `state` 비교), PKCE S256, callback에서 새 세션 id 발급(fixation 없음), `returnToAllowlist`(open-redirect 없음), issuer/endpoint pinning + RFC 9207 `iss` 검사 + 공유 `createJwtVerifier` 경유 ID-token `aud`/`azp` 프로파일(mix-up), CSRF-gated non-GET logout. `satellites/auth-oidc/auth-oidc.test.mjs`가 각 deny 케이스 검증; scope §6 adversarial review. **잔여 해소(auth-jwt 0.3.0 / auth-oidc 0.2.0):** multi-origin / CDN-fronted IdP는 이제 운영자-핀 `trustedEndpointHosts` allowlist를 통해 지원됩니다(엔드포인트 호스트는 issuer 호스트와 같거나 운영자가 선언한 allowlist에 포함될 때만 허용) — 이 allowlist는 운영자 설정에서만 만들어지고(절대 discovery/JWKS 내용에서 만들어지지 않으므로 mix-up 방어가 유지됨) same-host 문자열 검사만 완화합니다. `https` 요구, `isBlockedAddress` SSRF 가드 / `guardedFetch` post-DNS 재검사, `metadata.issuer` string-equality(issuer-confusion) 가드, RFC 9207 `iss` 검사는 모두 무조건 실행됩니다. broker는 또한 opt-in refresh-token rotation / silent renewal(`enableRefresh`, 기본 off)을 추가했습니다: refresh token은 AEAD 봉투로만 저장되고(평문/audit 없음), 하드 `refreshMaxLifetimeSeconds` 상한으로 제한되며, 갱신된 ID token은 완전 재검증 + subject-pin(anti-swap)되고, refresh 실패는 fail-closed입니다 — broker가 여전히 access token을 사용하지 않으므로 `at_hash`/`c_hash` 제외는 유효하게 유지됩니다. |
116
118
  | P1-OPS-009 | Dashboard audit 노출: `haechi-dashboard`의 `detections[].path` stored XSS, 미래 필드 audit leak, localhost 뷰어 DNS-rebinding 읽기, remote bind 시 인증 없는 읽기 | Mitigated | `satellites/dashboard/index.mjs` + `assets.mjs`: 엄격 CSP(`require-trusted-types-for 'script'`) + `textContent`-only 렌더링(XSS), `FORBIDDEN_KEYS` 위 재귀적 key-by-key allowlist projection(필드 leak), 요청별 anti-rebinding `Host` allowlist + CORP/COOP same-origin(rebinding), `sessionGuard` **및** TLS 종단을 요구하는 fail-closed remote bind(인증 없는 remote 읽기). `satellites/dashboard/dashboard.test.mjs`; scope §6 adversarial review. **잔여:** remote bind 시 운영자가 TLS 종단을 책임 |
117
119
  | P2-CRYPTO-001 | KMS backend egress: `haechi-crypto-kms@0.2.0` Vault/GCP/Azure backend가 key material이나 provider/key-path 상세를 유출하거나 의도치 않은(metadata) 엔드포인트에 도달 가능 | Accepted | `satellites/crypto-kms/{vault,gcp,azure}.mjs`: optional-peer + injected-client 모델과 faithful-mock `assertCryptoProviderConformance`(cross-key·corrupted-blob 거부, HMAC determinism/domain-separation), Vault `fetch`의 satellite-local `isBlockedAddress` SSRF 가드(dev-only `satellites/crypto-kms/ssrf-parity.test.mjs`로 auth-jwt와 parity 유지), generic fail-closed provider-error 매핑(audit에 provider/key-ARN 없음). `{vault,gcp,azure}.test.mjs` + `crypto-kms.test.mjs`; scope §6 adversarial review. **수용된 잔여:** 실제 Vault/GCP/Azure live-backend 검증은 CI 외부; 발행 tarball은 zero runtime dependency 유지 |
118
120
 
@@ -1,9 +1,9 @@
1
1
  # Haechi Risk Register and Release Gates
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
4
- - Date: 2026-06-16
5
- - Target version: 1.3.x
6
- - Branch: `main`
3
+ - Status: Living document (tracks core 1.7.x)
4
+ - Date: 2026-06-23
5
+ - Target version: 1.7.x
6
+ - Branch: `irae/prepare-1.7.0`
7
7
 
8
8
  ## 1. Current Assessment
9
9
 
@@ -14,9 +14,9 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
14
14
  | Category | Judgment | Rationale |
15
15
  |---|---|---|
16
16
  | GitHub public | Allowed | Security limitations, threat model, and shared responsibility are documented |
17
- | GitHub release/tag | Allowed (`v1.5.0` released) | `v1.5.0` is the current release (additive minor injectable audit/token-vault store seams for horizontal scale); all §5.7 / §5.8 findings remain Resolved and G9–G12 are Pass |
18
- | npm stable | `haechi@1.5.0` published | `1.5.0` is an attested OIDC publish adding `createAuditSink`/`createTokenVault` store seams (file defaults byte-identical) over the `1.4.x` baseline; no config/API break (`configVersion` stays `1`) |
19
- | Production use | Operator-gated; upgrade to `1.5.0` | Supported only with operator network controls, authz/authn, and key custody; operators running multiple replicas should inject a shared store (the `haechi-store-redis` satellite) so the audit hash chain and token vault hold across the fleet |
17
+ | GitHub release/tag | `v1.7.0` cut prepared (tag → attested publish) | `v1.7.0` is an additive minor over the shipped `v1.6.0`: crypto envelopes now use versioned NFKC AAD (`nfkc-json-v2`), legacy `v1` envelopes remain decryptable, and envelope freshness is enforced for token-vault ciphertext. G9–G14 are Pass pending final release preflight |
18
+ | npm stable | `haechi@1.6.0` published; next target `haechi@1.7.0` | `1.7.0` adds frozen `haechi/crypto` exports (`canonicalizeCryptoAad`, `CRYPTO_AAD_ENCODING_V2`) and updates `haechi-crypto-kms@0.3.0` to the same v2 AAD helper; additive export only, no config/API break (`configVersion` stays `1`) |
19
+ | Production use | Operator-gated; prepare upgrade to `1.7.0` | Supported only with operator network controls, authz/authn, and key custody; operators running multiple replicas should inject a shared store (the `haechi-store-redis` satellite) so the audit hash chain and token vault hold across the fleet |
20
20
 
21
21
  ## 2. Release Gates
22
22
 
@@ -35,6 +35,8 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
35
35
  | G10 | 2026-06-16 code-review round 2 (CR2) remediation gate | The CR2 register (`code-review-risk-register-2026-06-16-round2.md`, §5.8) found **no P0/P1**; its three P2s (`CR2-001` proxy upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply bound) plus the P3 cluster (`CR2-004..008`) are all **Resolved and shipped in `haechi@1.3.2`** (`CR2-009` won't-fix, `CR2-010` accepted) and the linked register is updated. | Pass (`haechi@1.3.2`, 2026-06-16) |
36
36
  | G11 | 1.4.0 signed-plugin authoring CLI | First-party CLI for the 1.0 Ed25519 trust gate — `plugin-keygen` (private key `0600`, public key = trust anchor), `plugin-sign` (binds the exact entry bytes), `plugin-verify` (runtime-equivalent verification, fail-closed, `--allow-capability`); no private-key leak to stdout/audit; adversarially verified; the `plugin-signing-and-trust.md` curation runbook closes the P1-SEC-025 "operator must curate anchors" residual. Additive CLI surface (no config/API break, `configVersion` stays `1`); `tests/api-contract.test.mjs` green; core stays zero runtime dependency; core bumped 1.3.3 → 1.4.0 (additive minor). | Pass (`haechi@1.4.0`, 2026-06-17) |
37
37
  | G12 | 1.5.0 horizontal-scale store seams | The audit sink and token vault gain an injectable **store** so a shared store can back the sha256 hash chain + token vault across replicas (closes the per-process / single-writer fleet limitations). `createAuditSink({store})` / `createTokenVault({store})` + the default `createFileAuditStore`/`createFileTokenStore`; the security-critical chaining / `sanitizeAudit` / reveal governance / retention stay in core, the store only abstracts the exclusive read-previous+persist (audit) / mutate+read (vault) primitives. Adversarially verified: file defaults byte-identical, chain math diff-identical to prior, the seam works for a non-file store, concurrent appends/tokenize stay non-forked/lossless, CR2-002 audit-no-plaintext intact. New exports frozen in `api-stability.md` + `tests/api-contract.test.mjs`; `createJsonlAuditSink`/`createLocalTokenVault` are back-compat wrappers; core stays zero runtime dependency; core bumped 1.4.0 → 1.5.0 (additive minor). The `haechi-store-redis` satellite is the production consumer. | Pass (`haechi@1.5.0`, 2026-06-17) |
38
+ | G13 | 1.6.0 crypto envelope integrity — nonce budget + security CI gate | Scoping confirmed the gap review's "AAD canonicalization" (GAP-P0-001) was partially closed by sorted canonical JSON, and closed the nonce half (GAP-P0-002): the local AES-256-GCM provider counts encryptions per `kid`, persists the count to the key file in pre-reserved windows (written before consumption → restart over-counts, never under-counts into reuse), warns once at 50%, and **fails closed at 2^32** (NIST SP 800-38D §8.3) instructing `init --force`. A read-only key file degrades to a per-process limit (warned, `HAECHI_NONCE_BUDGET_NOPERSIST`); multi-process sharing is an out-of-scope residual (single-writer reference provider; prod custody = KMS). Operator visibility via the frozen `haechi/crypto` `readNonceBudget` export + `haechi status` (`keys.nonceBudget` used%). Also adds GAP-P0-012: a named, branch-protectable `gate:security` CI job (`scripts/security-gate.mjs`) over cross-cutting security invariants. Core bumped 1.5.0 → 1.6.0 (additive minor); zero runtime dependency; `configVersion` stays `1`. | Pass (`haechi@1.6.0`, 2026-06-18) |
39
+ | G14 | 1.7.0 crypto envelope v2 — NFKC AAD + freshness + KMS parity | Closes the remaining crypto-envelope canonicalization/freshness half: new local-provider envelopes use `v:2`, `aadEncoding:"nfkc-json-v2"`, `createdAt`, and an optional `expiresAt`; decrypt chooses canonicalization by envelope version so legacy `v1`/unencoded envelopes remain readable, while v2 hashes `canonicalizeCryptoAad()` (sorted canonical JSON with NFKC-normalized string values and object keys). NFKC key collisions at one object level fail closed rather than collapse two AAD bindings. Token-vault ciphertext passes the token `expiresAt` into the envelope so stale vault ciphertext is rejected by crypto as defense-in-depth in addition to vault retention checks. `haechi-crypto-kms@0.3.0` imports the same helper and raises its core peer floor to `>=1.7.0 <2.0.0`; cross-implementation parity is tested against NFKC AAD. Additive frozen exports: `canonicalizeCryptoAad`, `CRYPTO_AAD_ENCODING_V2`; `configVersion` stays `1`; core stays zero runtime dependency. Remaining residual: stream sequence AAD / replay cache for encrypted stream fragments remains deferred because streaming encryption still emits transformed text, not independently decryptable stream envelopes. | Pass (cut prepared; final preflight pending) |
38
40
 
39
41
  ## 3. P0 Distribution-Blocking Risk Status
40
42
 
@@ -112,7 +114,7 @@ These IDs are scoped to the 0.9.0 satellite cut (`haechi-dashboard`, `haechi-aut
112
114
 
113
115
  | ID | Risk | Status | Resolution evidence |
114
116
  |---|---|---|---|
115
- | P1-SEC-026 | OIDC broker session/login security: login CSRF, authorization-code injection, open-redirect, session fixation, and mix-up (wrong IdP/RP) in `haechi-auth-oidc` | Mitigated | `satellites/auth-oidc/index.mjs`: state-first `/auth/callback` (atomic `take()` of a pre-auth-cookie-bound pending record + constant-time `state` compare before any egress), PKCE S256, fresh session id minted at callback (no fixation), `returnToAllowlist` (no open-redirect), issuer/endpoint pinning + RFC 9207 `iss` check + ID-token `aud`/`azp` profile via the shared `createJwtVerifier` (mix-up), CSRF-gated non-GET logout. `satellites/auth-oidc/auth-oidc.test.mjs` exercises each deny case; adversarial review in scope §6. **Residual:** multi-origin IdP out of scope |
117
+ | P1-SEC-026 | OIDC broker session/login security: login CSRF, authorization-code injection, open-redirect, session fixation, and mix-up (wrong IdP/RP) in `haechi-auth-oidc` | Mitigated | `satellites/auth-oidc/index.mjs`: state-first `/auth/callback` (atomic `take()` of a pre-auth-cookie-bound pending record + constant-time `state` compare before any egress), PKCE S256, fresh session id minted at callback (no fixation), `returnToAllowlist` (no open-redirect), issuer/endpoint pinning + RFC 9207 `iss` check + ID-token `aud`/`azp` profile via the shared `createJwtVerifier` (mix-up), CSRF-gated non-GET logout. `satellites/auth-oidc/auth-oidc.test.mjs` exercises each deny case; adversarial review in scope §6. **Residual closed (auth-jwt 0.3.0 / auth-oidc 0.2.0):** multi-origin / CDN-fronted IdP is now supported via an operator-pinned `trustedEndpointHosts` allowlist (an endpoint host is accepted iff it equals the issuer host OR is in the operator-declared allowlist) — the allowlist is built only from operator config (never discovery/JWKS content, so the mix-up defense holds) and relaxes ONLY the same-host string check; the `https` requirement, the `isBlockedAddress` SSRF guard / `guardedFetch` post-DNS re-check, the `metadata.issuer` string-equality (issuer-confusion) guard, and the RFC 9207 `iss` checks all still run unconditionally. The broker also gained opt-in refresh-token rotation / silent renewal (`enableRefresh`, default off): the refresh token is stored only as an AEAD envelope (never plaintext/audit), bounded by a hard `refreshMaxLifetimeSeconds` ceiling, the renewed ID token is fully re-verified + subject-pinned (anti-swap), and a refresh failure fails closed — the `at_hash`/`c_hash` exclusion stays valid because the broker still never uses the access token. |
116
118
  | P1-OPS-009 | Dashboard audit exposure: stored XSS via `detections[].path`, future-field audit leak, DNS-rebinding read of a localhost viewer, and unauthenticated read on remote bind in `haechi-dashboard` | Mitigated | `satellites/dashboard/index.mjs` + `assets.mjs`: strict CSP (`require-trusted-types-for 'script'`) + `textContent`-only rendering (XSS), recursive key-by-key allowlist projection over `FORBIDDEN_KEYS` (field leak), per-request anti-rebinding `Host` allowlist + CORP/COOP same-origin (rebinding), fail-closed remote bind requiring `sessionGuard` **and** TLS termination (unauthenticated remote read). `satellites/dashboard/dashboard.test.mjs`; adversarial review in scope §6. **Residual:** operator must terminate TLS for remote bind |
117
119
  | P2-CRYPTO-001 | KMS backend egress: the `haechi-crypto-kms@0.2.0` Vault/GCP/Azure backends could leak key material or provider/key-path detail or reach an unintended (metadata) endpoint | Accepted | `satellites/crypto-kms/{vault,gcp,azure}.mjs`: optional-peer + injected-client model with faithful-mock `assertCryptoProviderConformance` (cross-key + corrupted-blob rejection, HMAC determinism/domain-separation), satellite-local `isBlockedAddress` SSRF guard on the Vault `fetch` (kept honest by the dev-only `satellites/crypto-kms/ssrf-parity.test.mjs` vs auth-jwt), generic fail-closed provider-error mapping (no provider/key-ARN in audit). `{vault,gcp,azure}.test.mjs` + `crypto-kms.test.mjs`; adversarial review in scope §6. **Residual accepted:** live-backend (real Vault/GCP/Azure) validation is out of CI; the published tarball stays zero runtime dependency |
118
120
 
@@ -1,6 +1,6 @@
1
1
  # Haechi Shared Responsibility
2
2
 
3
- - 문서 상태: Living document (core 1.5.x 추적)
3
+ - 문서 상태: Living document (core 1.7.x 추적)
4
4
  - 작성일: 2026-06-10
5
5
 
6
6
  ## 1. 책임 매트릭스
@@ -1,6 +1,6 @@
1
1
  # Haechi Shared Responsibility
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
3
+ - Status: Living document (tracks core 1.7.x)
4
4
  - Date: 2026-06-10
5
5
 
6
6
  ## 1. Responsibility Matrix
@@ -1,6 +1,6 @@
1
1
  # Haechi Threat Model
2
2
 
3
- - 문서 상태: Living document(core 1.5.x 추적)
3
+ - 문서 상태: Living document(core 1.7.x 추적)
4
4
  - 작성일: 2026-06-10
5
5
 
6
6
  ## 1. 보호 대상
@@ -13,7 +13,7 @@ Haechi가 보호하려는 주요 자산은 다음과 같습니다.
13
13
  | Tool/resource result | MCP result, local inference response | 응답 내 PII/secret 재유출 차단 |
14
14
  | TokenVault record | tokenized PII mapping | 저장 시 암호화, reveal 기본 차단 |
15
15
  | Audit event | detection metadata, decision summary | 평문 비포함, hash chain 무결성 |
16
- | Crypto envelope | encrypted segments | canonical AAD binding, key provider 교체성 |
16
+ | Crypto envelope | encrypted segments | versioned NFKC AAD binding, 제공된 경우 freshness, key provider 교체성 |
17
17
  | Plugin manifest | custom provider/filter declaration | capability disclosure, dynamic runtime 차단 |
18
18
 
19
19
  ## 2. 신뢰 경계
@@ -54,6 +54,8 @@ Haechi가 보호하려는 주요 자산은 다음과 같습니다.
54
54
  | 인증 없는 멀티 클라이언트 접근 | 로컬 프로세스가 upstream / token round-trip 경로를 무단 사용 | 선택적 bearer auth (`auth.provider: bearer`); 없거나 잘못된 경우 → 바디 읽기 전 401; identity별 rate limit 및 model allowlist |
55
55
  | Audit tail truncation | 꼬리 audit 레코드의 무음 삭제 | 추가 전용/별도 미디어의 `audit.anchor` head-hash anchoring으로 마지막 anchor까지의 절단 탐지 (0.7) |
56
56
  | Local dev key in production | 소프트웨어 키의 운영 custody 오용 | `assertCryptoProviderConformance`를 통한 외부 `cryptoProvider` 주입; reference KMS adapter (envelope 암호화) |
57
+ | 단일 키의 GCM nonce 고갈 | 로컬 AES-256-GCM provider는 랜덤 96-bit IV를 쓰며, 한 키로 ~2^32회 암호화를 넘기면 birthday bound로 IV 충돌(GCM에 치명적 — 평문 XOR 누출 + 위조 가능) 확률이 무시할 수 없게 됨 | 로컬 provider는 **키당 2^32회 암호화에서 fail-closed**(NIST SP 800-38D §8.3) — 암호화를 거부하고 `haechi init --force` 회전을 안내. 호출 수는 kid별로 카운트되어 미리 예약한 윈도우 단위로 키 파일에 영속화되므로 재시작을 넘겨도 유지됨(과대집계는 가능, 재사용으로의 과소집계는 불가). 50%에서 1회 경고. **수용된 잔여 위험:** 읽기 전용 키 파일은 **프로세스 단위** 한도로 degrade(경고 `HAECHI_NONCE_BUDGET_NOPERSIST`)되고, 하나의 키 파일을 여러 프로세스가 공유하는 경우는 범위 밖(로컬 provider는 단일 writer 레퍼런스이며, 운영 custody는 자체 nonce 규율을 갖는 KMS 위성 사용) |
58
+ | Unicode AAD spoofing 또는 stale ciphertext replay | full-width key/value, compatibility 문자 등 시각적으로 동등한 Unicode AAD로 복호화 context를 흔들거나, retention 이후에도 stale token-vault ciphertext가 복호화될 수 있음 | 새 crypto envelope는 `v:2`, `aadEncoding:"nfkc-json-v2"`를 사용합니다. `canonicalizeCryptoAad()`가 string value와 object key를 NFKC 정규화한 뒤 정렬 canonical JSON으로 해시하며, legacy v1 envelope는 하위호환을 위해 기존 canonicalization으로 계속 복호화됩니다. 같은 object level의 NFKC key collision은 조용히 합치지 않고 fail-closed합니다. Envelope는 `expiresAt`를 가질 수 있고 local/KMS provider가 만료 envelope를 거부합니다. Token-vault ciphertext는 token `expiresAt`를 envelope에 묶어 vault retention check에 더한 방어층을 둡니다. **잔여:** streaming transform은 아직 독립 복호화 가능한 stream envelope를 만들지 않으므로 stream sequence AAD / replay cache는 후속입니다 |
57
59
  | Tampered release artifact | 변조된 tarball 설치 | npm provenance + GitHub release tarball의 sigstore attestation + `SHA256SUMS` (0.7) |
58
60
  | audit에 원시 credentials/identity 노출 | audit 로그를 통한 token 또는 subject 유출 | Token은 keyed-HMAC 해시로만 저장; identity subject/issuer는 keyed HMAC 처리; `auth_denied` 레코드에 token 미포함 |
59
61
  | token round-trip의 타 토큰 복원 | 클라이언트/요청 간 평문 복구 | detokenization은 opt-in(`detokenizeResponses`)이며 요청 스코프: 같은 요청을 보호하며 발급된 토큰만 복원 |
@@ -67,7 +69,7 @@ Haechi가 보호하려는 주요 자산은 다음과 같습니다.
67
69
  | OIDC login CSRF / authorization-code injection / open-redirect / session fixation (0.9) | 공격자가 피해자 broker 세션을 공격자 제어 로그인에 강제하거나, 탈취한 code를 주입하거나, 로그인 후 off-origin으로 redirect하거나, 사전 인지된 세션 id를 고정 | `/auth/callback`은 **state-first**: pre-auth 쿠키 바인딩된 pending record를 atomic `take()`하고 **모든 IdP egress 이전에** constant-time `state` 비교; PKCE S256 필수; callback에서 **새 세션 id 발급**(fixation 없음, pre-auth 쿠키 폐기); 로그인 후 `return_to`는 상대 경로 `returnToAllowlist`로 검증; logout은 non-GET + CSRF-header gated. 단일 IdP 기준 실질적 잔여 없음 |
68
70
  | OIDC mix-up (잘못된 IdP / 잘못된 RP) (0.9) | confused-deputy 공격으로 IdP를 바꾸거나 다른 client용으로 발급된 code/token을 재생 | issuer/`token_endpoint`/`jwks_uri`를 `/auth/login`에서 pending record에 pin; RFC 9207 `iss` 응답 파라미터가 pinned issuer와 일치해야 함; `metadata.issuer`가 설정 issuer와 string-equal해야 함; OIDC ID-token `aud`/`azp` 프로파일(`aud`는 `clientId` 포함; multi-valued `aud`는 `azp === clientId` 필요)로 cross-client 차단. multi-origin IdP는 범위 외 |
69
71
  | 토큰 엔드포인트 POST(및 Vault `fetch`)를 통한 broker SSRF — cloud metadata (0.9) | discovery와 request 사이에 `169.254.169.254`로 DNS-rebind되는 `token_endpoint`(또는 운영자 제공 `VAULT_ADDR`)가 instance-metadata 자격증명을 유출 | 모든 egress(discovery GET, 공유 verifier 경유 JWKS GET, token-exchange POST, end-session redirect, `haechi-crypto-kms` Vault `fetch`)가 **request 직전**(post-DNS) `lookup` 후 `isBlockedAddress` 재검사를 `redirect: "error"`·bounded body·timeout과 함께 수행. 운영자 신뢰 엔드포인트에 한함 |
70
- | audit/로그로의 token/secret leak (broker) (0.9) | ID/access/refresh token, `client_secret`, `code`, `state`, `nonce`, raw `sub`가 audit 로그나 client 응답에 기록됨 | broker는 모든 audit 이벤트를 자체 allowlist로 projection해 `subjectHash`/`issuerHash`/`sessionIdHash`(keyed-HMAC) + `provider`/`reasonCode`/timestamp만 방출; core `FORBIDDEN_KEYS`를 broker token/claim key까지 확장; access token은 **폐기**(저장·사용 안 함). 실질적 잔여 없음 |
72
+ | audit/로그로의 token/secret leak (broker) (0.9) | ID/access/refresh token, `client_secret`, `code`, `state`, `nonce`, raw `sub`가 audit 로그나 client 응답에 기록됨 | broker는 모든 audit 이벤트를 자체 allowlist로 projection해 `subjectHash`/`issuerHash`/`sessionIdHash`(keyed-HMAC) + `provider`/`reasonCode`/timestamp만 방출; core `FORBIDDEN_KEYS`를 broker token/claim key까지 확장; access token은 **폐기**(저장·사용 안 함). auth-oidc 0.2.0 opt-in refresh에서 `refresh_token`은 AEAD 봉투(도메인 분리 AAD)로만 저장되고, pinned token-endpoint refresh grant를 위해서만 복호화되며, audit/로그에 기록되거나 client에 반환되지 않습니다. 실질적 잔여 없음 |
71
73
  | KMS backend egress (Vault HTTP, GCP/Azure SDK) (0.9) | `haechi-crypto-kms` Vault/GCP/Azure backend가 key material이나 provider/key-path 상세를 유출하거나 의도치 않은 엔드포인트에 도달 | optional-peer + injected-client 모델과 **faithful-mock conformance**(cross-key·corrupted-blob 거부, HMAC determinism/domain-separation); Vault `fetch`는 위 satellite-local SSRF 가드 수행; 모든 backend는 provider 오류를 generic fail-closed 오류로 매핑하고 provider/key-ARN 상세를 audit에 기록하지 않음. live-backend 검증은 CI 외부 |
72
74
  | 동적 로딩된 악의적/침해된 signed plugin (1.0) | signed `authProvider` plugin이 worker sandbox에 로딩된 뒤 실행 중 host를 악용 | `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})`에 대한 Ed25519 서명, **trust-anchor-only** 키 해석(`signerKeyId`가 allowlist된 anchor가 아니면 verify 이전 거부; 알고리즘은 Ed25519로 고정), pin + `pluginId`별 version-floor + revocation denylist(`revokedSignerKeyIds`/`revokedEntrySha256`) + validity-window 집행, `assertAuthProviderConformance` 정합성 게이트, `node:worker_threads` memory/crash 격리 + per-call timeout-terminate, 전체 lifecycle audit(`plugin.load.*`/`authenticate.deny`/`worker.terminated`). 전체 게이트는 매 respawn마다 재실행. **수용된 잔여:** signed plugin 자신의 `fs`/`fetch`/`process.env`는 차단되지 않으며(`networkEgress: false`는 선언일 뿐 1.0에서 집행 통제 아님) 정당하게 받은 credential을 exfiltrate할 수 있음 — 오직 signing/vetting 신뢰 모델로만 통제됨. **1.1이 새 opt-in `process-isolated` 런타임에 대해 이 잔여를 닫음**(다음 행, P1-SEC-027); `worker_threads`(1.0) 모드는 불변이며 이 수용된 잔여를 유지 |
73
75
  | plugin으로의 PII/secret leak (1.0) | request body·crypto 키·token vault·raw claim이 worker 경계를 넘어 유출 | host는 worker에 **credential slice만** 전달(`Authorization` 헤더 / bearer token — request body 절대 안 보냄, crypto 키 절대 안 보냄); wire는 MessagePort 위 평문 JSON 문자열; **null-prototype, own-key-allowlist claims sanitizer**가 `__proto__`/`constructor`/`prototype`을 제거하고 크기를 bound한 뒤 **host**가 `buildExternalIdentity`로 keyed-HMAC identity를 구성(HMAC 키는 worker에 들어가지 않음). **수용된 잔여:** auth plugin이 정당하게 검증하는 credential은 그 plugin에 보임(위 행 참조) |
@@ -95,10 +97,10 @@ Haechi는 다음을 보장하지 않습니다.
95
97
  - URL query string 내 민감값 검사 (JSON body만 검사)
96
98
  - 마지막 anchor 이후의 audit tail truncation — `audit.anchor`(0.7)는 anchor가 추가 전용/별도 미디어에 있을 때 마지막 anchor까지의 레코드 삭제를 탐지합니다. 마지막 anchor 이후 기록된 레코드와 동일 파일시스템 anchor는 대상에서 제외됩니다
97
99
  - JSON-RPC batch 메시지 처리 (MCP stdio filter는 batch를 fail-closed로 거부)
98
- - `haechi-auth-oidc`의 multi-origin / CDN-fronted IdP(issuer host `token_endpoint`/`jwks_uri` host) — single-origin만 지원, `haechi-auth-jwt`와 동일 제약 (0.9)
99
- - refresh-token rotation / silent renewal / 장수명 broker 세션0.9 세션은 absolute-TTL + idle-timeout만; `offline_access`는 제거되고 access token은 폐기 (0.9)
100
+ - ~~`haechi-auth-oidc`의 multi-origin / CDN-fronted IdP~~ — **auth-jwt 0.3.0 / auth-oidc 0.2.0부터 지원**: 운영자-핀 `trustedEndpointHosts` allowlist를 통해 지원됩니다(엔드포인트/JWKS 호스트는 issuer 호스트와 같거나 운영자 allowlist에 포함될 때만 허용; allowlist는 설정 전용으로 discovery에서 유도되지 않으며 `https`/`isBlockedAddress`/`metadata.issuer`/RFC 9207 가드는 모두 무조건 실행). 남은 제외: 운영자가 선언한 호스트 집합만 신뢰합니다 Haechi는 discovery 문서가 주장하는 임의 호스트를 절대 신뢰하지 않습니다.
101
+ - ~~refresh-token rotation / silent renewal~~ **auth-oidc 0.2.0부터 지원**: opt-in `enableRefresh`(기본 off)를 통해 지원됩니다 refresh token은 AEAD 봉투로만 저장되고(평문/audit 없음), silent renewal은 하드 `refreshMaxLifetimeSeconds` 상한으로 제한되며, 갱신된 ID token은 완전 재검증 + subject-pin되고, refresh 실패는 fail-closed입니다. 여전히 범위 외: 무제한 세션(하드 상한이 항상 총 수명을 제한).
100
102
  - Dashboard write action(reveal, purge, policy edit) — `haechi-dashboard`는 읽기 전용으로 `POST`/`DELETE` surface 없음; mutation은 reveal governance 하의 CLI에 유지 (0.9)
101
- - OIDC broker의 `at_hash`/`c_hash` 검증 — broker가 access token을 사용하지 않으므로 정확히 범위 외 (0.9)
103
+ - OIDC broker의 `at_hash`/`c_hash` 검증 — broker가 access token을 사용하지 않으므로 정확히 범위 외 (0.9; auth-oidc 0.2.0 refresh에서도 여전히 유효 — 갱신은 access token이 아닌 `refresh_token`을 소비)
102
104
  - **`worker_threads`(1.0)** 모드에서 악의적 signed plugin에 대한 capability *집행*(`fs`/`net`/`process.env` 차단) — worker 격리는 memory/crash 격리 + data-minimization만 제공. **1.1의 opt-in `process-isolated` 런타임에서 집행됨**(`--permission` 하 자식 프로세스, 부여 0, `--allow-net` Node에서 — P1-SEC-027)
103
105
  - `worker_threads`(1.0) signed plugin이 정당하게 받는 credential의 봉쇄 — de-facto network egress가 있어 exfiltrate 가능; 오직 signing/vetting 신뢰 모델로만 통제. **1.1 `process-isolated`에서 봉쇄됨**(net+stdio+fs 거부, `--allow-net` Node)
104
106
  - `--allow-net` **없는** Node에서 `process-isolated` plugin의 네트워크 봉쇄 — 런타임이 거기서 **fail closed**(생성 거부)하며 미봉쇄로 실행하지 않음; `worker_threads` 또는 `--allow-net` Node 사용 (1.1)
@@ -1,6 +1,6 @@
1
1
  # Haechi Threat Model
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
3
+ - Status: Living document (tracks core 1.7.x)
4
4
  - Date: 2026-06-10
5
5
 
6
6
  ## 1. Assets Under Protection
@@ -13,7 +13,7 @@ The primary assets Haechi protects are:
13
13
  | Tool/resource result | MCP result, local inference response | Prevent re-leakage of PII/secrets in responses |
14
14
  | TokenVault record | tokenized PII mapping | Encrypted at rest, reveal blocked by default |
15
15
  | Audit event | detection metadata, decision summary | No plaintext content, hash chain integrity |
16
- | Crypto envelope | encrypted segments | Canonical AAD binding, swappable key provider |
16
+ | Crypto envelope | encrypted segments | Versioned NFKC AAD binding, freshness where supplied, swappable key provider |
17
17
  | Plugin manifest | custom provider/filter declaration | Capability disclosure, dynamic runtime blocked |
18
18
 
19
19
  ## 2. Trust Boundaries
@@ -54,6 +54,8 @@ The primary assets Haechi protects are:
54
54
  | Unauthenticated multi-client access | Any local process uses the upstream / token round-trip | Optional bearer auth (`auth.provider: bearer`); missing/invalid → 401 before body read; per-identity rate limit and model allowlist |
55
55
  | Audit tail truncation | Silent deletion of trailing audit records | `audit.anchor` head-hash anchoring on append-only/separate media detects truncation back to the last anchor (0.7) |
56
56
  | Local dev key in production | Software key misused as production custody | External `cryptoProvider` injection with `assertCryptoProviderConformance`; reference KMS adapter (envelope encryption) |
57
+ | GCM nonce exhaustion under one key | The local AES-256-GCM provider uses random 96-bit IVs; past ~2^32 encryptions under one key the birthday bound makes an IV collision (catastrophic for GCM — leaks plaintext XOR + enables forgery) non-negligible | The local provider **fails closed at 2^32 encryptions per key** (NIST SP 800-38D §8.3) — it refuses to encrypt and instructs `haechi init --force` to rotate. Invocations are counted per-kid, persisted to the key file in pre-reserved windows so the count survives restarts (over-counts, never under-counts into reuse); a one-time warning fires at 50%. **Accepted residual:** a read-only key file degrades to a per-PROCESS limit (warned, `HAECHI_NONCE_BUDGET_NOPERSIST`) and multiple processes sharing one key file are out of scope (the local provider is the single-writer reference; production custody uses a KMS satellite that owns its own nonce discipline) |
58
+ | Unicode AAD spoofing or stale ciphertext replay | A caller uses visually equivalent Unicode in AAD (full-width keys/values, compatibility characters) to destabilize decryption context, or a stale token-vault ciphertext remains decryptable after retention | New crypto envelopes are `v:2` with `aadEncoding:"nfkc-json-v2"`: `canonicalizeCryptoAad()` NFKC-normalizes string values and object keys before sorted canonical JSON hashing, while legacy v1 envelopes keep the old canonicalization for backward decrypt. NFKC key collisions at one object level fail closed. Envelopes may carry `expiresAt`; local and KMS providers reject expired envelopes. Token-vault ciphertext now binds its token `expiresAt` into the envelope as defense-in-depth. **Residual:** stream sequence AAD / replay cache is still deferred because streaming transforms do not create independently decryptable stream envelopes |
57
59
  | Tampered release artifact | Modified tarball installed | npm provenance + sigstore attestation of the GitHub release tarball + `SHA256SUMS` (0.7) |
58
60
  | Raw credentials/identity in audit | Token or subject leak through the audit log | Tokens stored only as keyed-HMAC hashes; identity subject/issuer are keyed HMAC; `auth_denied` records no token |
59
61
  | Token round-trip restoring foreign tokens | Cross-client/request plaintext recovery | Detokenization is opt-in (`detokenizeResponses`) and request-scoped: only tokens issued while protecting the same request are restored |
@@ -67,7 +69,7 @@ The primary assets Haechi protects are:
67
69
  | OIDC login CSRF / authorization-code injection / open-redirect / session fixation (0.9) | An attacker forces a victim's broker session onto an attacker-controlled login, injects a stolen code, redirects post-login off-origin, or fixes a pre-known session id | `/auth/callback` is **state-first**: atomic `take()` of a pre-auth-cookie-bound pending record and constant-time `state` compare **before any IdP egress**; PKCE S256 mandatory; a **fresh session id minted at callback** (no fixation, pre-auth cookie discarded); post-login `return_to` validated against a relative-path `returnToAllowlist`; logout is non-GET + CSRF-header gated. None material for a single IdP |
68
70
  | OIDC mix-up (wrong IdP / wrong RP) (0.9) | A confused-deputy attack swaps the IdP or replays a code/token minted for a different client | Issuer/`token_endpoint`/`jwks_uri` are pinned into the pending record at `/auth/login`; the RFC 9207 `iss` response param must equal the pinned issuer; `metadata.issuer` must string-equal the configured issuer; and the OIDC ID-token `aud`/`azp` profile (`aud` must contain `clientId`; multi-valued `aud` requires `azp === clientId`) closes cross-client. Multi-origin IdP out of scope |
69
71
  | Broker SSRF to cloud metadata via the token-endpoint POST (and Vault `fetch`) (0.9) | A `token_endpoint` (or operator-supplied `VAULT_ADDR`) that DNS-rebinds to `169.254.169.254` between discovery and request exfiltrates instance-metadata credentials | Every egress (discovery GET, JWKS GET via the shared verifier, token-exchange POST, end-session redirect, and the `haechi-crypto-kms` Vault `fetch`) runs a `lookup`-then-`isBlockedAddress` re-check **immediately before the request** (post-DNS), with `redirect: "error"`, a bounded response body, and a timeout. Operator-trusted endpoints only |
70
- | Token/secret leak into audit/logs (broker) (0.9) | An ID/access/refresh token, `client_secret`, `code`, `state`, `nonce`, or raw `sub` is written to the audit log or a client response | The broker projects every audit event through its own allowlist and emits only `subjectHash`/`issuerHash`/`sessionIdHash` (keyed-HMAC) + `provider`/`reasonCode`/timestamp; core's `FORBIDDEN_KEYS` is extended to cover the broker token/claim keys; the access token is **discarded** (never stored or used). None material |
72
+ | Token/secret leak into audit/logs (broker) (0.9) | An ID/access/refresh token, `client_secret`, `code`, `state`, `nonce`, or raw `sub` is written to the audit log or a client response | The broker projects every audit event through its own allowlist and emits only `subjectHash`/`issuerHash`/`sessionIdHash` (keyed-HMAC) + `provider`/`reasonCode`/timestamp; core's `FORBIDDEN_KEYS` is extended to cover the broker token/claim keys; the access token is **discarded** (never stored or used). Under auth-oidc 0.2.0 opt-in refresh, the `refresh_token` is stored ONLY as an AEAD envelope (domain-separated AAD), decrypted only for the pinned token-endpoint refresh grant, and never written to audit/logs or returned to a client. None material |
71
73
  | KMS backend egress (Vault HTTP, GCP/Azure SDK) (0.9) | A `haechi-crypto-kms` Vault/GCP/Azure backend leaks key material or provider/key-path detail, or reaches an unintended endpoint | Optional-peer + injected-client model with **faithful-mock conformance** (cross-key + corrupted-blob rejection, HMAC determinism/domain-separation); the Vault `fetch` runs the satellite-local SSRF guard above; all backends map provider errors to a generic fail-closed error and never write provider/key-ARN detail to audit. Live-backend validation is out of CI |
72
74
  | Malicious/compromised signed plugin loaded dynamically (1.0) | A signed `authProvider` plugin is loaded into the worker sandbox and abuses the host once running | Ed25519 signature over `canonicalize({pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter})`, **trust-anchor-only** key resolution (refuse before verify if `signerKeyId` is not an allowlisted anchor; algorithm pinned to Ed25519), pin + per-`pluginId` version-floor + revocation denylist (`revokedSignerKeyIds`/`revokedEntrySha256`) + validity-window enforcement, `assertAuthProviderConformance` correctness gate, `node:worker_threads` memory/crash isolation + per-call timeout-terminate, and full lifecycle audit (`plugin.load.*`/`authenticate.deny`/`worker.terminated`). The full gate re-runs on every respawn. **Accepted residual:** a signed plugin's own `fs`/`fetch`/`process.env` is NOT blocked — `networkEgress: false` is a declaration, not an enforced control in 1.0 — and the plugin CAN exfiltrate the credential it legitimately receives; this is gated only by the signing/vetting trust model. **1.1 closes this residual for the new opt-in `process-isolated` runtime** (next row, P1-SEC-027); the `worker_threads` (1.0) mode is unchanged and keeps this accepted residual |
73
75
  | PII/secret leak to a plugin (1.0) | The request body, crypto key, token vault, or a raw claim leaks across the worker boundary | The host sends the worker **only the credential slice** (the `Authorization` header / bearer token — never the request body, never the crypto key); the wire is a plain JSON string over the MessagePort; a **null-prototype, own-key-allowlist claims sanitizer** strips `__proto__`/`constructor`/`prototype` and bounds size before the **host** builds the keyed-HMAC identity via `buildExternalIdentity` (the HMAC key never enters the worker). **Accepted residual:** the credential the auth plugin legitimately validates is visible to it (see the row above) |
@@ -95,10 +97,10 @@ Haechi does not guarantee:
95
97
  - Detection of sensitive values in URL query strings (JSON body only)
96
98
  - Audit tail truncation beyond the last anchor — `audit.anchor` (0.7) detects deletion of records back to the last anchor when the anchor is on append-only/separate media; records written after the last anchor, and same-filesystem anchors, are not covered
97
99
  - JSON-RPC batch message processing (the MCP stdio filter rejects batches fail-closed)
98
- - Multi-origin / CDN-fronted IdP for `haechi-auth-oidc` (issuer host `token_endpoint`/`jwks_uri` host) single-origin only, same constraint as `haechi-auth-jwt` (0.9)
99
- - Refresh-token rotation / silent renewal / long-lived broker sessions 0.9 sessions are absolute-TTL + idle-timeout only; `offline_access` is stripped and the access token is discarded (0.9)
100
+ - ~~Multi-origin / CDN-fronted IdP for `haechi-auth-oidc`~~ — **supported since auth-jwt 0.3.0 / auth-oidc 0.2.0** via an operator-pinned `trustedEndpointHosts` allowlist (an endpoint/JWKS host is accepted iff it equals the issuer host OR is operator-allowlisted; the allowlist is config-only, never discovery-derived, and the `https`/`isBlockedAddress`/`metadata.issuer`/RFC 9207 guards still run unconditionally). The remaining exclusion: only an operator-declared host set is trusted — Haechi never trusts an arbitrary host a discovery document claims.
101
+ - ~~Refresh-token rotation / silent renewal~~ **supported since auth-oidc 0.2.0** via opt-in `enableRefresh` (default off): the refresh token is stored only as an AEAD envelope (never plaintext/audit), silent renewal is bounded by a hard `refreshMaxLifetimeSeconds` ceiling, the renewed ID token is fully re-verified + subject-pinned, and a refresh failure fails closed. Still out of scope: unbounded sessions (the hard ceiling always caps total lifetime).
100
102
  - Dashboard write actions (reveal, purge, policy edits) — `haechi-dashboard` is read-only with no `POST`/`DELETE` surface; mutation stays in the CLI under reveal governance (0.9)
101
- - `at_hash`/`c_hash` validation in the OIDC broker — out of scope precisely because the broker never uses the access token (0.9)
103
+ - `at_hash`/`c_hash` validation in the OIDC broker — out of scope precisely because the broker never uses the access token (0.9; still valid under auth-oidc 0.2.0 refresh — renewal consumes the `refresh_token`, never the access token)
102
104
  - Capability *enforcement* against a malicious signed plugin (blocking `fs`/`net`/`process.env`) in the **`worker_threads` (1.0)** mode — worker isolation is memory/crash isolation + data-minimization only. **Enforced in 1.1 for the opt-in `process-isolated` runtime** (child process under `--permission`, zero grants, on a `--allow-net` Node — P1-SEC-027)
103
105
  - Containment of the credential a `worker_threads` (1.0) signed plugin legitimately receives — it has de-facto network egress and can exfiltrate it; gated only by the signing/vetting trust model. **Contained in 1.1 under `process-isolated`** (net+stdio+fs denial) on a `--allow-net` Node
104
106
  - Network containment of a `process-isolated` plugin on a Node **without `--allow-net`** — the runtime **fails closed** there (refuses to construct) rather than run uncontained; use `worker_threads` or a `--allow-net` Node (1.1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haechi",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic — a stable, zero-dependency security gateway.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -80,6 +80,7 @@
80
80
  "bench:throughput": "node scripts/bench-throughput.mjs",
81
81
  "scan:detection": "node scripts/bench-detection.mjs --gate",
82
82
  "check:peer-ranges": "node scripts/check-satellite-peer-ranges.mjs",
83
+ "gate:security": "node scripts/security-gate.mjs",
83
84
  "release:preflight": "node scripts/release-preflight.mjs && node scripts/check-satellite-peer-ranges.mjs",
84
85
  "release:preflight:npm": "node scripts/release-preflight.mjs --require-npm-auth && node scripts/check-satellite-peer-ranges.mjs",
85
86
  "haechi": "node packages/cli/bin/haechi.mjs",
@@ -8,7 +8,7 @@ import { signPolicyBundleFile, verifyPolicyBundleFile } from "../../policy-bundl
8
8
  import { PluginLoadError, signPluginManifest, validatePluginManifestFile, verifySignedPlugin } from "../../plugin/index.mjs";
9
9
  import { runMcpStdioFilter, wrapMcpChild } from "../../mcp-stdio/index.mjs";
10
10
  import { addToken, listTokens, revokeToken } from "../../auth/index.mjs";
11
- import { createLocalCryptoProvider } from "../../crypto/index.mjs";
11
+ import { createLocalCryptoProvider, readNonceBudget } from "../../crypto/index.mjs";
12
12
  import { spawn } from "node:child_process";
13
13
  import { DEFAULT_CONFIG_PATH, createRuntime, isValidPort, loadConfig, writeDefaultConfig } from "../runtime.mjs";
14
14
 
@@ -227,6 +227,34 @@ async function statusCommand(argv) {
227
227
  warnings.push(`key file ${config.keys.keyFile} does not exist; run haechi init`);
228
228
  }
229
229
 
230
+ // Surface the local AES-GCM nonce budget so an operator can rotate BEFORE the
231
+ // fail-closed limit (the in-band signal; the runtime also warns at 50% on
232
+ // stderr). Only the local provider has a software budget — an external
233
+ // cryptoProvider (KMS) owns its own nonce discipline.
234
+ keys.nonceBudget = null;
235
+ if (keys.exists && config.keys.provider === "local") {
236
+ try {
237
+ const budget = await readNonceBudget(config.keys.keyFile);
238
+ const usedPercent = Math.round(budget.usedFraction * 1000) / 10;
239
+ keys.nonceBudget = {
240
+ kid: budget.kid,
241
+ used: budget.used,
242
+ limit: budget.limit,
243
+ remaining: budget.remaining,
244
+ usedPercent,
245
+ exhausted: budget.exhausted
246
+ };
247
+ if (budget.exhausted) {
248
+ warnings.push(`crypto key ${budget.kid} has EXHAUSTED its safe encryption budget (${budget.limit}); encryption is failing closed — rotate now with 'haechi init --force'`);
249
+ } else if (budget.used >= budget.warnThreshold) {
250
+ warnings.push(`crypto key ${budget.kid} has used ${usedPercent}% of its safe encryption budget; plan a rotation ('haechi init --force')`);
251
+ }
252
+ } catch {
253
+ // A malformed/active-key-less file is already surfaced by init/encrypt;
254
+ // do not double-warn from the status read.
255
+ }
256
+ }
257
+
230
258
  const anchorEnabled = config.audit.anchor.mode === "file";
231
259
  const audit = {
232
260
  path: config.audit.path,
@@ -983,7 +1011,7 @@ const COMMAND_HELP = {
983
1011
  status: {
984
1012
  usage: "haechi status [--config haechi.config.json]",
985
1013
  summary: "Show what is and is not protected under the current config.",
986
- detail: "Prints effective policy mode, response/streaming protection, target, token vault governance, key file permissions, audit chain status, and a consolidated warnings list."
1014
+ detail: "Prints effective policy mode, response/streaming protection, target, token vault governance, key file permissions, the local key's AES-GCM nonce budget (used %), audit chain status, and a consolidated warnings list."
987
1015
  },
988
1016
  proxy: {
989
1017
  usage: `haechi proxy [--config haechi.config.json] [--host 127.0.0.1] [--port ${DEFAULT_PROXY_PORT}] [--allow-remote-bind]`,
@@ -3,6 +3,23 @@ import { dirname } from "node:path";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
 
5
5
  const ALG = "AES-256-GCM";
6
+ export const CRYPTO_AAD_ENCODING_V2 = "nfkc-json-v2";
7
+
8
+ // Random 96-bit GCM IVs are only safe up to a bounded number of invocations per
9
+ // key: by the birthday bound the IV-collision probability stays negligible only
10
+ // below ~2^32 encryptions under ONE key (NIST SP 800-38D §8.3 caps random-IV
11
+ // invocations at 2^32). A nonce collision under AES-GCM is catastrophic (it
12
+ // leaks the XOR of the two plaintexts and enables forgery), so the local
13
+ // provider FAILS CLOSED at the limit rather than risk reuse — the operator must
14
+ // rotate (`haechi init --force`). The count is persisted per-kid in the key file
15
+ // (see reserveNonceWindow) so it survives restarts; rotation resets it.
16
+ const MAX_ENCRYPTIONS_PER_KEY = 2 ** 32;
17
+ const NONCE_WARN_THRESHOLD = 2 ** 31; // warn once at 50% of the budget
18
+ // Invocations are reserved a window at a time and the window is persisted BEFORE
19
+ // it is consumed, so a crash/restart can only OVER-count (skip an unused tail of
20
+ // a window) — never under-count into reuse. A large window keeps the per-encrypt
21
+ // overhead at ~one key-file write per million encryptions.
22
+ const NONCE_RESERVE_WINDOW = 2 ** 20;
6
23
 
7
24
  // Single source of truth for parsing + validating an on-disk local key file.
8
25
  // Both the provider's loadKeys() and initLocalKeyFile() (existing-file path)
@@ -50,6 +67,84 @@ export function createLocalCryptoProvider({ keyFile }) {
50
67
  return cachedKeys;
51
68
  }
52
69
 
70
+ // Per-process view of the active key's reserved nonce window:
71
+ // { kid, base, granted, used } where base is the key file's `usage` at the
72
+ // window start and (base + used) is the next invocation index. null until the
73
+ // first encrypt reserves a window.
74
+ let reservation = null;
75
+ let nonceWarned = false;
76
+ // Set if the key file cannot be written (e.g. read-only mount): the budget
77
+ // then degrades to PER-PROCESS enforcement and counts forward in memory.
78
+ let persistDisabled = false;
79
+
80
+ // Reserve the next window of invocations for `activeKid` by advancing the
81
+ // persisted `usage` BEFORE consuming it (fail-closed at the per-key limit).
82
+ // Read-modify-write the key file in place, preserving every other field. The
83
+ // local provider is the single-writer reference provider; concurrent writers
84
+ // sharing one key file are out of scope (production custody uses a KMS
85
+ // satellite) — a documented residual, not silent reuse, since reuse needs an
86
+ // actual IV collision and over-counting only wastes budget. If the key file is
87
+ // not writable, fall back to per-process counting (warned once) rather than
88
+ // breaking encryption on a hardened read-only mount.
89
+ async function reserveNonceWindow(activeKid) {
90
+ let current;
91
+ let raw = null;
92
+ let entry = null;
93
+ if (persistDisabled && reservation && reservation.kid === activeKid) {
94
+ // No persistence: continue counting forward from the last window in memory.
95
+ current = reservation.base + reservation.granted;
96
+ } else {
97
+ raw = JSON.parse(await readFile(keyFile, "utf8"));
98
+ entry = raw.keys?.find((k) => k.kid === activeKid);
99
+ if (!entry) {
100
+ throw new Error(`Active key ${activeKid} not found while reserving nonce budget`);
101
+ }
102
+ current = entry.usage ?? 0;
103
+ }
104
+ if (current >= MAX_ENCRYPTIONS_PER_KEY) {
105
+ throw new Error(
106
+ `local AES-256-GCM key ${activeKid} reached its safe encryption limit (${MAX_ENCRYPTIONS_PER_KEY}); rotate the key with 'haechi init --force' before encrypting more`
107
+ );
108
+ }
109
+ const granted = Math.min(NONCE_RESERVE_WINDOW, MAX_ENCRYPTIONS_PER_KEY - current);
110
+ if (!persistDisabled && entry) {
111
+ try {
112
+ entry.usage = current + granted;
113
+ await writeFile(keyFile, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600 });
114
+ } catch (error) {
115
+ persistDisabled = true;
116
+ process.emitWarning(
117
+ `local AES-256-GCM nonce budget for key ${activeKid} cannot be persisted (${error?.code ?? error?.message}); enforcing the PER-PROCESS limit only — cross-restart protection is OFF, so rotate keys on a schedule`,
118
+ { code: "HAECHI_NONCE_BUDGET_NOPERSIST" }
119
+ );
120
+ }
121
+ }
122
+ reservation = { kid: activeKid, base: current, granted, used: 0 };
123
+ }
124
+
125
+ // Account one GCM encryption against the active key's nonce budget, reserving
126
+ // a fresh window when the current one is exhausted. Returns nothing; throws
127
+ // fail-closed at the limit. MUST be called before generating the IV.
128
+ async function consumeNonceBudget(activeKid) {
129
+ if (!reservation || reservation.kid !== activeKid || reservation.used >= reservation.granted) {
130
+ await reserveNonceWindow(activeKid);
131
+ }
132
+ const index = reservation.base + reservation.used; // 0-based invocation count
133
+ if (index >= MAX_ENCRYPTIONS_PER_KEY) {
134
+ throw new Error(
135
+ `local AES-256-GCM key ${activeKid} reached its safe encryption limit (${MAX_ENCRYPTIONS_PER_KEY}); rotate the key with 'haechi init --force' before encrypting more`
136
+ );
137
+ }
138
+ reservation.used += 1;
139
+ if (!nonceWarned && index >= NONCE_WARN_THRESHOLD) {
140
+ nonceWarned = true;
141
+ process.emitWarning(
142
+ `local AES-256-GCM key ${activeKid} has used ${index} of ${MAX_ENCRYPTIONS_PER_KEY} safe encryptions; plan a key rotation ('haechi init --force')`,
143
+ { code: "HAECHI_NONCE_BUDGET" }
144
+ );
145
+ }
146
+ }
147
+
53
148
  return {
54
149
  id: "haechi.crypto.local-aes-gcm",
55
150
  version: "0.1.0",
@@ -57,23 +152,32 @@ export function createLocalCryptoProvider({ keyFile }) {
57
152
  readsPlaintext: true,
58
153
  networkEgress: false
59
154
  },
60
- async encrypt({ plaintext, aad }) {
155
+ async encrypt({ plaintext, aad, expiresAt = null }) {
61
156
  const { active: { kid, key } } = await loadKeys();
157
+ // Fail closed at the per-key random-IV invocation limit BEFORE choosing an
158
+ // IV, so we never generate a nonce past the safe budget (NIST SP 800-38D).
159
+ await consumeNonceBudget(kid);
62
160
  const iv = randomBytes(12);
63
161
  const cipher = createCipheriv("aes-256-gcm", key, iv);
64
- const aadBytes = Buffer.from(canonicalize(aad), "utf8");
162
+ const aadBytes = Buffer.from(canonicalizeCryptoAad(aad), "utf8");
65
163
  cipher.setAAD(aadBytes);
66
164
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
67
165
  const tag = cipher.getAuthTag();
68
- return {
69
- v: 1,
166
+ const envelope = {
167
+ v: 2,
70
168
  alg: ALG,
71
169
  kid,
72
170
  iv: iv.toString("base64url"),
73
171
  ct: ciphertext.toString("base64url"),
74
172
  tag: tag.toString("base64url"),
75
- aadHash: sha256(aadBytes)
173
+ aadHash: sha256(aadBytes),
174
+ aadEncoding: CRYPTO_AAD_ENCODING_V2,
175
+ createdAt: new Date().toISOString()
76
176
  };
177
+ if (expiresAt !== null && expiresAt !== undefined) {
178
+ envelope.expiresAt = normalizeEnvelopeExpiry(expiresAt);
179
+ }
180
+ return envelope;
77
181
  },
78
182
  // Keyed hash over a domain-separated derived key. The raw stored key is an
79
183
  // AES-256-GCM key and must never be used for HMAC directly; every use case
@@ -92,12 +196,13 @@ export function createLocalCryptoProvider({ keyFile }) {
92
196
  if (envelope.alg && envelope.alg !== ALG) {
93
197
  throw new Error(`Unsupported local crypto algorithm: ${envelope.alg}`);
94
198
  }
199
+ assertEnvelopeFresh(envelope);
95
200
  const selected = envelope.kid ? byKid.get(envelope.kid) : active;
96
201
  if (!selected) {
97
202
  throw new Error(`Unknown key id in envelope: ${envelope.kid}`);
98
203
  }
99
204
  const { key } = selected;
100
- const aadBytes = Buffer.from(canonicalize(aad), "utf8");
205
+ const aadBytes = Buffer.from(canonicalizeAadForEnvelope(envelope, aad), "utf8");
101
206
  if (envelope.aadHash && envelope.aadHash !== sha256(aadBytes)) {
102
207
  throw new Error("AAD hash mismatch");
103
208
  }
@@ -268,6 +373,28 @@ export async function assertCryptoProviderConformance(provider, { requireHmac =
268
373
  return { ok: true };
269
374
  }
270
375
 
376
+ // Read the active key's nonce-budget status for operator visibility (e.g.
377
+ // `haechi status`). `used` reflects the PERSISTED reservation (advanced a window
378
+ // at a time), so it is a slight SAFE over-estimate of actual encryptions — never
379
+ // an under-estimate. Throws if the file has no usable active key.
380
+ export async function readNonceBudget(keyFile) {
381
+ const raw = JSON.parse(await readFile(keyFile, "utf8"));
382
+ const activeEntry = raw.keys?.find((key) => key.status === "active") ?? raw.keys?.[0];
383
+ if (!activeEntry) {
384
+ throw new Error("No active key found while reading nonce budget");
385
+ }
386
+ const used = activeEntry.usage ?? 0;
387
+ return {
388
+ kid: activeEntry.kid,
389
+ used,
390
+ limit: MAX_ENCRYPTIONS_PER_KEY,
391
+ remaining: Math.max(0, MAX_ENCRYPTIONS_PER_KEY - used),
392
+ usedFraction: used / MAX_ENCRYPTIONS_PER_KEY,
393
+ warnThreshold: NONCE_WARN_THRESHOLD,
394
+ exhausted: used >= MAX_ENCRYPTIONS_PER_KEY
395
+ };
396
+ }
397
+
271
398
  export function canonicalize(value) {
272
399
  if (Array.isArray(value)) {
273
400
  return `[${value.map((item) => canonicalize(item)).join(",")}]`;
@@ -278,6 +405,61 @@ export function canonicalize(value) {
278
405
  return JSON.stringify(value);
279
406
  }
280
407
 
408
+ export function canonicalizeCryptoAad(value) {
409
+ if (Array.isArray(value)) {
410
+ return `[${value.map((item) => canonicalizeCryptoAad(item)).join(",")}]`;
411
+ }
412
+ if (value && typeof value === "object") {
413
+ const seen = new Set();
414
+ const entries = [];
415
+ for (const key of Object.keys(value)) {
416
+ const normalizedKey = key.normalize("NFKC");
417
+ if (seen.has(normalizedKey)) {
418
+ throw new Error(`crypto AAD NFKC key collision: ${JSON.stringify(normalizedKey)}`);
419
+ }
420
+ seen.add(normalizedKey);
421
+ entries.push(`${JSON.stringify(normalizedKey)}:${canonicalizeCryptoAad(value[key])}`);
422
+ }
423
+ return `{${entries.sort().join(",")}}`;
424
+ }
425
+ if (typeof value === "string") {
426
+ return JSON.stringify(value.normalize("NFKC"));
427
+ }
428
+ return JSON.stringify(value);
429
+ }
430
+
431
+ function canonicalizeAadForEnvelope(envelope, aad) {
432
+ if (envelope.aadEncoding && envelope.aadEncoding !== CRYPTO_AAD_ENCODING_V2) {
433
+ throw new Error(`Unsupported crypto AAD encoding: ${envelope.aadEncoding}`);
434
+ }
435
+ if (envelope.aadEncoding === CRYPTO_AAD_ENCODING_V2 || envelope.v === 2) {
436
+ return canonicalizeCryptoAad(aad);
437
+ }
438
+ return canonicalize(aad);
439
+ }
440
+
441
+ function normalizeEnvelopeExpiry(expiresAt) {
442
+ const iso = expiresAt instanceof Date ? expiresAt.toISOString() : String(expiresAt);
443
+ const ts = Date.parse(iso);
444
+ if (!Number.isFinite(ts)) {
445
+ throw new Error("crypto envelope expiresAt must be a valid timestamp");
446
+ }
447
+ return new Date(ts).toISOString();
448
+ }
449
+
450
+ function assertEnvelopeFresh(envelope) {
451
+ if (!envelope.expiresAt) {
452
+ return;
453
+ }
454
+ const expiresAt = Date.parse(envelope.expiresAt);
455
+ if (!Number.isFinite(expiresAt)) {
456
+ throw new Error("crypto envelope expiresAt is invalid");
457
+ }
458
+ if (Date.now() >= expiresAt) {
459
+ throw new Error("Crypto envelope expired");
460
+ }
461
+ }
462
+
281
463
  function sha256(value) {
282
464
  return createHash("sha256").update(value).digest("base64url");
283
465
  }
@@ -229,6 +229,7 @@ export function createTokenVault({
229
229
  }
230
230
 
231
231
  const createdAt = new Date();
232
+ const expiresAt = addDays(createdAt, retentionDays).toISOString();
232
233
  const aad = {
233
234
  purpose: "token-vault",
234
235
  token,
@@ -238,9 +239,9 @@ export function createTokenVault({
238
239
  view.set(token, {
239
240
  type,
240
241
  createdAt: createdAt.toISOString(),
241
- expiresAt: addDays(createdAt, retentionDays).toISOString(),
242
+ expiresAt,
242
243
  metadata: sanitizeMetadata(metadata),
243
- envelope: await cryptoProvider.encrypt({ plaintext, aad }),
244
+ envelope: await cryptoProvider.encrypt({ plaintext, aad, expiresAt }),
244
245
  aad
245
246
  });
246
247
  return { token, type };