haechi 0.6.0 → 0.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
@@ -243,6 +243,9 @@ Haechi는 로컬 정책 부트스트래핑을 위한 기본 지역별 Privacy Pr
243
243
  - `haechi init --force`는 로컬 키를 교체한다: 기존 키는 `retired` 상태로 보관되어 기존 암호문과 token vault 레코드를 `kid`로 복호화할 수 있다.
244
244
  - Privacy profile은 명시적으로 더 엄격한 사용자 action을 강화할 수는 있지만 약화할 수는 없다.
245
245
  - 탐지는 문자열 값, JSON 숫자(예: 카드 번호), 객체 키 이름을 검사한다. Base64/URL 인코딩된 값과 URL 쿼리 스트링은 검사되지 않는다.
246
+ - Audit tail truncation: `audit.anchor.mode: file`을 설정하면(추가 전용/별도 미디어에서) `haechi audit-verify --anchor`가 마지막 anchor 이후 꼬리 레코드 삭제를 탐지한다. 동일한 쓰기 가능 파일시스템에서는 공격자가 두 파일을 함께 잘라낼 수 있다.
247
+ - Key custody: `keys.provider: external`은 주입된 `cryptoProvider`를 허용한다; `assertCryptoProviderConformance`로 adapter를 검증한다. envelope 암호화 KMS adapter는 `examples/crypto-kms-reference/`를 참고한다.
248
+ - Release integrity: 배포된 tarball에는 npm provenance attestation이 포함되며, GitHub release asset에는 sigstore attestation과 `SHA256SUMS`가 추가된다(`gh attestation verify`와 `node scripts/release-checksums.mjs --check`로 검증한다).
246
249
  - 이 패키지는 개발자 프리뷰이다. 인터넷에 노출된 운영 LLM 게이트웨이로 사용하지 않는다.
247
250
 
248
251
  ## 현재 범위
@@ -262,3 +265,5 @@ Haechi는 로컬 정책 부트스트래핑을 위한 기본 지역별 Privacy Pr
262
265
  0.5.0은 SSE/NDJSON 스트리밍 응답 검사를 추가한다: `streaming.requestMode: "inspect"`가 bounded sliding buffer로 응답을 stream-filter하여 프레임에 걸쳐 쪼개진 PII도 잡는다(`streaming.maxMatchBytes`). `docs/current/release-0.5-implementation-scope.md` 참고.
263
266
 
264
267
  0.6.0은 인증과 클라이언트별 통제를 추가한다: 해시 기반 token 저장소와 `haechi auth` CLI를 갖춘 내장 bearer auth, identity scope/label로 바인딩되는 named policy profile, model allowlisting, 그리고 identity별 rate limiting — audit 로그에는 PII-safe identity가 기록된다. `docs/current/release-0.6-implementation-scope.md` 참고.
268
+
269
+ 0.7.0은 운영 강화(ops-hardening) 릴리스이다: 꼬리 절단을 탐지하는 audit head-hash anchoring(`audit.anchor`), `assertCryptoProviderConformance`와 reference KMS adapter를 포함한 강화된 외부 `cryptoProvider` 계약, 그리고 서명/체크섬된 GitHub release artifact. `docs/current/release-0.7-implementation-scope.md` 참고.
package/README.md CHANGED
@@ -243,6 +243,9 @@ Set `privacy.profile` in `haechi.config.json` to apply the profile's default act
243
243
  - `haechi init --force` rotates the local key: prior keys are kept as `retired` so existing envelopes and token vault records stay decryptable by `kid`.
244
244
  - Privacy profiles can strengthen but never weaken an explicitly stricter user action.
245
245
  - Detection scans string values, JSON numbers (e.g. card numbers), and object key names. Base64/URL-encoded values and URL query strings are NOT inspected.
246
+ - Audit tail truncation: set `audit.anchor.mode: file` (on append-only/separate media) so `haechi audit-verify --anchor` detects deletion of trailing records back to the last anchor. On the same writable filesystem an attacker can truncate both files together.
247
+ - Key custody: `keys.provider: external` accepts an injected `cryptoProvider`; validate adapters with `assertCryptoProviderConformance`. See `examples/crypto-kms-reference/` for an envelope-encryption KMS adapter.
248
+ - Release integrity: published tarballs carry an npm provenance attestation; GitHub release assets add a sigstore attestation and `SHA256SUMS` (verify with `gh attestation verify` and `node scripts/release-checksums.mjs --check`).
246
249
  - The package is a developer preview. Do not expose it as an internet-facing production LLM gateway.
247
250
 
248
251
  ## Current Scope
@@ -262,3 +265,5 @@ Set `privacy.profile` in `haechi.config.json` to apply the profile's default act
262
265
  0.5.0 adds SSE/NDJSON streaming response inspection: `streaming.requestMode: "inspect"` stream-filters responses with a bounded sliding buffer that catches PII split across frames (`streaming.maxMatchBytes`). See `docs/current/release-0.5-implementation-scope.md`.
263
266
 
264
267
  0.6.0 adds authentication and per-client controls: built-in bearer auth with a hashed token store and `haechi auth` CLI, named policy profiles bound by identity scope/label, model allowlisting, and per-identity rate limiting — with PII-safe identity in the audit log. See `docs/current/release-0.6-implementation-scope.md`.
268
+
269
+ 0.7.0 is operational hardening: audit head-hash anchoring (`audit.anchor`) that detects tail truncation, a hardened external `cryptoProvider` contract with `assertCryptoProviderConformance` and a reference KMS adapter, and signed/checksummed GitHub release artifacts. See `docs/current/release-0.7-implementation-scope.md`.
package/docs/README.md CHANGED
@@ -17,6 +17,7 @@ English is the primary documentation language. Korean translations are maintaine
17
17
  - `docs/current/release-0.4-implementation-scope.md`: 0.4 token round-trip, mcp-wrap, audit-verify/status, identity/authProvider contract reservation
18
18
  - `docs/current/release-0.5-implementation-scope.md`: 0.5 SSE/NDJSON streaming response inspection with bounded cross-frame buffer
19
19
  - `docs/current/release-0.6-implementation-scope.md`: 0.6 bearer auth, named policy profiles, model allowlist, rate limiting
20
+ - `docs/current/release-0.7-implementation-scope.md`: 0.7 audit anchoring, cryptoProvider contract + reference KMS adapter, signed release artifacts
20
21
  - `docs/current/configuration.md`: full configuration reference (every key, defaults, validation, presets, common setups)
21
22
  - `docs/current/risk-register-release-gate.md`: release-blocking risks, security/operational risk status, npm release gates (0.3.2 baseline)
22
23
  - `docs/current/threat-model.md`: Haechi 0.3.2 trust boundaries, protected assets, key threats and controls
@@ -2,7 +2,7 @@
2
2
 
3
3
  - 문서 상태: Draft 0.1
4
4
  - 작성일: 2026-06-10
5
- - 기준 버전: 0.6.0
5
+ - 기준 버전: 0.7.0
6
6
 
7
7
  ## 1. 버전 해석
8
8
 
@@ -44,6 +44,9 @@
44
44
  - `haechi/stream-filter` (`inspectResponseStream`, path helpers) 및 `createStreamProtector` (스트리밍 검사 내부 구현)
45
45
  - `haechi/auth` (`createBearerAuthProvider`, token store, `buildIdentity`) 및 `authProvider` 계약
46
46
  - `policy.profiles`/`policy.profileBinding`/`modelAllowlist`/`rate` 및 `identity`/`profile` audit 필드
47
+ - `assertCryptoProviderConformance` 및 강화된 cryptoProvider 계약 (envelope base shape + provider 확장)
48
+ - `audit.anchor` 설정 및 `verifyAuditChain(path, { anchorPath })`
49
+ - `scripts/release-checksums.mjs` (SHA256SUMS 생성/검증)
47
50
 
48
51
  ## 4. Migration note 기준
49
52
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  - Status: Draft 0.1
4
4
  - Date: 2026-06-10
5
- - Target version: 0.6.0
5
+ - Target version: 0.7.0
6
6
 
7
7
  ## 1. Version Interpretation
8
8
 
@@ -43,6 +43,9 @@ The following exports are treated as preview in 0.4.0.
43
43
  - `status` / `audit-verify` CLI output shapes
44
44
  - `haechi/stream-filter` (`inspectResponseStream`, path helpers) and `createStreamProtector` (streaming inspection internals)
45
45
  - `haechi/auth` (`createBearerAuthProvider`, token store, `buildIdentity`) and the `authProvider` contract
46
+ - `assertCryptoProviderConformance` and the hardened cryptoProvider contract (envelope base shape + provider extensions)
47
+ - `audit.anchor` config and `verifyAuditChain(path, { anchorPath })`
48
+ - `scripts/release-checksums.mjs` (SHA256SUMS generate/verify)
46
49
  - `policy.profiles`/`policy.profileBinding`/`modelAllowlist`/`rate` and the `identity`/`profile` audit fields
47
50
 
48
51
  ## 4. Migration note criteria
@@ -0,0 +1,91 @@
1
+ # Haechi 0.7 Implementation Scope
2
+
3
+ - 문서 상태: Final
4
+ - 작성일: 2026-06-10
5
+ - 기준 버전: 0.7.0 (0.6.0 이후)
6
+ - 성격: ops hardening
7
+ - 구현 완료: 2026-06-10 — PR #22 (audit anchoring), #23 (cryptoProvider 계약 + reference KMS), #24 (signed release artifacts)
8
+
9
+ ## 1. 릴리스 목표
10
+
11
+ 1.0("stable", developer-preview 레이블 제거)이 차단하는 운영 스토리를 강화한다: 단일 로컬 파일을 넘어선 audit 무결성, 외부 key custody, 검증 가능한 릴리스 아티팩트. 두 번의 1.0 차단 릴리스 중 첫 번째다.
12
+
13
+ **범위 결정 (2026-06-10):** 0.7은 **ops hardening** — audit 무결성, key custody 계약, 서명된 아티팩트에 집중한다. 이전에 여기에 묶였던 **생태계** 항목들(npm org `@haechi/*`, `@haechi/crypto-kms` / `@haechi/auth-oidc` 게시, `@haechi/dashboard`, npm workspaces)은 **0.8**로 이월하며, 0.8에서 중복된 0.7 로드맵 행도 제거한다.
14
+
15
+ Core의 **zero runtime dependency** 기조는 협상 불가: 0.7의 모든 것은 `node:` 빌트인만으로 제공된다. 무거운 어댑터(AWS KMS, Vault)는 satellite/example이며, 절대 core에 포함되지 않는다.
16
+
17
+ ## 2. 범위
18
+
19
+ ### 2.1 Audit tail-truncation 방어: head-hash anchoring (내장, zero-dep)
20
+
21
+ audit hash chain은 변조와 재정렬은 탐지하지만, 마지막 N개 레코드의 **삭제**는 탐지하지 못한다 — 단축된 chain도 여전히 검증을 통과하기 때문이다. 0.7은 주기적 anchoring으로 일반적인 경우를 해결한다.
22
+
23
+ - 추가 이후 JSONL sink는 현재 chain head를 별도의 **append-only anchor 스트림**에 기록한다: JSON 한 줄 `{ sequence, eventHash, timestamp }`.
24
+ - Config `audit.anchor`:
25
+ - `mode`: `none` (기본값 — 현재 동작) | `file` | `stdout`.
26
+ - `path`: `mode: file`일 때의 anchor 파일 (다른 매체 / append-only 플래그가 설정된 경로 권장).
27
+ - `everyRecords`: anchor 주기 (기본값 `1` — 레코드마다 anchor; 배치 처리 시 값 증가). Anchor 라인은 매우 작다.
28
+ - `verifyAuditChain(path, { anchorPath })`은 교차 검증한다: 최신 anchor의 `sequence`가 chain 길이를 초과해서는 안 되며, 앵커된 `sequence`의 chain 레코드는 앵커된 `eventHash`로 해시되어야 한다. 최신 anchor보다 짧은 chain → **truncation 탐지** (마지막 anchor 이후의 레코드가 제거된 것).
29
+ - `haechi audit-verify --anchor <path>`로 확인하며; `haechi status`는 anchor 모드 + 마지막으로 앵커된 sequence를 보고한다.
30
+ - **보장 범위의 한계:** truncation은 **마지막 anchor** 이전까지만 탐지된다; 마지막 anchor 이후, truncation 이전에 기록된 레코드는 여전히 조용히 손실될 수 있다. `everyRecords: 1`이면 그 범위는 레코드 하나다. 문서화되어 있다.
31
+
32
+ ### 2.2 외부 append-only audit sink 계약
33
+
34
+ - 주입된 `auditSink` 계약을 공식화한다 (이미 `createRuntime(config, { auditSink })`를 통해 지원됨): `record(event)`는 append-only이며 순서를 보존한다; 외부 sink는 hash chain을 직접 구현하거나 내장 sink를 래핑한다. 기능 플래그(`writesAudit`, `integrity`, `appendOnly`)가 문서화된다.
35
+ - HTTP/syslog/object-lock 전달 레퍼런스 sink는 **0.8 satellite/example**이다; 0.7은 계약 + 내장 anchoring을 zero-dep 해답으로 제공한다.
36
+
37
+ ### 2.3 cryptoProvider 계약 강화 + 레퍼런스 KMS 어댑터
38
+
39
+ - `keys.provider: external`에 대한 `cryptoProvider` 계약을 강화하고 문서화한다: 외부 provider는 `encrypt`, `decrypt`, **그리고 `hmac`** (토큰/identity를 위해 0.4에서 추가됨)을 구현해야 하며, envelope 형태(`{ v, alg, kid, iv, ct, tag, aadHash }`)를 보존하고, canonical AAD를 바인딩하며, `kid`로 키를 선택해야 한다.
40
+ - `assertCryptoProviderConformance(provider)` (익스포트된 테스트 헬퍼)를 제공한다: encrypt→decrypt 왕복, AAD 불일치 거부, `hmac` 결정론 + domain separation. Satellite 어댑터는 이를 통해 자체 테스트한다.
41
+ - `examples/crypto-kms-reference/` 아래에 **레퍼런스 어댑터**를 제공한다 (자체 `package.json`, AWS/Vault SDK는 *optional/peer* 의존성으로, core의 `files`에 포함하지 않음): 주입 방법을 시연한다. 이것이 0.8에서(npm org 취득 후) 게시되는 **`@haechi/crypto-kms`** satellite의 소스가 된다.
42
+
43
+ ### 2.4 서명된 릴리스 아티팩트
44
+
45
+ - npm provenance (SLSA attestation)는 신뢰할 수 있는 퍼블리싱을 통해 이미 제공된다 (0.4부터). 0.7은 **GitHub 릴리스 에셋 무결성**을 추가한다: 릴리스 워크플로가 `npm pack`을 실행하고, `SHA256SUMS`를 생성하며, 타볼 + 체크섬 (그리고 가능한 경우 sigstore/cosign 서명)을 각 GitHub 릴리스에 첨부한다.
46
+ - 사용자가 설치 전 다운로드한 타볼을 검증할 수 있게 하고, 릴리스 에셋에 레지스트리 외의 변조 방지 매니페스트를 제공한다.
47
+
48
+ ## 3. 명시적 비범위 (0.8로 이월)
49
+
50
+ - npm org `@haechi/*` 생성; `@haechi/crypto-kms`, `@haechi/auth-oidc`, `@haechi/auth-jwt` 게시.
51
+ - `@haechi/dashboard` (읽기 전용 audit 뷰어) 및 npm workspaces 전환.
52
+ - 게시된 패키지로서의 실제 AWS KMS / HashiCorp Vault SDK 연동 (0.7은 계약 + 레퍼런스 example만 제공).
53
+ - 분산/공유 audit 또는 rate 상태.
54
+
55
+ ## 4. Config 스키마 요약
56
+
57
+ ```json
58
+ "audit": {
59
+ "sink": "jsonl",
60
+ "path": ".haechi/audit.jsonl",
61
+ "anchor": { "mode": "none", "path": ".haechi/audit.anchor.jsonl", "everyRecords": 1 }
62
+ }
63
+ ```
64
+ Fail-closed 검증: 알 수 없는 `anchor.mode`; `path` 없이 `mode: file`; 비양수 `everyRecords`.
65
+
66
+ ## 5. 1.0 졸업 기준 진행
67
+
68
+ 0.7은 다섯 개의 1.0("developer-preview 레이블 제거") 차단 조건 중 세 개를 진전시킨다:
69
+
70
+ | 1.0 차단 조건 | 0.7 기여 |
71
+ |---|---|
72
+ | 운영 key custody | cryptoProvider 계약 강화 + conformance 테스트 + 레퍼런스 어댑터 (게시 패키지는 0.8) |
73
+ | 외부 / tamper-evident audit | 내장 anchoring으로 tail-truncation 해결; 외부 sink 계약 문서화 |
74
+ | 검증 가능한 릴리스 아티팩트 | 서명/체크섬된 GitHub 릴리스 에셋 |
75
+ | API stability freeze | (1.0) |
76
+ | Plugin sandbox + 실환경 검증 | (1.0) |
77
+
78
+ ## 6. 테스트 기준 (구현 시)
79
+
80
+ - Anchoring: `everyRecords`에 따라 anchor 라인이 기록됨; anchor가 포함된 `verifyAuditChain`이 truncation(최신 anchor보다 짧은 chain)을 탐지하고 온전한 chain은 통과시킴; `mode: none`이면 0.6 동작과 byte 단위로 동일.
81
+ - `audit-verify --anchor` 종료 코드 + 출력; `status`가 anchor 모드/마지막 sequence를 보고.
82
+ - cryptoProvider conformance 헬퍼가 로컬 provider를 통과시키고, `hmac` 누락 / AAD 불일치 provider를 실패시킴.
83
+ - `audit.anchor` 블록에 대한 Config 검증.
84
+ - 릴리스 워크플로가 팩된 타볼과 일치하는 `SHA256SUMS`를 생성함 (CI 검증 가능).
85
+
86
+ ## 7. 권장 PR 분할 (스택)
87
+
88
+ 1. Audit anchoring (sink가 anchor 기록) + `verifyAuditChain` anchor 교차 검증 + config + `audit-verify --anchor` / `status`.
89
+ 2. cryptoProvider 계약 문서 + `assertCryptoProviderConformance` + `examples/crypto-kms-reference/`.
90
+ 3. 서명된 릴리스 아티팩트 (릴리스 워크플로 + 검증 문서).
91
+ 4. 0.7.0 릴리스 컷 (버전, 문서 EN/KO, threat-model/risk-register/api-stability, wiki).
@@ -0,0 +1,92 @@
1
+ # Haechi 0.7 Implementation Scope
2
+
3
+ - Status: Final
4
+ - Date: 2026-06-10
5
+ - Target version: 0.7.0 (after 0.6.0)
6
+ - Type: ops hardening
7
+ - Shipped: 2026-06-10 — PRs #22 (audit anchoring), #23 (cryptoProvider contract + reference KMS), #24 (signed release artifacts)
8
+
9
+ ## 1. Release Goal
10
+
11
+ Harden the operational story that 1.0 ("stable", developer-preview label removed) blocks on: audit integrity beyond a single local file, external key custody, and verifiable release artifacts. This is the first of the two 1.0-blocker releases.
12
+
13
+ **Scope decision (2026-06-10):** 0.7 is focused on **ops hardening** — audit integrity, key custody contract, signed artifacts. The **ecosystem** items previously grouped here (npm org `@haechi/*`, publishing `@haechi/crypto-kms` / `@haechi/auth-oidc`, `@haechi/dashboard`, npm workspaces) move to **0.8**, which also removes the duplicate 0.7 roadmap row.
14
+
15
+ Core's **zero runtime dependency** posture is non-negotiable: everything in 0.7 ships with `node:` builtins only. Heavy adapters (AWS KMS, Vault) are satellites/examples, never core.
16
+
17
+ ## 2. Scope
18
+
19
+ ### 2.1 Audit tail-truncation defense: head-hash anchoring (built-in, zero-dep)
20
+
21
+ The audit hash chain detects tampering and reordering but **not** deletion of the last N records — the shortened chain still verifies. 0.7 closes this for the common case with periodic anchoring.
22
+
23
+ - After appending, the JSONL sink writes the current chain head to a separate **append-only anchor stream**: one JSON line `{ sequence, eventHash, timestamp }`.
24
+ - Config `audit.anchor`:
25
+ - `mode`: `none` (default — current behavior) | `file` | `stdout`.
26
+ - `path`: anchor file when `mode: file` (created `0600`). `stdout` writes anchor lines to stdout for capture by a long-running command's supervisor — not for JSON-emitting commands, whose output it would interleave.
27
+ - `everyRecords`: anchor cadence (default `1` — anchor every record; raise to batch). Anchor lines are tiny.
28
+ - `verifyAuditChain(path, { anchorPath })` cross-checks: the latest anchor's `sequence` must not exceed the chain length, and the chain record at the anchored `sequence` must hash to the anchored `eventHash`. A chain shorter than the latest anchor → **truncation detected** (records after the last anchor were removed). A partial trailing anchor line (from a crash) is tolerated.
29
+ - `haechi audit-verify --anchor <path>` surfaces this; `haechi status` reports anchor mode + last anchored sequence.
30
+ - **Threat-model boundary (required, not optional):** the anchor adds tamper-evidence **only when it lives on append-only or physically separate media** (append-only-flagged FS, S3/GCS object-lock, syslog, a different host). On the **same writable filesystem** an attacker who truncates `audit.jsonl` can truncate `audit.anchor.jsonl` to match and verification passes — so file-mode on the same disk is a convenience, not a guarantee. The CLI (`status`, `audit-verify`, `config`) states this explicitly.
31
+ - **Bounded guarantee:** even on proper media, truncation is detected only back to the **last anchor**; records written after the last anchor and before truncation can still be lost silently. With `everyRecords: 1` that window is one record. Documented.
32
+
33
+ ### 2.2 External append-only audit sink contract
34
+
35
+ - Formalize the injected `auditSink` contract (already supported via `createRuntime(config, { auditSink })`): `record(event)` is append-only and order-preserving; an external sink either implements the hash chain itself or wraps the built-in sink. Capability flags (`writesAudit`, `integrity`, `appendOnly`) are documented.
36
+ - A reference HTTP/syslog/object-lock forwarding sink is a **0.8 satellite/example**; 0.7 ships the contract + the built-in anchoring as the zero-dep answer.
37
+
38
+ ### 2.3 cryptoProvider contract hardening + reference KMS adapter
39
+
40
+ - Tighten and document the `cryptoProvider` contract for `keys.provider: external`: a provider always implements `encrypt`/`decrypt`, binds canonical AAD, and selects keys by `kid`; the envelope **base shape** is `{ v, alg, kid, iv, ct, tag, aadHash }` and adapters **may add provider-specific fields** (e.g. a KMS adapter's `wrappedKey`). `hmac` is required **only by features that use it** — bearer auth and deterministic tokenization — and `createRuntime` fails closed at construction when one of those is configured without `hmac` (an encrypt-only provider is otherwise valid). Policy-bundle signing uses the local key file directly via the CLI, not the injected provider.
41
+ - Ship `assertCryptoProviderConformance(provider, { requireHmac = true })` (an exported test helper): encrypt→decrypt round-trip (distinct plaintexts), AAD-mismatch rejection, **tampered-ciphertext rejection (real AEAD authentication)**, and `hmac` determinism + data-dependency + domain separation + invalid-domain rejection. Satellite adapters self-test against it; pass `requireHmac: false` for an encrypt-only provider.
42
+ - Ship a **reference adapter** under `examples/crypto-kms-reference/` (its own `package.json`, AWS/Vault SDK as an *optional* dependency; the in-process `createInMemoryKms` is explicitly non-production) demonstrating envelope-encryption injection. It is the source that becomes the published **`@haechi/crypto-kms`** satellite in 0.8 (gated on the npm org).
43
+
44
+ ### 2.4 Signed release artifacts
45
+
46
+ - npm provenance (SLSA attestation) already ships via trusted publishing (since 0.4). 0.7 adds **GitHub release asset integrity**: the release workflow runs `npm pack`, emits `SHA256SUMS`, and attaches the tarball + checksums (and, where available, a sigstore/cosign signature) to each GitHub release.
47
+ - Lets users verify a downloaded tarball before install and gives the release assets a tamper-evident manifest beyond the registry.
48
+
49
+ ## 3. Explicit non-scope (deferred to 0.8)
50
+
51
+ - Create npm org `@haechi/*`; publish `@haechi/crypto-kms`, `@haechi/auth-oidc`, `@haechi/auth-jwt`.
52
+ - `@haechi/dashboard` (read-only audit viewer) and npm workspaces conversion.
53
+ - Real AWS KMS / HashiCorp Vault SDK integration as a published package (0.7 ships the contract + reference example only).
54
+ - Distributed/shared audit or rate state.
55
+
56
+ ## 4. Config schema summary
57
+
58
+ ```json
59
+ "audit": {
60
+ "sink": "jsonl",
61
+ "path": ".haechi/audit.jsonl",
62
+ "anchor": { "mode": "none", "path": ".haechi/audit.anchor.jsonl", "everyRecords": 1 }
63
+ }
64
+ ```
65
+ Fail-closed validation: unknown `anchor.mode`; `mode: file` without a `path`; non-positive `everyRecords`.
66
+
67
+ ## 5. 1.0 exit-criteria progress
68
+
69
+ 0.7 advances three of the five 1.0 ("remove developer-preview label") blockers:
70
+
71
+ | 1.0 blocker | 0.7 contribution |
72
+ |---|---|
73
+ | Operational key custody | cryptoProvider contract hardened + conformance test + reference adapter (published package in 0.8) |
74
+ | External / tamper-evident audit | Built-in anchoring closes tail-truncation; external sink contract documented |
75
+ | Verifiable release artifacts | Signed/checksummed GitHub release assets |
76
+ | API stability freeze | (1.0) |
77
+ | Plugin sandbox + real-environment validation | (1.0) |
78
+
79
+ ## 6. Test criteria (for implementation)
80
+
81
+ - Anchoring: anchor lines written per `everyRecords`; `verifyAuditChain` with an anchor detects truncation (chain shorter than last anchor) and passes an intact chain; `mode: none` keeps 0.6 behavior byte-for-byte.
82
+ - `audit-verify --anchor` exit code + output; `status` reports anchor mode/last sequence.
83
+ - cryptoProvider conformance helper passes the local provider and fails a provider missing `hmac` / mismatching AAD.
84
+ - Config validation for the `audit.anchor` block.
85
+ - Release workflow produces `SHA256SUMS` matching the packed tarball (CI-verifiable).
86
+
87
+ ## 7. Suggested PR breakdown (stacked)
88
+
89
+ 1. Audit anchoring (sink writes anchors) + `verifyAuditChain` anchor cross-check + config + `audit-verify --anchor` / `status`.
90
+ 2. cryptoProvider contract doc + `assertCryptoProviderConformance` + `examples/crypto-kms-reference/`.
91
+ 3. Signed release artifacts (release workflow + verification doc).
92
+ 4. 0.7.0 release cut (version, docs EN/KO, threat-model/risk-register/api-stability, wiki).
@@ -38,14 +38,37 @@ provenance 없이 수행한 publish는 release note에 갭을 명시적으로
38
38
  - https://docs.npmjs.com/trusted-publishers/
39
39
  - https://docs.github.com/actions/publishing-packages/publishing-nodejs-packages
40
40
 
41
- ## 3. GitHub Actions
41
+ ## 3. 서명된 릴리스 아티팩트
42
+
43
+ **암호학적** 신뢰 앵커는 **npm provenance 증명**(레지스트리 아티팩트)과 **sigstore 증명**(release tarball)이며, 둘 다 GitHub OIDC로 아티팩트를 이 repo의 release workflow 신원에 묶는다. `SHA256SUMS`는 오프라인 체크섬(`sha256sum -c`)을 위한 **도구 호환 편의 수단**이고, 같은 workflow가 생성·업로드하므로 그 자체로는 신뢰 앵커가 아니다. provenance에 더해, publish workflow는 다운로드한 tarball을 설치 전에 검증할 수 있도록 다음 자산을 첨부한다.
44
+
45
+ - `npm pack` 후 `node scripts/release-checksums.mjs <tarball>`로 `SHA256SUMS` 매니페스트(표준 `<sha256-hex> <name>` 형식)를 생성한다.
46
+ - `actions/attest-build-provenance`로 tarball의 **keyless sigstore 증명**(GitHub OIDC, 서명 키 없음)을 만든다.
47
+ - tarball + `SHA256SUMS`를 GitHub release에 업로드한다.
48
+
49
+ 다운로드한 릴리스 검증:
50
+
51
+ ```bash
52
+ # 체크섬 (크로스플랫폼: sha256sum -c, 또는 내장 스크립트)
53
+ node scripts/release-checksums.mjs --check SHA256SUMS
54
+ sha256sum -c SHA256SUMS # GNU
55
+ shasum -a 256 -c SHA256SUMS # macOS
56
+
57
+ # sigstore 증명 (이 repo의 release workflow가 빌드한 tarball)
58
+ gh attestation verify haechi-<version>.tgz --repo raeseoklee/haechi
59
+
60
+ # npm provenance (레지스트리 아티팩트)
61
+ npm audit signatures
62
+ ```
63
+
64
+ ## 4. GitHub Actions
42
65
 
43
66
  | Workflow | 목적 |
44
67
  |---|---|
45
68
  | `.github/workflows/ci.yml` | test, release preflight, SBOM artifact |
46
- | `.github/workflows/npm-publish.yml` | GitHub release published 이벤트에서 npm publish (trusted publishing 구성 provenance 경로) |
69
+ | `.github/workflows/npm-publish.yml` | GitHub release published 이벤트에서 npm provenance publish + 체크섬/증명 release 자산 |
47
70
 
48
- ## 4. 배포 차단 조건
71
+ ## 5. 배포 차단 조건
49
72
 
50
73
  다음 중 하나라도 실패하면 npm publish를 하지 않는다.
51
74
 
@@ -38,14 +38,37 @@ References:
38
38
  - https://docs.npmjs.com/trusted-publishers/
39
39
  - https://docs.github.com/actions/publishing-packages/publishing-nodejs-packages
40
40
 
41
- ## 3. GitHub Actions
41
+ ## 3. Signed release artifacts
42
+
43
+ The **cryptographic** trust anchors are the **npm provenance attestation** (registry artifact) and the **sigstore attestation** (release tarball) — both bind the artifact to this repository's release workflow identity via GitHub OIDC. `SHA256SUMS` is a **tooling-compatible convenience** for offline checksumming (`sha256sum -c`); on its own it is not a trust anchor, since the same workflow produces and uploads it. Beyond provenance, the publish workflow attaches these assets so a downloaded tarball can be verified before install:
44
+
45
+ - It runs `npm pack`, then `node scripts/release-checksums.mjs <tarball>` to emit a `SHA256SUMS` manifest (standard `<sha256-hex> <name>` format).
46
+ - It produces a **keyless sigstore attestation** of the tarball via `actions/attest-build-provenance` (GitHub OIDC, no signing keys).
47
+ - It uploads the tarball + `SHA256SUMS` to the GitHub release.
48
+
49
+ Verify a downloaded release:
50
+
51
+ ```bash
52
+ # checksum (cross-platform: sha256sum -c, or the bundled script)
53
+ node scripts/release-checksums.mjs --check SHA256SUMS
54
+ sha256sum -c SHA256SUMS # GNU
55
+ shasum -a 256 -c SHA256SUMS # macOS
56
+
57
+ # sigstore attestation (tarball was built by this repo's release workflow)
58
+ gh attestation verify haechi-<version>.tgz --repo raeseoklee/haechi
59
+
60
+ # npm provenance (registry artifact)
61
+ npm audit signatures
62
+ ```
63
+
64
+ ## 4. GitHub Actions
42
65
 
43
66
  | Workflow | Purpose |
44
67
  |---|---|
45
68
  | `.github/workflows/ci.yml` | Tests, release preflight, SBOM artifact |
46
- | `.github/workflows/npm-publish.yml` | npm publish on GitHub release published event (provenance path once trusted publishing is configured) |
69
+ | `.github/workflows/npm-publish.yml` | npm provenance publish + checksummed/attested release assets on GitHub release published |
47
70
 
48
- ## 4. Deployment block conditions
71
+ ## 5. Deployment block conditions
49
72
 
50
73
  npm publish is not performed if any of the following fail.
51
74
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  - 문서 상태: Draft 0.3
4
4
  - 작성일: 2026-06-10
5
- - 기준 버전: 0.6.0
5
+ - 기준 버전: 0.7.0
6
6
  - 기준 브랜치: `main`
7
7
 
8
8
  ## 1. 현재 판단
@@ -129,7 +129,7 @@ base64/인코딩 값 디코딩 검사, query string 검사, audit tail truncatio
129
129
  | 0.4.0 ✅ | token round-trip and adoption | 2026-06-10 구현 완료: 요청 스코프 response detokenization, deterministic tokenization(파생 키), `haechi mcp-wrap`, `haechi audit-verify`/`haechi status`, injection detection type(기본 allow), `identity`/`authProvider` 계약 예약. `docs/current/release-0.4-implementation-scope.md` 참조 |
130
130
  | 0.5.0 ✅ | streaming hardening | 2026-06-10 출시: bounded cross-frame 버퍼를 사용한 SSE/NDJSON 스트리밍 응답 검사(`streaming.requestMode: inspect`). stream sequence AAD, replay cache, 강화된 원격 배포 가이드는 0.6+으로 이월. `docs/current/release-0.5-implementation-scope.md` 참조 |
131
131
  | 0.6.0 ✅ | Shipped 2026-06-10 (PRs #17–#19): built-in bearer auth, named policy profiles, model allowlist, request rate limit, PII-safe identity in audit. `docs/current/release-0.6-implementation-scope.md` 참조 |
132
- | 0.7.0 | observability | npm workspaces 전환, `@haechi/dashboard` read-only audit viewer (hash chain 무결성 표시, 요약/검색/타임라인) |
132
+ | 0.7.0 | Shipped 2026-06-10 (PRs #22–#24): audit head-hash anchoring + external sink contract, cryptoProvider contract hardening + `assertCryptoProviderConformance` + reference KMS adapter, 서명/체크섬된 release artifact. `docs/current/release-0.7-implementation-scope.md` 참조 |
133
133
  | 1.0.0 | stable API contract | migration policy, long-term audit schema, plugin sandbox/runtime conformance 및 allowlist/manifest 통과 외부 auth/classifier package 동적 로딩 |
134
134
 
135
135
  동적 npm package 로딩은 1.0 plugin sandbox 이전까지 금지한다. 0.4~0.7의 외부 provider는 `createRuntime(config, providers)` 프로그래매틱 주입만 지원한다.
@@ -2,7 +2,7 @@
2
2
 
3
3
  - Status: Draft 0.3
4
4
  - Date: 2026-06-10
5
- - Target version: 0.6.0
5
+ - Target version: 0.7.0
6
6
  - Branch: `main`
7
7
 
8
8
  ## 1. Current Assessment
@@ -129,8 +129,8 @@ All checklist items below were completed for 0.3.2 on 2026-06-10 except the prov
129
129
  | 0.4.0 ✅ | Token round-trip and adoption | Shipped 2026-06-10: request-scoped response detokenization, deterministic tokenization (derived key), `haechi mcp-wrap`, `haechi audit-verify`/`haechi status`, injection detection type (default allow), `identity`/`authProvider` contracts reserved. See `docs/current/release-0.4-implementation-scope.md` |
130
130
  | 0.5.0 ✅ | Streaming hardening | Shipped 2026-06-10: SSE/NDJSON streaming response inspection with bounded cross-frame buffer (`streaming.requestMode: inspect`). Stream sequence AAD, replay cache, stronger remote deployment guide deferred to 0.6+. See `docs/current/release-0.5-implementation-scope.md` |
131
131
  | 0.6.0 ✅ | Auth and per-client controls | Shipped 2026-06-10 (PRs #17–#19): built-in bearer auth, named policy profiles, model allowlist, request rate limit, PII-safe identity in audit. See `docs/current/release-0.6-implementation-scope.md` |
132
- | 0.7.0 | Ops hardening and ecosystem | Vault/AWS KMS reference adapter, external append-only audit sink, signed release artifacts, npm org (`@haechi/*`), OIDC satellite, `@haechi/dashboard` |
133
- | 0.7.0 | Observability | npm workspaces migration, `@haechi/dashboard` read-only audit viewer (hash chain integrity display, summary/search/timeline) |
132
+ | 0.7.0 | Ops hardening | Shipped 2026-06-10 (PRs #22–#24): audit head-hash anchoring + external sink contract, cryptoProvider contract hardening + `assertCryptoProviderConformance` + reference KMS adapter, signed/checksummed release artifacts. See `docs/current/release-0.7-implementation-scope.md` |
133
+ | 0.8.0 | Ecosystem and observability | npm org (`@haechi/*`), publish `@haechi/crypto-kms` and `@haechi/auth-oidc`, npm workspaces, `@haechi/dashboard` read-only audit viewer (hash-chain integrity display, summary/search/timeline) |
134
134
  | 1.0.0 | Stable API contract | Migration policy, long-term audit schema, plugin sandbox/runtime conformance, and dynamic loading of external auth/classifier packages that pass allowlist/manifest |
135
135
 
136
136
  Dynamic npm package loading is prohibited until the 1.0 plugin sandbox. External providers in 0.4–0.7 are supported only via `createRuntime(config, providers)` programmatic injection.
@@ -2,7 +2,7 @@
2
2
 
3
3
  - 문서 상태: Draft 0.1
4
4
  - 작성일: 2026-06-10
5
- - 기준 버전: 0.6.0
5
+ - 기준 버전: 0.7.0
6
6
 
7
7
  ## 1. 보호 대상
8
8
 
@@ -48,6 +48,9 @@ Haechi가 보호하려는 주요 자산은 다음이다.
48
48
  | signing/encryption 키 혼용 | key separation 위반 | policy bundle 서명 키를 domain-separated 파생 키로 분리 |
49
49
  | JSON number/object key 은닉 | 카드번호 등 비문자열 leaf 미탐지 | number leaf와 object key도 detection/transform 대상 |
50
50
  | 인증 없는 멀티 클라이언트 접근 | 로컬 프로세스가 upstream / token round-trip 경로를 무단 사용 | 선택적 bearer auth (`auth.provider: bearer`); 없거나 잘못된 경우 → 바디 읽기 전 401; identity별 rate limit 및 model allowlist |
51
+ | Audit tail truncation | 꼬리 audit 레코드의 무음 삭제 | 추가 전용/별도 미디어의 `audit.anchor` head-hash anchoring으로 마지막 anchor까지의 절단 탐지 (0.7) |
52
+ | Local dev key in production | 소프트웨어 키의 운영 custody 오용 | `assertCryptoProviderConformance`를 통한 외부 `cryptoProvider` 주입; reference KMS adapter (envelope 암호화) |
53
+ | Tampered release artifact | 변조된 tarball 설치 | npm provenance + GitHub release tarball의 sigstore attestation + `SHA256SUMS` (0.7) |
51
54
  | audit에 원시 credentials/identity 노출 | audit 로그를 통한 token 또는 subject 유출 | Token은 keyed-HMAC 해시로만 저장; identity subject/issuer는 keyed HMAC 처리; `auth_denied` 레코드에 token 미포함 |
52
55
  | token round-trip의 타 토큰 복원 | 클라이언트/요청 간 평문 복구 | detokenization은 opt-in(`detokenizeResponses`)이며 요청 스코프: 같은 요청을 보호하며 발급된 토큰만 복원 |
53
56
  | tool result/응답 내 간접 prompt injection | 심어진 지시문에 의한 agent 조작 | 응답 방향 휴리스틱, 기본 report-only(`injection` action `allow`), 격상은 명시적 정책 선택. 완전 방어 아님 |
@@ -66,7 +69,7 @@ Haechi가 보호하려는 주요 자산은 다음이다.
66
69
  - 외부 MCP server의 OAuth/resource binding 검증
67
70
  - base64/URL-encoded 값, 유니코드 난독화 값의 디코딩 후 검사
68
71
  - URL query string 내 민감값 검사 (JSON body만 검사)
69
- - audit hash chain의 tail truncation(꼬리 절단) 탐지 체인은 변조/재정렬은 탐지하지만 마지막 N개 레코드 삭제는 외부 보존 사본 없이는 탐지 불가
72
+ - 마지막 anchor 이후의 audit tail truncation — `audit.anchor`(0.7) anchor가 추가 전용/별도 미디어에 있을 마지막 anchor까지의 레코드 삭제를 탐지한다; 마지막 anchor 이후 기록된 레코드와 동일 파일시스템 anchor는 대상에서 제외된다
70
73
  - JSON-RPC batch 메시지 처리 (MCP stdio filter는 batch를 fail-closed로 거부)
71
74
 
72
75
  ## 5. 남은 운영 전제
@@ -2,7 +2,7 @@
2
2
 
3
3
  - Status: Draft 0.1
4
4
  - Date: 2026-06-10
5
- - Target version: 0.6.0
5
+ - Target version: 0.7.0
6
6
 
7
7
  ## 1. Assets Under Protection
8
8
 
@@ -48,6 +48,9 @@ The primary assets Haechi protects are:
48
48
  | Signing/encryption key conflation | Key separation violation | Policy bundle signing key isolated as a domain-separated derived key |
49
49
  | JSON number / object key concealment | Undetected non-string leaves such as card numbers | Number leaves and object keys included in detection/transform scope |
50
50
  | 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 |
51
+ | 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) |
52
+ | Local dev key in production | Software key misused as production custody | External `cryptoProvider` injection with `assertCryptoProviderConformance`; reference KMS adapter (envelope encryption) |
53
+ | Tampered release artifact | Modified tarball installed | npm provenance + sigstore attestation of the GitHub release tarball + `SHA256SUMS` (0.7) |
51
54
  | 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 |
52
55
  | 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 |
53
56
  | Indirect prompt injection in tool results/responses | Agent manipulation via planted instructions | Response-direction heuristics, report-only by default (`injection` action `allow`); escalation is an explicit policy choice. Not a complete defense |
@@ -66,7 +69,7 @@ The primary assets Haechi protects are:
66
69
  - OAuth/resource binding validation for external MCP servers
67
70
  - Inspection of base64/URL-encoded values or unicode-obfuscated values after decoding
68
71
  - Detection of sensitive values in URL query strings (JSON body only)
69
- - Audit hash chain tail truncation detection the chain detects tampering and reordering, but deletion of the last N records cannot be detected without an externally preserved copy
72
+ - 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
70
73
  - JSON-RPC batch message processing (the MCP stdio filter rejects batches fail-closed)
71
74
 
72
75
  ## 5. Remaining Operational Assumptions
@@ -0,0 +1,47 @@
1
+ # `@haechi/crypto-kms` (reference)
2
+
3
+ A reference KMS-backed `cryptoProvider` for Haechi's `keys.provider: external` path. This is the **shape** of the satellite published as `@haechi/crypto-kms` in 0.8 — it lives here as a dependency-free reference so core stays zero-runtime-dependency.
4
+
5
+ ## How it works
6
+
7
+ Envelope encryption: each `encrypt` generates a fresh AES-256-GCM **data key**, encrypts the plaintext locally, and **wraps the data key with the KMS**. The KMS master key never leaves the KMS. `decrypt` unwraps the data key via the KMS and decrypts locally. `hmac` derives a per-domain key from the KMS, preserving Haechi's domain-separation discipline (tokens, identity, policy bundles).
8
+
9
+ The envelope matches Haechi's contract (`v, alg, kid, iv, ct, tag, aadHash`) plus a `wrappedKey`. AAD is canonicalized and bound exactly as the local provider does, so it passes `assertCryptoProviderConformance`.
10
+
11
+ ## The KMS client interface
12
+
13
+ Inject any client implementing:
14
+
15
+ ```js
16
+ {
17
+ keyId: string,
18
+ async wrap(dataKey: Buffer): string, // KMS-encrypt a data key
19
+ async unwrap(wrapped: string): Buffer, // KMS-decrypt it
20
+ async deriveHmacKey(domain: string): Buffer // KMS-derived per-domain key
21
+ }
22
+ ```
23
+
24
+ `createInMemoryKms()` is a process-local stand-in for examples/tests. A real deployment swaps in an AWS KMS / HashiCorp Vault client (e.g. `@aws-sdk/client-kms` `GenerateDataKey`/`Decrypt`, plus an HKDF for `deriveHmacKey`).
25
+
26
+ ## Usage
27
+
28
+ In 0.7 this is a repo reference example — import it by relative path
29
+ (`./examples/crypto-kms-reference/index.mjs`). From 0.8 it is published as
30
+ `@haechi/crypto-kms` and imported by name, as shown below.
31
+
32
+ ```js
33
+ import { createRuntime } from "haechi/runtime";
34
+ import { createKmsCryptoProvider, createInMemoryKms } from "@haechi/crypto-kms";
35
+
36
+ const cryptoProvider = createKmsCryptoProvider({ kms: createInMemoryKms() });
37
+ const runtime = createRuntime({ keys: { provider: "external" }, /* ... */ }, { cryptoProvider });
38
+ ```
39
+
40
+ ## Self-test
41
+
42
+ ```js
43
+ import { assertCryptoProviderConformance } from "haechi/crypto";
44
+ await assertCryptoProviderConformance(cryptoProvider); // throws on any contract violation
45
+ ```
46
+
47
+ This reference is **not a production key provider**; `createInMemoryKms` holds a process-local master key. Use a real KMS client for custody.
@@ -0,0 +1,133 @@
1
+ // Reference KMS-backed cryptoProvider for Haechi (keys.provider: external).
2
+ //
3
+ // This is the *shape* a published @haechi/crypto-kms satellite (0.8) takes. It
4
+ // uses envelope encryption: a fresh data key per record encrypts the plaintext
5
+ // locally with AES-256-GCM, and the data key is wrapped by the KMS. The master
6
+ // key never leaves the KMS. The `kms` client is injected, so this file has zero
7
+ // real dependencies — a real adapter swaps createInMemoryKms() for an AWS KMS /
8
+ // HashiCorp Vault client implementing the same small interface.
9
+ //
10
+ // Inject it: createRuntime(config, { cryptoProvider: createKmsCryptoProvider({ kms }) })
11
+ // and set keys.provider: "external".
12
+
13
+ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
14
+
15
+ // The published @haechi/crypto-kms satellite imports this from `haechi/crypto`;
16
+ // it is inlined here so the reference example is fully self-contained (no
17
+ // cross-package import) and matches Haechi's canonical AAD exactly.
18
+ function canonicalize(value) {
19
+ if (Array.isArray(value)) {
20
+ return `[${value.map((item) => canonicalize(item)).join(",")}]`;
21
+ }
22
+ if (value && typeof value === "object") {
23
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(",")}}`;
24
+ }
25
+ return JSON.stringify(value);
26
+ }
27
+
28
+ const ALG = "AES-256-GCM";
29
+ const HMAC_KEY_DOMAIN = "haechi:crypto-kms:hmac-root:v1";
30
+
31
+ // The injected KMS client must implement:
32
+ // keyId: string
33
+ // async wrap(dataKey: Buffer) -> string (KMS-encrypt a data key)
34
+ // async unwrap(wrapped: string) -> Buffer (KMS-decrypt it back)
35
+ // async deriveHmacKey(domain: string) -> Buffer (KMS-derived per-domain key)
36
+ export function createKmsCryptoProvider({ kms }) {
37
+ if (!kms || typeof kms.wrap !== "function" || typeof kms.unwrap !== "function" || typeof kms.deriveHmacKey !== "function") {
38
+ throw new Error("createKmsCryptoProvider requires a kms client with wrap/unwrap/deriveHmacKey");
39
+ }
40
+
41
+ function sha256(value) {
42
+ // Plain SHA-256, matching Haechi's core aadHash (defence-in-depth; GCM
43
+ // already authenticates the AAD via the tag).
44
+ return createHash("sha256").update(value).digest("base64url");
45
+ }
46
+
47
+ return {
48
+ id: "haechi.crypto.kms-reference",
49
+ version: "0.1.0",
50
+ capabilities: {
51
+ readsPlaintext: true,
52
+ networkEgress: true, // a real KMS adapter calls out to the KMS
53
+ keyCustody: "external-kms"
54
+ },
55
+ async encrypt({ plaintext, aad }) {
56
+ const dataKey = randomBytes(32);
57
+ const iv = randomBytes(12);
58
+ const cipher = createCipheriv("aes-256-gcm", dataKey, iv);
59
+ const aadBytes = Buffer.from(canonicalize(aad), "utf8");
60
+ cipher.setAAD(aadBytes);
61
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
62
+ const tag = cipher.getAuthTag();
63
+ return {
64
+ v: 1,
65
+ alg: ALG,
66
+ kid: kms.keyId,
67
+ iv: iv.toString("base64url"),
68
+ ct: ciphertext.toString("base64url"),
69
+ tag: tag.toString("base64url"),
70
+ wrappedKey: await kms.wrap(dataKey),
71
+ aadHash: sha256(aadBytes)
72
+ };
73
+ },
74
+ async decrypt({ envelope, aad }) {
75
+ if (envelope.alg && envelope.alg !== ALG) {
76
+ throw new Error(`Unsupported algorithm: ${envelope.alg}`);
77
+ }
78
+ const aadBytes = Buffer.from(canonicalize(aad), "utf8");
79
+ if (envelope.aadHash && envelope.aadHash !== sha256(aadBytes)) {
80
+ throw new Error("AAD hash mismatch");
81
+ }
82
+ const dataKey = await kms.unwrap(envelope.wrappedKey);
83
+ const decipher = createDecipheriv("aes-256-gcm", dataKey, Buffer.from(envelope.iv, "base64url"));
84
+ decipher.setAAD(aadBytes);
85
+ decipher.setAuthTag(Buffer.from(envelope.tag, "base64url"));
86
+ return Buffer.concat([
87
+ decipher.update(Buffer.from(envelope.ct, "base64url")),
88
+ decipher.final()
89
+ ]).toString("utf8");
90
+ },
91
+ async hmac({ data, domain }) {
92
+ if (!domain || typeof domain !== "string") {
93
+ throw new Error("hmac requires a non-empty domain string");
94
+ }
95
+ // Domain-separated: derive a per-domain key from the KMS, then HMAC.
96
+ const derived = await kms.deriveHmacKey(domain);
97
+ return createHmac("sha256", derived).update(data).digest("hex");
98
+ }
99
+ };
100
+ }
101
+
102
+ // In-memory stand-in for AWS KMS / Vault — for examples and tests only. A real
103
+ // deployment injects a client backed by the cloud KMS.
104
+ //
105
+ // WARNING: the default masterKey is a fresh random key PER PROCESS. Anything
106
+ // encrypted in one run cannot be decrypted in the next — exactly the silent
107
+ // data-loss footgun that key rotation must avoid. For any persistence across
108
+ // restarts, supply a stable `masterKey` (or, in production, use a real KMS that
109
+ // holds the master key). This fake is NOT a production key provider.
110
+ export function createInMemoryKms({ keyId = "kms-ref-local", masterKey = randomBytes(32) } = {}) {
111
+ return {
112
+ keyId,
113
+ async wrap(dataKey) {
114
+ const iv = randomBytes(12);
115
+ const cipher = createCipheriv("aes-256-gcm", masterKey, iv);
116
+ const ct = Buffer.concat([cipher.update(dataKey), cipher.final()]);
117
+ const tag = cipher.getAuthTag();
118
+ return Buffer.concat([iv, tag, ct]).toString("base64url");
119
+ },
120
+ async unwrap(wrapped) {
121
+ const buffer = Buffer.from(wrapped, "base64url");
122
+ const iv = buffer.subarray(0, 12);
123
+ const tag = buffer.subarray(12, 28);
124
+ const ct = buffer.subarray(28);
125
+ const decipher = createDecipheriv("aes-256-gcm", masterKey, iv);
126
+ decipher.setAuthTag(tag);
127
+ return Buffer.concat([decipher.update(ct), decipher.final()]);
128
+ },
129
+ async deriveHmacKey(domain) {
130
+ return createHmac("sha256", masterKey).update(`${HMAC_KEY_DOMAIN}:${domain}`).digest();
131
+ }
132
+ };
133
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@haechi/crypto-kms",
3
+ "version": "0.0.0-reference",
4
+ "private": true,
5
+ "description": "Reference KMS-backed cryptoProvider for Haechi (keys.provider: external). Promoted to a published @haechi/* satellite in 0.8.",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./index.mjs"
9
+ },
10
+ "peerDependencies": {
11
+ "haechi": ">=0.7.0"
12
+ },
13
+ "optionalDependencies": {
14
+ "@aws-sdk/client-kms": "^3"
15
+ },
16
+ "engines": {
17
+ "node": ">=22"
18
+ }
19
+ }
@@ -47,7 +47,12 @@
47
47
  },
48
48
  "audit": {
49
49
  "sink": "jsonl",
50
- "path": ".haechi/audit.jsonl"
50
+ "path": ".haechi/audit.jsonl",
51
+ "anchor": {
52
+ "mode": "none",
53
+ "path": ".haechi/audit.anchor.jsonl",
54
+ "everyRecords": 1
55
+ }
51
56
  },
52
57
  "tokenVault": {
53
58
  "provider": "local",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haechi",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Experimental developer preview for self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -55,6 +55,7 @@
55
55
  "haechi.config.example.json",
56
56
  "packages/",
57
57
  "examples/",
58
+ "scripts/release-checksums.mjs",
58
59
  "docs/current/"
59
60
  ],
60
61
  "scripts": {
@@ -63,6 +64,7 @@
63
64
  "pack:dry": "npm pack --dry-run",
64
65
  "scan:stale-names": "node scripts/stale-name-scan.mjs",
65
66
  "sbom": "node scripts/generate-sbom.mjs",
67
+ "checksums": "node scripts/release-checksums.mjs",
66
68
  "bench:payload": "node scripts/bench-payload.mjs",
67
69
  "release:preflight": "node scripts/release-preflight.mjs",
68
70
  "release:preflight:npm": "node scripts/release-preflight.mjs --require-npm-auth",
@@ -7,20 +7,55 @@ import { setTimeout as delay } from "node:timers/promises";
7
7
 
8
8
  const FORBIDDEN_KEYS = new Set(["value", "plaintext", "payload", "content", "message", "prompt", "secret"]);
9
9
 
10
- export function createJsonlAuditSink({ path }) {
10
+ export function createJsonlAuditSink({ path, anchor = null }) {
11
11
  if (!path) {
12
12
  throw new Error("JSONL audit sink requires path");
13
13
  }
14
+ const anchorMode = anchor?.mode ?? "none";
15
+ const anchorPath = anchor?.path ?? null;
16
+ const everyRecords = anchor?.everyRecords ?? 1;
17
+ if (!["none", "file", "stdout"].includes(anchorMode)) {
18
+ throw new Error(`Invalid audit anchor mode: ${anchorMode}`);
19
+ }
20
+ if (anchorMode === "file" && !anchorPath) {
21
+ throw new Error("audit anchor mode 'file' requires an anchor path");
22
+ }
23
+ // The sink is a public export reachable via auditSink injection, so it
24
+ // validates everyRecords itself rather than trusting normalizeConfig.
25
+ if (!Number.isInteger(everyRecords) || everyRecords < 1) {
26
+ throw new Error("audit anchor everyRecords must be a positive integer");
27
+ }
14
28
 
15
29
  let writeQueue = Promise.resolve();
16
30
 
31
+ async function writeAnchor(record) {
32
+ const { sequence, eventHash } = record.auditIntegrity;
33
+ // Tamper-evidence against tail truncation: the chain head is appended to a
34
+ // separate append-only stream, so deleting trailing records leaves the
35
+ // chain shorter than the last anchored sequence.
36
+ if (anchorMode === "none" || sequence % everyRecords !== 0) {
37
+ return;
38
+ }
39
+ const line = `${JSON.stringify({ sequence, eventHash, timestamp: record.timestamp })}\n`;
40
+ if (anchorMode === "stdout") {
41
+ process.stdout.write(line);
42
+ } else {
43
+ await mkdir(dirname(anchorPath), { recursive: true });
44
+ // 0600 on creation, like the key/lock files. Note this only matters for
45
+ // confidentiality of the timeline — tamper-evidence still requires the
46
+ // anchor to live on append-only/separate media (see docs).
47
+ await appendFile(anchorPath, line, { mode: 0o600 });
48
+ }
49
+ }
50
+
17
51
  return {
18
52
  id: "haechi.audit.jsonl",
19
53
  version: "0.1.0",
20
54
  capabilities: {
21
55
  writesAudit: true,
22
56
  writesPlaintext: false,
23
- integrity: "sha256-hash-chain"
57
+ appendOnly: true,
58
+ integrity: anchorMode === "none" ? "sha256-hash-chain" : "sha256-hash-chain+anchor"
24
59
  },
25
60
  async record(event) {
26
61
  const write = writeQueue.then(async () => {
@@ -28,6 +63,7 @@ export function createJsonlAuditSink({ path }) {
28
63
  await withFileLock(`${path}.lock`, async () => {
29
64
  const record = await buildIntegrityRecord(path, sanitizeAudit(event));
30
65
  await appendFile(path, `${JSON.stringify(record)}\n`, "utf8");
66
+ await writeAnchor(record);
31
67
  });
32
68
  });
33
69
  writeQueue = write.catch(() => {});
@@ -87,7 +123,12 @@ export function sanitizeAudit(value) {
87
123
  return value;
88
124
  }
89
125
 
90
- export async function verifyAuditChain(path) {
126
+ export async function verifyAuditChain(path, { anchorPath = null } = {}) {
127
+ // The anchor stream (if provided) records the chain head at past points; a
128
+ // chain shorter than the last anchor, or a hash that disagrees with an
129
+ // anchor, is tail truncation / tampering the chain alone cannot catch.
130
+ const anchors = anchorPath ? await readAnchors(anchorPath) : null;
131
+
91
132
  const lines = createInterface({
92
133
  input: createReadStream(path, { encoding: "utf8" }),
93
134
  crlfDelay: Infinity
@@ -122,14 +163,66 @@ export async function verifyAuditChain(path) {
122
163
  return { valid: false, records, reason: "event hash mismatch" };
123
164
  }
124
165
 
166
+ if (anchors && anchors.bySequence.has(expectedSequence)
167
+ && anchors.bySequence.get(expectedSequence) !== eventHash) {
168
+ return { valid: false, records, reason: `anchor hash mismatch at sequence ${expectedSequence}` };
169
+ }
170
+
125
171
  expectedPreviousHash = eventHash;
126
172
  expectedSequence += 1;
127
173
  records += 1;
128
174
  }
129
175
 
130
- // headHash anchors the chain externally: publishing it out-of-band is the
131
- // only defense against tail truncation, which the chain alone cannot detect.
132
- return { valid: true, records, headHash: expectedPreviousHash };
176
+ if (anchors && anchors.lastSequence > records) {
177
+ return {
178
+ valid: false,
179
+ records,
180
+ reason: `tail truncation: chain has ${records} records but anchor attests sequence ${anchors.lastSequence}`
181
+ };
182
+ }
183
+
184
+ // headHash anchors the chain externally. With anchorPath, truncation back to
185
+ // the last anchor is now detected; the residual gap is records written after
186
+ // the last anchor.
187
+ const result = { valid: true, records, headHash: expectedPreviousHash };
188
+ if (anchors) {
189
+ result.anchored = { count: anchors.bySequence.size, lastSequence: anchors.lastSequence };
190
+ }
191
+ return result;
192
+ }
193
+
194
+ async function readAnchors(anchorPath) {
195
+ const bySequence = new Map();
196
+ let lastSequence = 0;
197
+ try {
198
+ const lines = createInterface({
199
+ input: createReadStream(anchorPath, { encoding: "utf8" }),
200
+ crlfDelay: Infinity
201
+ });
202
+ for await (const line of lines) {
203
+ if (!line.trim()) {
204
+ continue;
205
+ }
206
+ // A crash can leave a partial trailing anchor line; tolerate it (skip)
207
+ // rather than failing the whole verification. The chain check plus the
208
+ // remaining valid anchors still bound truncation detection.
209
+ let anchor;
210
+ try {
211
+ anchor = JSON.parse(line);
212
+ } catch {
213
+ continue;
214
+ }
215
+ if (typeof anchor.sequence === "number" && typeof anchor.eventHash === "string") {
216
+ bySequence.set(anchor.sequence, anchor.eventHash);
217
+ lastSequence = Math.max(lastSequence, anchor.sequence);
218
+ }
219
+ }
220
+ } catch (error) {
221
+ if (error.code !== "ENOENT") {
222
+ throw error;
223
+ }
224
+ }
225
+ return { bySequence, lastSequence };
133
226
  }
134
227
 
135
228
  async function buildIntegrityRecord(path, event) {
@@ -147,19 +147,27 @@ async function reportCommand(argv) {
147
147
  async function auditVerifyCommand(argv) {
148
148
  const options = parseOptions(argv);
149
149
  let auditPath = options.audit ?? options.path;
150
- if (!auditPath) {
150
+ let anchorPath = typeof options.anchor === "string" ? options.anchor : null;
151
+ if (!auditPath || (options.anchor === true && !anchorPath)) {
151
152
  try {
152
- auditPath = (await loadConfig(options.config ?? DEFAULT_CONFIG_PATH)).audit.path;
153
+ const config = await loadConfig(options.config ?? DEFAULT_CONFIG_PATH);
154
+ auditPath = auditPath ?? config.audit.path;
155
+ // --anchor with no value (or no flag at all) falls back to the configured
156
+ // anchor path when anchoring is enabled.
157
+ if (!anchorPath && config.audit.anchor.mode === "file") {
158
+ anchorPath = config.audit.anchor.path;
159
+ }
153
160
  } catch {
154
- auditPath = ".haechi/audit.jsonl";
161
+ auditPath = auditPath ?? ".haechi/audit.jsonl";
155
162
  }
156
163
  }
157
164
 
158
- const result = await verifyAuditChain(auditPath);
165
+ const result = await verifyAuditChain(auditPath, { anchorPath });
159
166
  writeJson({
160
167
  ok: result.valid,
161
168
  command: "audit-verify",
162
169
  auditPath,
170
+ anchorPath,
163
171
  result
164
172
  });
165
173
  if (!result.valid) {
@@ -208,17 +216,30 @@ async function statusCommand(argv) {
208
216
  warnings.push(`key file ${config.keys.keyFile} does not exist; run haechi init`);
209
217
  }
210
218
 
211
- const audit = { path: config.audit.path, exists: false, chain: null };
219
+ const anchorEnabled = config.audit.anchor.mode === "file";
220
+ const audit = {
221
+ path: config.audit.path,
222
+ exists: false,
223
+ chain: null,
224
+ anchor: { mode: config.audit.anchor.mode, path: anchorEnabled ? config.audit.anchor.path : null }
225
+ };
212
226
  try {
213
227
  await stat(config.audit.path);
214
228
  audit.exists = true;
215
- audit.chain = await verifyAuditChain(config.audit.path);
229
+ audit.chain = await verifyAuditChain(config.audit.path, {
230
+ anchorPath: anchorEnabled ? config.audit.anchor.path : null
231
+ });
216
232
  if (!audit.chain.valid) {
217
233
  warnings.push(`audit chain verification failed: ${audit.chain.reason}`);
218
234
  }
219
235
  } catch {
220
236
  // No audit file yet is a normal pre-first-run state, not a warning.
221
237
  }
238
+ if (config.audit.anchor.mode === "none") {
239
+ warnings.push("audit.anchor.mode is none: tail truncation of the audit log cannot be detected");
240
+ } else if (config.audit.anchor.mode === "file") {
241
+ warnings.push("audit.anchor: real tail-truncation defense requires the anchor on append-only or separate media; on the same writable filesystem an attacker can truncate both files together");
242
+ }
222
243
 
223
244
  writeJson({
224
245
  ok: true,
@@ -566,9 +587,9 @@ const COMMAND_HELP = {
566
587
  summary: "Summarize audit events without raw payloads."
567
588
  },
568
589
  "audit-verify": {
569
- usage: "haechi audit-verify [--audit .haechi/audit.jsonl] [--config haechi.config.json]",
590
+ usage: "haechi audit-verify [--audit .haechi/audit.jsonl] [--anchor [path]] [--config haechi.config.json]",
570
591
  summary: "Verify the audit hash chain; print validity, record count, and head hash.",
571
- detail: "Exit 4 on a broken chain. The head hash is the value to anchor externally against tail truncation."
592
+ detail: "Exit 4 on a broken chain. With --anchor (or audit.anchor.mode: file in config) it cross-checks the anchor stream and detects tail truncation back to the last anchor. The anchor only adds real defense when kept on append-only or separate media — on the same writable filesystem an attacker can truncate both files together."
572
593
  },
573
594
  status: {
574
595
  usage: "haechi status [--config haechi.config.json]",
@@ -698,6 +719,17 @@ Tokenization (model sees token, caller sees plaintext)
698
719
  tokenVault.detokenizeResponses restore request-issued tokens in the response
699
720
  (needs responseProtection.enabled)
700
721
 
722
+ Audit integrity
723
+ audit.anchor.mode none | file | stdout (default none)
724
+ file/stdout anchor the chain head so tail
725
+ truncation is detected (haechi audit-verify --anchor).
726
+ Real defense needs the anchor on append-only or
727
+ separate media; same-filesystem anchors can be
728
+ truncated together. stdout mode is for long-running
729
+ commands (proxy), not JSON-emitting ones.
730
+ audit.anchor.path .haechi/audit.anchor.jsonl (mode: file)
731
+ audit.anchor.everyRecords anchor cadence (default 1)
732
+
701
733
  Privacy + MCP
702
734
  privacy.profile kr-pipa | eu-gdpr | us-general | null
703
735
  mcp.allowedMethods client-callable method allowlist
@@ -60,7 +60,12 @@ export function defaultConfig() {
60
60
  },
61
61
  audit: {
62
62
  sink: "jsonl",
63
- path: ".haechi/audit.jsonl"
63
+ path: ".haechi/audit.jsonl",
64
+ anchor: {
65
+ mode: "none",
66
+ path: ".haechi/audit.anchor.jsonl",
67
+ everyRecords: 1
68
+ }
64
69
  },
65
70
  tokenVault: {
66
71
  provider: "local",
@@ -117,7 +122,18 @@ export function createRuntime(config, providers = {}) {
117
122
  const normalized = normalizeConfig(config);
118
123
  const cryptoProvider = providers.cryptoProvider ?? createConfiguredCryptoProvider(normalized);
119
124
  assertProvider("cryptoProvider", cryptoProvider, ["encrypt", "decrypt"]);
120
- const auditSink = providers.auditSink ?? createJsonlAuditSink({ path: normalized.audit.path });
125
+ // hmac is only required by features that use it (bearer auth, deterministic
126
+ // tokenization). An encrypt-only external provider is valid otherwise; fail
127
+ // closed at construction rather than deep in a request if a needing feature
128
+ // is configured without it.
129
+ if (typeof cryptoProvider.hmac !== "function"
130
+ && (normalized.auth.provider === "bearer" || normalized.tokenVault.deterministic)) {
131
+ throw new Error("cryptoProvider must implement hmac() for bearer auth / deterministic tokenization");
132
+ }
133
+ const auditSink = providers.auditSink ?? createJsonlAuditSink({
134
+ path: normalized.audit.path,
135
+ anchor: normalized.audit.anchor
136
+ });
121
137
  assertProvider("auditSink", auditSink, ["record"]);
122
138
  const tokenVault = providers.tokenVault ?? createLocalTokenVault({
123
139
  path: normalized.tokenVault.path,
@@ -214,7 +230,11 @@ export function normalizeConfig(config) {
214
230
  },
215
231
  audit: {
216
232
  ...defaultConfig().audit,
217
- ...(config.audit ?? {})
233
+ ...(config.audit ?? {}),
234
+ anchor: {
235
+ ...defaultConfig().audit.anchor,
236
+ ...(config.audit?.anchor ?? {})
237
+ }
218
238
  },
219
239
  tokenVault: {
220
240
  ...defaultConfig().tokenVault,
@@ -248,6 +268,16 @@ export function normalizeConfig(config) {
248
268
  if (merged.audit.sink !== "jsonl") {
249
269
  throw new Error("Current implementation only supports jsonl audit sink");
250
270
  }
271
+ if (!["none", "file", "stdout"].includes(merged.audit.anchor.mode)) {
272
+ throw new Error(`Invalid audit.anchor.mode: ${merged.audit.anchor.mode}`);
273
+ }
274
+ if (merged.audit.anchor.mode === "file"
275
+ && (typeof merged.audit.anchor.path !== "string" || !merged.audit.anchor.path.trim())) {
276
+ throw new Error("audit.anchor.mode 'file' requires audit.anchor.path");
277
+ }
278
+ if (!Number.isInteger(merged.audit.anchor.everyRecords) || merged.audit.anchor.everyRecords < 1) {
279
+ throw new Error("audit.anchor.everyRecords must be a positive integer");
280
+ }
251
281
  if (merged.tokenVault.provider !== "local") {
252
282
  throw new Error("0.2 only supports local token vault provider");
253
283
  }
@@ -139,6 +139,113 @@ export async function initLocalKeyFile(keyFile, { force = false } = {}) {
139
139
  return { created: true, keyFile, rotated: retiredKeys.length > 0 };
140
140
  }
141
141
 
142
+ // Conformance suite for any cryptoProvider used via keys.provider: external.
143
+ // Adapter authors (e.g. a KMS satellite) run this to self-test against the
144
+ // contract. encrypt/decrypt are always required; hmac is required for
145
+ // tokenization, auth, deterministic tokens, and policy bundles — pass
146
+ // { requireHmac: false } for an encrypt-only provider.
147
+ export async function assertCryptoProviderConformance(provider, { requireHmac = true } = {}) {
148
+ const failures = [];
149
+ const check = async (name, fn) => {
150
+ try {
151
+ await fn();
152
+ } catch (error) {
153
+ failures.push(`${name}: ${error.message}`);
154
+ }
155
+ };
156
+ const assert = (condition, message) => {
157
+ if (!condition) {
158
+ throw new Error(message);
159
+ }
160
+ };
161
+
162
+ if (typeof provider?.encrypt !== "function" || typeof provider?.decrypt !== "function") {
163
+ throw new Error("cryptoProvider must implement encrypt() and decrypt()");
164
+ }
165
+
166
+ const plaintext = `conformance-${randomBytes(8).toString("hex")}@example.com`;
167
+ const aad = { purpose: "conformance", path: "messages[0].content", type: "email" };
168
+
169
+ const other = `conformance-${randomBytes(8).toString("hex")}@example.org`;
170
+
171
+ await check("encrypt/decrypt round-trip", async () => {
172
+ const envelope = await provider.encrypt({ plaintext, aad });
173
+ assert(envelope && typeof envelope === "object", "encrypt must return an envelope object");
174
+ assert(envelope.kid, "envelope must carry a key id (kid)");
175
+ assert(envelope.aadHash, "envelope must carry an aadHash");
176
+ const back = await provider.decrypt({ envelope, aad });
177
+ assert(back === plaintext, "decrypt did not return the original plaintext");
178
+ // A second, distinct plaintext rules out a decrypt that returns a fixed value.
179
+ const back2 = await provider.decrypt({ envelope: await provider.encrypt({ plaintext: other, aad }), aad });
180
+ assert(back2 === other, "decrypt did not return the second plaintext (fixed/garbage output)");
181
+ });
182
+
183
+ await check("decrypt rejects a different AAD", async () => {
184
+ const envelope = await provider.encrypt({ plaintext, aad });
185
+ let rejected = false;
186
+ try {
187
+ await provider.decrypt({ envelope, aad: { ...aad, type: "phone" } });
188
+ } catch {
189
+ rejected = true;
190
+ }
191
+ assert(rejected, "decrypt accepted a mismatched AAD (no AAD binding)");
192
+ });
193
+
194
+ await check("decrypt rejects tampered ciphertext (real AEAD authentication)", async () => {
195
+ const envelope = await provider.encrypt({ plaintext, aad });
196
+ if (typeof envelope.ct !== "string" || envelope.ct.length === 0) {
197
+ return; // provider uses a non-ct envelope shape; the AAD check above still applies
198
+ }
199
+ // Flip a byte of the ciphertext; a real AEAD provider fails the auth tag.
200
+ const buf = Buffer.from(envelope.ct, "base64url");
201
+ buf[0] ^= 0xff;
202
+ let rejected = false;
203
+ try {
204
+ await provider.decrypt({ envelope: { ...envelope, ct: buf.toString("base64url") }, aad });
205
+ } catch {
206
+ rejected = true;
207
+ }
208
+ assert(rejected, "decrypt accepted tampered ciphertext (no AEAD authentication)");
209
+ });
210
+
211
+ if (requireHmac) {
212
+ if (typeof provider.hmac !== "function") {
213
+ failures.push("hmac: provider does not implement hmac() (required for tokenization/auth/bundles)");
214
+ } else {
215
+ await check("hmac is deterministic and data-dependent", async () => {
216
+ const a = await provider.hmac({ data: "x", domain: "haechi:conformance:v1" });
217
+ const b = await provider.hmac({ data: "x", domain: "haechi:conformance:v1" });
218
+ assert(typeof a === "string" && a.length > 0, "hmac must return a non-empty string");
219
+ assert(a === b, "hmac is not deterministic for the same (data, domain)");
220
+ // Different data MUST give different output — else tokens/identities collide.
221
+ const c = await provider.hmac({ data: "y", domain: "haechi:conformance:v1" });
222
+ assert(a !== c, "hmac ignores the data argument (same output for different data)");
223
+ });
224
+ await check("hmac separates domains", async () => {
225
+ const a = await provider.hmac({ data: "x", domain: "haechi:conformance:a" });
226
+ const b = await provider.hmac({ data: "x", domain: "haechi:conformance:b" });
227
+ assert(a !== b, "hmac does not separate domains (same output for different domains)");
228
+ });
229
+ await check("hmac requires a domain", async () => {
230
+ for (const badDomain of ["", undefined, null]) {
231
+ let rejected = false;
232
+ try {
233
+ await provider.hmac({ data: "x", domain: badDomain });
234
+ } catch {
235
+ rejected = true;
236
+ }
237
+ assert(rejected, `hmac accepted an invalid domain (${JSON.stringify(badDomain)})`);
238
+ }
239
+ });
240
+ }
241
+ }
242
+
243
+ if (failures.length > 0) {
244
+ throw new Error(`cryptoProvider conformance failed:\n- ${failures.join("\n- ")}`);
245
+ }
246
+ return { ok: true };
247
+ }
248
+
142
249
  export function canonicalize(value) {
143
250
  if (Array.isArray(value)) {
144
251
  return `[${value.map((item) => canonicalize(item)).join(",")}]`;
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ // Generate or verify a SHA256SUMS manifest for release artifacts.
3
+ //
4
+ // node scripts/release-checksums.mjs <file...> # print "<hash> <name>" lines
5
+ // node scripts/release-checksums.mjs --check SHA256SUMS # verify files against a manifest
6
+ //
7
+ // Standard `<sha256-hex> <basename>` format (two spaces), so `sha256sum -c`
8
+ // and `shasum -a 256 -c` interoperate with what this prints.
9
+
10
+ import { createHash } from "node:crypto";
11
+ import { createReadStream } from "node:fs";
12
+ import { readFile } from "node:fs/promises";
13
+ import { basename, dirname, isAbsolute, join, relative } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ export async function sha256File(path) {
17
+ const hash = createHash("sha256");
18
+ for await (const chunk of createReadStream(path)) {
19
+ hash.update(chunk);
20
+ }
21
+ return hash.digest("hex");
22
+ }
23
+
24
+ export function formatManifestLine(hashHex, name) {
25
+ return `${hashHex} ${name}`;
26
+ }
27
+
28
+ export function parseManifest(text) {
29
+ return text
30
+ .split(/\r?\n/)
31
+ .map((line) => line.trim())
32
+ .filter(Boolean)
33
+ .map((line) => {
34
+ const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line);
35
+ if (!match) {
36
+ throw new Error(`Malformed SHA256SUMS line: ${line}`);
37
+ }
38
+ return { hash: match[1], name: match[2] };
39
+ });
40
+ }
41
+
42
+ export async function generateManifest(files) {
43
+ const lines = [];
44
+ for (const file of files) {
45
+ lines.push(formatManifestLine(await sha256File(file), basename(file)));
46
+ }
47
+ return `${lines.join("\n")}\n`;
48
+ }
49
+
50
+ export async function verifyManifest(manifestPath) {
51
+ const baseDir = dirname(manifestPath);
52
+ const entries = parseManifest(await readFile(manifestPath, "utf8"));
53
+ const results = [];
54
+ for (const entry of entries) {
55
+ // A manifest is untrusted input: never hash a path that escapes the
56
+ // manifest's own directory (no absolute paths, no `../` traversal).
57
+ const rel = relative(baseDir, join(baseDir, entry.name));
58
+ if (isAbsolute(entry.name) || rel.startsWith("..")) {
59
+ results.push({ name: entry.name, ok: false, reason: "unsafe path" });
60
+ continue;
61
+ }
62
+ let actual = null;
63
+ try {
64
+ actual = await sha256File(join(baseDir, entry.name));
65
+ } catch (error) {
66
+ results.push({ name: entry.name, ok: false, reason: error.code === "ENOENT" ? "missing" : error.message });
67
+ continue;
68
+ }
69
+ results.push({ name: entry.name, ok: actual === entry.hash, reason: actual === entry.hash ? null : "hash mismatch" });
70
+ }
71
+ return { ok: results.every((r) => r.ok), results };
72
+ }
73
+
74
+ async function main(argv) {
75
+ if (argv[0] === "--check") {
76
+ const manifestPath = argv[1];
77
+ if (!manifestPath) {
78
+ throw new Error("--check requires a SHA256SUMS path");
79
+ }
80
+ const { ok, results } = await verifyManifest(manifestPath);
81
+ for (const r of results) {
82
+ process.stderr.write(`${r.ok ? "OK " : "FAIL"} ${r.name}${r.reason ? ` (${r.reason})` : ""}\n`);
83
+ }
84
+ process.exitCode = ok ? 0 : 1;
85
+ return;
86
+ }
87
+ if (argv.length === 0) {
88
+ throw new Error("usage: release-checksums.mjs <file...> | --check SHA256SUMS");
89
+ }
90
+ process.stdout.write(await generateManifest(argv));
91
+ }
92
+
93
+ // Run only as a CLI (not when imported by tests). fileURLToPath handles
94
+ // Windows paths and URL encoding that a raw `file://` compare would miss.
95
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
96
+ main(process.argv.slice(2)).catch((error) => {
97
+ process.stderr.write(`release-checksums: ${error.message}\n`);
98
+ process.exitCode = 1;
99
+ });
100
+ }