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 +5 -0
- package/README.md +5 -0
- package/docs/README.md +1 -0
- package/docs/current/api-stability.ko.md +4 -1
- package/docs/current/api-stability.md +4 -1
- package/docs/current/release-0.7-implementation-scope.ko.md +91 -0
- package/docs/current/release-0.7-implementation-scope.md +92 -0
- package/docs/current/release-process.ko.md +26 -3
- package/docs/current/release-process.md +26 -3
- package/docs/current/risk-register-release-gate.ko.md +2 -2
- package/docs/current/risk-register-release-gate.md +3 -3
- package/docs/current/threat-model.ko.md +5 -2
- package/docs/current/threat-model.md +5 -2
- package/examples/crypto-kms-reference/README.md +47 -0
- package/examples/crypto-kms-reference/index.mjs +133 -0
- package/examples/crypto-kms-reference/package.json +19 -0
- package/haechi.config.example.json +6 -1
- package/package.json +3 -1
- package/packages/audit/index.mjs +99 -6
- package/packages/cli/bin/haechi.mjs +40 -8
- package/packages/cli/runtime.mjs +33 -3
- package/packages/crypto/index.mjs +107 -0
- package/scripts/release-checksums.mjs +100 -0
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.
|
|
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.
|
|
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.
|
|
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
|
|
69
|
+
| `.github/workflows/npm-publish.yml` | GitHub release published 이벤트에서 npm provenance publish + 체크섬/증명 release 자산 |
|
|
47
70
|
|
|
48
|
-
##
|
|
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.
|
|
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
|
|
69
|
+
| `.github/workflows/npm-publish.yml` | npm provenance publish + checksummed/attested release assets on GitHub release published |
|
|
47
70
|
|
|
48
|
-
##
|
|
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.
|
|
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 |
|
|
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.
|
|
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
|
|
133
|
-
| 0.
|
|
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.
|
|
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
|
-
-
|
|
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.
|
|
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
|
|
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.
|
|
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",
|
package/packages/audit/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
150
|
+
let anchorPath = typeof options.anchor === "string" ? options.anchor : null;
|
|
151
|
+
if (!auditPath || (options.anchor === true && !anchorPath)) {
|
|
151
152
|
try {
|
|
152
|
-
|
|
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
|
|
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.
|
|
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
|
package/packages/cli/runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|