haechi 0.7.0 → 0.9.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.
Files changed (31) hide show
  1. package/README.ko.md +13 -2
  2. package/README.md +13 -2
  3. package/docs/current/api-stability.ko.md +14 -1
  4. package/docs/current/api-stability.md +14 -1
  5. package/docs/current/configuration.ko.md +106 -2
  6. package/docs/current/configuration.md +106 -2
  7. package/docs/current/release-0.6-implementation-scope.ko.md +4 -4
  8. package/docs/current/release-0.6-implementation-scope.md +4 -4
  9. package/docs/current/release-0.7-implementation-scope.ko.md +4 -4
  10. package/docs/current/release-0.7-implementation-scope.md +4 -4
  11. package/docs/current/release-0.8-implementation-scope.ko.md +145 -0
  12. package/docs/current/release-0.8-implementation-scope.md +145 -0
  13. package/docs/current/release-0.9-implementation-scope.ko.md +231 -0
  14. package/docs/current/release-0.9-implementation-scope.md +231 -0
  15. package/docs/current/release-process.ko.md +42 -5
  16. package/docs/current/release-process.md +42 -5
  17. package/docs/current/risk-register-release-gate.ko.md +18 -5
  18. package/docs/current/risk-register-release-gate.md +16 -4
  19. package/docs/current/threat-model.ko.md +16 -1
  20. package/docs/current/threat-model.md +16 -1
  21. package/examples/crypto-kms-reference/README.md +6 -40
  22. package/haechi.config.example.json +2 -1
  23. package/package.json +7 -1
  24. package/packages/audit/index.mjs +12 -1
  25. package/packages/auth/index.mjs +45 -0
  26. package/packages/cli/runtime.mjs +5 -1
  27. package/packages/core/index.mjs +4 -0
  28. package/packages/filter/index.mjs +58 -3
  29. package/packages/proxy/index.mjs +3 -0
  30. package/examples/crypto-kms-reference/index.mjs +0 -133
  31. package/examples/crypto-kms-reference/package.json +0 -19
@@ -0,0 +1,145 @@
1
+ # Haechi 0.8 구현 범위
2
+
3
+ - 상태: Draft 0.3 (설계 — 아직 미구현)
4
+ - 작성일: 2026-06-10
5
+ - 대상 버전: 0.8.0 (0.7.0 이후)
6
+ - 유형: 에코시스템 기반
7
+
8
+ ## 1. 릴리스 목표
9
+
10
+ `haechi-*` 패키지 에코시스템을 띄운다: 저장소를 npm workspaces 모노레포로 전환하고 첫 두 satellite를 배포한다 — `haechi-crypto-kms`(0.7 reference 승격)와 `haechi-auth-jwt`(JWKS bearer 검증), 둘 다 unscoped(`@haechi` scope는 점유됨; org 불필요). 이로써 운영 키 custody를 설치 가능한 패키지로 실현하고, core의 zero-dependency 자세를 건드리지 않으면서 auth 에코시스템을 키운다.
11
+
12
+ **범위 결정(2026-06-10):** 0.8은 **패키징 기반 + satellite**다. `haechi-dashboard` 읽기 전용 audit 뷰어(UI 빌드)와 완전한 대화형 `haechi-auth-oidc`는 **0.9**로 이동하여, 0.8은 코드가 가볍고 모노레포 + 두 개의 헤드리스 친화적 어댑터에 집중한다.
13
+
14
+ Core(`haechi`, unscoped)는 **zero runtime dependency**를 유지한다. satellite 의존성(예: AWS SDK)은 해당 satellite의 `package.json`에만 존재하며 core의 tarball이나 SBOM에 절대 들어가지 않는다.
15
+
16
+ ## 2. 범위
17
+
18
+ ### 2.1 npm workspaces 모노레포 (검증된 resolution 메커니즘)
19
+
20
+ 순진한 레이아웃(`"workspaces": ["satellites/*"]` + satellite peer 범위 `"haechi": ">=0.8.0"`)은 **동작하지 않으며** 경험적 테스트 후 기각했다: npm은 충족되지 않은 peer 범위를 레지스트리 조회로 취급하고(`ETARGET: no matching version for haechi@>=0.8.0`), 루트 프로젝트를 `node_modules/haechi`로 심링크하지 않으며, satellite의 `import "haechi/crypto"`가 `ERR_MODULE_NOT_FOUND`로 던진다. 루트 프로젝트는 기본적으로 workspace 멤버가 **아니므로** npm이 절대 링크하지 않는다.
21
+
22
+ 검증된 동작 레이아웃:
23
+
24
+ - **루트가 자기 자신을 workspace 멤버로 등록한다:** `"workspaces": [".", "satellites/*"]`. `"."` 항목이 npm으로 하여금 `node_modules/haechi → ..` 심링크를 만들게 해 satellite가 core를 resolve한다. 이게 없으면 satellite는 레지스트리로 fallback한다.
25
+ - **저장소 루트는 배포되는 `haechi` 패키지로 그대로 남는다** — `exports`, `bin`, `files` 허용목록은 변하지 않는다. `package.json` 변경은 추가된 `workspaces` 필드와 버전 bump뿐이다. satellite는 core의 `files`에 없으므로 `haechi` tarball 안으로 절대 실리지 않는다(검증됨: 루트 `npm pack --dry-run`은 `files` 허용목록만 나열하고 `satellites/`는 제외된다).
26
+ - **satellite는 core에 대한 이중 의존성을 선언한다:**
27
+ - `"peerDependencies": { "haechi": ">=0.8.0 <1.0.0" }` — *소비자*가 설치할 때의 계약. satellite는 소비자의 단일 `haechi` 인스턴스를 재사용한다(crypto/identity 표면 하나, 중복 사본 없음).
28
+ - `"devDependencies": { "haechi": "*" }` — 모노레포 개발/CI 중에 npm이 peer 범위를 레지스트리에서 resolve하지 않고 **로컬 workspace**를 링크하게 만드는 메커니즘. `npm pack`은 배포 tarball에서 devDependencies를 제거하므로 소비자 매니페스트에는 peer 범위만 남고 `*` devDep은 다운스트림에서 보이지 않는다. (`satellites/*/package.json`을 읽는 저장소/소스 스캐너는 여전히 그것을 본다. 이 source-vs-artifact 차이는 예상된 것이고 무해하다.)
29
+ - **소비자 peer-mismatch 동작:** 배포된 satellite를 *비호환* `haechi`(예: 이미 설치된 `haechi@0.7.0`)에 설치하면 hard failure가 아니라 npm `ERESOLVE` **경고**가 난다; 소비자는 `haechi`를 올리거나(자기 책임으로 `--legacy-peer-deps`) 해야 한다. satellite는 범위를 벗어난 core에서 올바르게 동작하지 않는다.
30
+ - satellite는 core를 subpath로 import한다(`haechi/crypto`, `haechi/auth`, `haechi/runtime`). 개발 중에는 workspace 심링크로, 프로덕션에서는 소비자가 설치한 `haechi`로 resolve된다.
31
+ - `examples/crypto-kms-reference/`는 `satellites/crypto-kms/`로 **승격**한다. reference 예제는 workspaces 이전에 중첩 `package.json`이 `haechi/crypto`를 self-resolve하지 못해 `canonicalize`를 인라인했었다; **workspaces 하에서는 그 import가 resolve되므로** satellite는 사본을 들고 다니지 않고 `haechi/crypto`에서 `canonicalize`를 import한다(core와 satellite 간 AAD 정규화 drift 방지). 기존 `examples/` 디렉터리는 배포된 패키지를 가리키는 짧은 README만 남긴다. conformance 테스트가 satellite의 AAD 정규화가 `haechi/crypto`와 **byte-for-byte 동일**함을 검증한다(의미적 동등이 아니라).
32
+ - **lock 파일:** workspaces 전환은 `package-lock.json`을 workspace-resolve된 항목(루트 자기 멤버 포함)으로 재생성한다. 재생성된 lock 파일을 커밋한다; CI는 `npm ci`(stale/누락 lock이면 실패)를 쓰므로, 전환 PR이 새 lock을 커밋해야 한다.
33
+
34
+ **CI 전략(중복 실행 방지):** 루트 CI는 루트에서 `node --test`를 직접 돌리며, 이는 `satellites/**/*.test.mjs`를 자동 발견한다(workspace 심링크 덕에 그들의 `haechi/*` import가 resolve됨). CI는 `npm test --workspaces`를 **쓰지 않는다**(루트 자기 멤버로 재귀해 스위트를 다시 돌릴 것이다). 각 satellite는 로컬 단독 실행용(`npm test -w haechi-crypto-kms`) 자체 `test` 스크립트만 유지한다. 검증됨: 루트 `node --test`가 core + satellite 테스트를 한 번 실행하고, `node_modules/haechi → ..` 심링크 순환에도 runner가 hang하지 않는다(node는 `node_modules`를 건너뜀).
35
+
36
+ **정직한 패키징 노트(기존 "byte-stable" 주장 대체):** 루트 tarball은 0.7.0과 byte-identical이 **아니다** — `package.json`이 `workspaces` 필드를 얻고 버전이 bump되며, 이는 어떤 릴리스에서도 예상되는 일이다. 방어 가능하고 테스트된 더 좁은 주장이며 CI 게이트(§6.1)로 강제한다: **(a) satellite 파일이 `haechi` tarball에 나타나지 않고, (b) `haechi` tarball 자체의 `package.json`이 runtime `dependencies`를 0으로 선언한다.** 게이트는 **패킹된 매니페스트**를 검사한다(`npm pack` 출력에서 `package.json`을 추출해 `dependencies`가 비어있음/undefined임을 단언) — 오늘은 공허하게 통과하고 미래의 runtime-dep 누수를 놓칠 설치된 `node_modules` SBOM이 아니라.
37
+
38
+ ### 2.2 unscoped `haechi-*` 이름 + 패키지별 trusted publishing
39
+
40
+ - **네이밍(2026-06-10 결정):** `@haechi` npm org/scope는 제3자가 이미 점유했으므로, satellite는 **unscoped `haechi-*`** 이름으로 발행한다 — `haechi-crypto-kms`, `haechi-auth-jwt`(둘 다 npm에 비어있음 확인). **npm org 불필요**, unscoped core `haechi`와 일관되며, 각 이름을 개별 예약 + Trusted Publisher 바인딩한다. (scope 대비 트레이드오프: 네임스페이스 그룹핑/방어 없음; `haechi-` 접두사가 관례.)
41
+ - 각 satellite는 0.7에서 증명한 **동일한 OIDC trusted-publishing + sigstore + SHA256SUMS** 경로로 배포한다 — 자체 npmjs.com Trusted Publisher 링크와 태그 트리거 배포 워크플로.
42
+ - **satellite `package.json` 요구사항(루트에서 상속 안 됨):** 각 satellite는 여전히 자체 `"publishConfig": { "access": "public", "provenance": true }`를 설정한다. unscoped 패키지는 기본이 public이라 `access: public`은 보조적이고, `provenance: true`와 루트 `publishConfig` 비상속이 핵심이다. 배포 후 런북은 `npm view haechi-<pkg> access`가 `public`을 보고하는지 검증한다.
43
+ - **태그 네임스페이싱 + 워크플로 가드(오트리거·충돌 방지):**
44
+ - core 릴리스 태그: `v<semver>`(예: `v0.8.0`). 루트 배포 워크플로는 `push: tags: ['v[0-9]*.[0-9]*.[0-9]*']`에서 트리거된다. GitHub 태그 glob은 `.`을 리터럴로, `[0-9]*`를 느슨하게 취급하므로(`v1.2.3.4`나 `v1a.2.3`도 매칭됨), 워크플로는 pre-publish 단계에서 엄격한 `^v[0-9]+\.[0-9]+\.[0-9]+$` 정규식으로 **재검증**하고 불일치 시 fail-closed한다.
45
+ - satellite 태그는 **접두사**가 붙는다: `crypto-kms-v<semver>`, `auth-jwt-v<semver>`. 각 satellite 워크플로는 자기 접두사 glob에서만 트리거되고 마찬가지로 `^<prefix>-v[0-9]+\.[0-9]+\.[0-9]+$`로 재검증한다.
46
+ - 각 워크플로는 배포할 패키지 디렉터리를 재확인한다(`npm publish -w <dir>`)므로 잘못 태깅된 push가 엉뚱한 패키지를 배포할 수 없다. npmjs.com의 Trusted Publisher는 **특정 워크플로 파일명**에 바인딩된다 — npm 설정 갱신 없이 워크플로를 rename하면 OIDC auth가 깨진다(런북에 패키지→워크플로-파일명→태그-glob 매핑 표와 함께 실패 모드로 문서화).
47
+ - satellite별 **독립 semver**(satellite patch가 core를 bump하지 않음). satellite는 `0.1.0`에서 시작한다. **pre-1.0 계약:** satellite는 표준 npm semver를 따르며 `0.x` **minor** bump가 breaking change를 담을 수 있다; 소비자는 `major.minor`로 핀해야 한다(예: `haechi-crypto-kms@~0.1`). satellite는 자체 `1.0.0`까지 pre-stable이다.
48
+ - **첫 배포 부트스트랩(org 불필요):** satellite별 순서 — (1) npmjs.com에서 (아직 미발행) unscoped 이름에 대해 repo + 정확한 워크플로 파일명을 연결하는 **Trusted Publisher 설정**; (2) satellite 첫 태그 push → 워크플로의 OIDC 배포가 provenance와 함께 `0.1.0`을 생성하고 첫 발행 시 **이름을 확보**. 노트북에서의 수동 `npm publish`는 필요 없다(0.7 trusted-publishing 자세와 동일). 이름이 unscoped이고 현재 비어있으므로 org-membership 선행 요건이 없다.
49
+
50
+ ### 2.3 `haechi-crypto-kms` (배포 + 실제 KMS 클라이언트)
51
+
52
+ - 0.7 reference(`createKmsCryptoProvider` envelope 암호화 + `createInMemoryKms`)를 배포 패키지로 승격하며, 인라인 `canonicalize`를 `import { canonicalize } from "haechi/crypto"`(§2.1)로 전환한다. 기존 `kms` 클라이언트 인터페이스(`keyId`/`wrap`/`unwrap`/`deriveHmacKey`)는 **변경하지 않으므로** 승격된 provider와 in-memory 클라이언트는 byte-for-byte 동일하고 0.7 테스트가 그대로 넘어온다.
53
+ - **실제 AWS KMS 클라이언트**를 `haechi-crypto-kms/aws`에 추가한다: `createAwsKmsClient({ keyId, region, client, hmacRootCiphertext })`. 동일한 `kms` 인터페이스를 구현한다: `wrap` = CSPRNG로 생성한 32바이트 data key를 KMS `Encrypt`, `unwrap` = KMS `Decrypt`(envelope 암호화 — master key는 KMS를 떠나지 않음); `deriveHmacKey(domain)` = 단일 KMS-`Decrypt`된 32바이트 root(`hmacRootCiphertext`, 캐시)에 대한 **HKDF-SHA256**, domain-separated — 결정적이고 토큰당 네트워크 호출 없음. `hmacRootCiphertext`가 없으면 `deriveHmacKey`는 throw하고 provider는 encrypt-only가 된다(`requireHmac:false`로 유효).
54
+ - **`@aws-sdk/client-kms`는 hard dependency가 아니라 OPTIONAL peer dependency다**(2026-06-10 결정, 기존 "satellite 자체 의존성" 표현을 수정). `client` 미주입 시에만 **lazy import**되므로: 모노레포 `npm ci`/CI는 (대용량) AWS SDK를 절대 받지 않고; in-memory 또는 주입형 클라이언트를 쓰는 소비자는 설치하지 않으며; core는 자명하게 영향받지 않는다. 배포 satellite는 `peerDependencies` + `peerDependenciesMeta.optional`로 선언한다. 이로써 실제 백엔드를 제공하면서도 satellite를 의존성 가볍게 유지한다.
55
+ - satellite CI는 in-memory 클라이언트 **그리고** KMS `encrypt`/`decrypt` ops의 **주입된 mock**(SDK·네트워크 없음)으로 구동되는 AWS 클라이언트에 대해 `assertCryptoProviderConformance`(workspace 심링크로 `haechi/crypto`에서 import)를 실행한다. mock은 충실한 envelope(per-mock master key의 AES-256-GCM)여야 한다: `Decrypt`는 이 키가 wrap한 blob에 대해서만 plaintext를 반환하고, 다른 키가 wrap한 blob(cross-key 격리)과 손상된 blob은 **거부**한다. 항상 성공하는 trivial stub은 불충분하며 스위트가 이 거부 경로와 HMAC 결정성/domain-separation을 실행한다. sandbox KMS 키에 대한 실제 `createAwsKmsClient` 검증은 **CI 밖 통합 테스트**다(문서화, 게이팅 아님).
56
+
57
+ ### 2.4 `haechi-auth-jwt` (JWKS bearer 검증, 의존성 최소)
58
+
59
+ `createJwtAuthProvider({ issuer, audience, jwksUri, cryptoProvider, algorithms, clockSkewSeconds, claimMappings })`는 **헤드리스** 게이트웨이를 위한 `authProvider` 계약을 구현한다. `node:` 빌트인만으로 구현 가능하다(`jose` 없음): JWKS는 전역 `fetch`로, JWK→키는 `crypto.createPublicKey({ key: jwk, format: "jwk" })`, 서명은 `crypto.verify`로 검증한다.
60
+
61
+ **구현 노트 — ES256 서명 인코딩(검증됨):** JWS ES256 서명은 raw `R‖S`(IEEE-P1363, P-256은 64바이트)이지만, `node:crypto.verify`는 EC 키에 대해 기본이 **DER**이고 raw 서명에 `false`를 반환한다 — 유효한 ES256 토큰을 전부 조용히 거부한다. 검증기는 EC 알고리즘에 대해 반드시 `dsaEncoding: "ieee-p1363"`을 넘겨야 한다. (경험적 확인: 기본 DER ⇒ `false`; `ieee-p1363` ⇒ `true`.) 이는 옵션이 아니라 수용 기준이다.
62
+
63
+ **보안 명세(필수 — 옵션이 아니라 수용 기준).** 아래 구체 상수는 구현 재량이 아니라 *결정*이다.
64
+
65
+ - **알고리즘 선택은 서버 측이며, 토큰에서 가져오지 않는다.** 검증기는 설정된 `algorithms` 허용목록(기본 `["RS256","ES256"]`)과 JWK의 `kty`/`crv`에서 알고리즘을 고른다. 토큰의 `alg` 헤더는 키 선택 **전에** 허용목록 *멤버십*만 확인하고, 검증 루틴을 선택하지 않는다.
66
+ - **`alg: "none"`을 무조건 거부**한다.
67
+ - **alg-confusion 차단:** RSA 공개키를 HMAC verify에 절대 넣지 않는다. HMAC 계열(`HS*`)은 기본적으로 **허용하지 않는다**; JWKS에서 온 공개키는 오직 그에 맞는 비대칭 알고리즘과만 쓰인다.
68
+ - **`kid`는 필수**다; 서명 키는 JWKS에서 `kid`로 선택하며, 모든 키를 시도하지 않는다.
69
+ - **RSA 키 강도 하한:** modulus `< 2048` 비트인 RSA JWK는 invalid로 거부한다.
70
+ - **JWK 사용 의도:** JWK에 `use`가 있으면 `sig`여야 하고, `key_ops`가 있으면 `verify`/`sign`을 포함하고 `encrypt`/`decrypt`를 포함하지 않아야 한다. 아니면 거부.
71
+ - **헤더 `typ` / JWE 금지:** `typ`가 있으면 `JWT`여야 한다; 암호화된(JWE) 토큰은 무조건 거부 — JWS만 수용.
72
+ - **클레임은 필수이며 완전 검증한다:**
73
+ - `iss`는 설정된 `issuer`와 같아야 한다.
74
+ - `aud`(토큰)는 문자열 또는 문자열 배열일 수 있다(RFC 7519); 설정된 `audience`는 그 문자열과 같거나 배열의 멤버여야 한다 — 정확, 대소문자 구분 일치.
75
+ - `sub`는 필수이며 비어있지 않은 문자열이어야 한다(`subjectHash`의 입력).
76
+ - `exp`와 `nbf`는 **필수**다. `exp`: `now > exp + clockSkewSeconds`이면 거부. `nbf`: `now < nbf - clockSkewSeconds`이면 거부. `exp`가 없는 토큰은 거부. `iat`가 있으면 sanity-check.
77
+ - **`clockSkewSeconds`** 기본 `60`, **최대 `300`** — `> 300` 값은 생성 시 거부(더 큰 skew는 만료 검증을 무력화).
78
+ - **JWKS fetch는 SSRF-하드닝:**
79
+ - `issuer`는 유효한 **HTTPS URL**이어야 하고; `jwksUri`는 HTTPS이며 그 **hostname이 `issuer` hostname과 정확히 일치**해야 한다(포트 제외). 0.8은 **단일 origin issuer만** 지원한다 — issuer 식별자와 다른 host에서 JWKS를 서빙하는 IdP(일부 CDN-fronted 구성)는 0.8 범위 밖이며 생성 시 거부한다. 비-URL issuer(URN 형태)는 명확한 오류로 생성 시 거부한다.
80
+ - private/loopback/link-local 대역과 클라우드 메타데이터 엔드포인트로의 요청은 거부한다: `127.0.0.0/8`, `::1`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`(`169.254.169.254` 포함), `fe80::/10`. 거부는 DNS resolve 후 fetch 시점에 한다(rebinding 방어), fetch 타임아웃 포함.
81
+ - **JWKS 응답 경계:** 응답 본문 `> 1 MiB`는 거부; `JSON.parse`는 병적 중첩(depth 한계)에 대해 가드한다. JWT 세그먼트는 `JSON.parse` 전에 **엄격한 base64url**(`[A-Za-z0-9_-]`, 패딩 없음)로 디코드한다.
82
+ - **JWKS 캐시는 경계가 있고 DoS 저항적:** 키는 TTL로 캐시한다; 알 수 없는 `kid`가 **무제한 refetch를 트리거하지 않는다** — **60초 cooldown**당 최대 1회 전체 JWKS 갱신이므로, 위조된 `kid` 폭주가 IdP에 대한 fetch storm으로 바뀌지 않는다.
83
+ - **identity는 PII-safe(fail-closed):** `cryptoProvider`는 **필수**이며 `hmac`을 노출해야 한다; 없으면 provider 생성자가 throw한다(PII-safe identity를 만들 수 없음). `subjectHash`/`issuerHash`는 keyed **HMAC-SHA-256**(`haechi:identity:hash:v1`), hex 인코딩(64자) — raw `sub`/`iss`는 절대 저장·로깅하지 않는다. `scopes`는 설정된 scope 클레임(`scp`/`scope`)에서, `labels`는 허용목록 클레임 매핑에서만 온다.
84
+ - **모든 곳에서 fail-closed:** 검증 오류 → `authenticate`는 `null`(거부)을 반환하고 요청 경로로 throw하지 않으며, 토큰 세부정보를 클라이언트로 echo하지 않는다.
85
+
86
+ **주입**으로 연결한다(`createRuntime(config, { authProvider: createJwtAuthProvider(...) })`); `auth.provider: external`. 동적 로딩은 1.0 plugin sandbox까지 금지를 유지한다.
87
+
88
+ ### 2.5 모노레포 릴리스 프로세스
89
+
90
+ - 기존 루트 워크플로가 `haechi`를 계속 배포한다(`v*` 태그 glob + 엄격한 정규식 재검증으로 가드).
91
+ - 각 satellite는 0.7의 서명 아티팩트 단계(pack → checksum → attest → publish → upload)를 자기 디렉터리로 스코프하여 재사용하는 자체 접두사 태그 배포 워크플로를 가지며, 각각 자체 Trusted Publisher(특정 워크플로 파일명)에 바인딩된다.
92
+ - 패키지별 릴리스 런북(`release-process.md`)은 다음을 문서화한다: 태그 규칙 + 패키지→워크플로-파일명→태그-glob 매핑 표, Trusted Publisher 부트스트랩 순서(예약 → 설정 → 태그), 워크플로-rename 실패 모드, 배포 후 검증(provenance, `npm view ... access`).
93
+
94
+ ## 3. 명시적 비범위 (0.9+로 이월)
95
+
96
+ - `haechi-dashboard` 읽기 전용 audit 뷰어(UI 빌드, 자체 기술스택 결정).
97
+ - `haechi-auth-oidc` 완전 대화형 OIDC(authorization-code flow) — `haechi-auth-jwt`가 헤드리스 케이스를 먼저 다룬다.
98
+ - `haechi-auth-jwt` multi-origin/CDN-fronted JWKS(issuer host ≠ JWKS host).
99
+ - `haechi-classifier-*` ML/휴리스틱 classifier 플러그인.
100
+ - `haechi-crypto-kms` Vault/GCP/Azure 백엔드(0.8은 AWS만).
101
+ - satellite의 동적 로딩(1.0 plugin sandbox).
102
+
103
+ risk-register와 로드맵은 `haechi-auth-oidc`와 `haechi-dashboard`를 0.8 행에서 빼 새 **0.9** 행으로 옮겨, 공개 문서가 이 범위와 일치하도록 갱신한다.
104
+
105
+ ## 4. 하위 호환성
106
+
107
+ Core 동작은 불변이다: 루트 패키지의 `exports`, `bin`, `files`, zero-dep runtime 자세는 동일하다. 루트 `package.json`에 `workspaces`(`"."` 자기 항목 포함)를 추가하는 것은 `haechi`를 단일 의존성으로 설치하는 누구에게도 무해(inert)하다. 기존 config와 API는 손대지 않으며, satellite는 순수하게 부가적이고 opt-in이다.
108
+
109
+ ## 5. 1.0 관계
110
+
111
+ 0.8 자체는 1.0 blocker를 닫지 않지만, **운영 키 custody를 설치 가능하고 attest된 패키지로 실현**하고(`haechi-crypto-kms`) satellite 모델을 end-to-end로 증명한다. 남은 1.0 게이트는 유지된다: API 안정성 freeze와 plugin sandbox + 실환경 검증.
112
+
113
+ ## 6. 테스트 기준 (PR 분해에 매핑)
114
+
115
+ ### 6.1 PR1 — workspaces 전환 (새 배포 패키지 없음)
116
+
117
+ - 루트 `npm install`이 **ERESOLVE/ETARGET 없이** exit 0; `node_modules/haechi`가 workspace 심링크; 커밋된 `package-lock.json`으로 fresh checkout에서 `npm ci` 성공.
118
+ - `import { ... } from "haechi/crypto"`를 하는 satellite 테스트가 루트 `node --test`에서 green.
119
+ - **no-leak + zero-dep 게이트:** core `npm pack --dry-run`에 **`satellites/` 경로 없음**; **패킹된** `haechi` `package.json`(tarball에서 추출)의 `dependencies`가 비어있음/undefined. 게이트는 **음성 테스트**된다: core의 `files`에 `satellites/`를 임시로 추가하거나 core `package.json`에 runtime dep를 추가하면 게이트가 명확한 오류로 실패한다(공허한 통과 방지).
120
+ - in-memory crypto provider(승격된 0.7 코드)가 workspace 심링크로 `assertCryptoProviderConformance`를 통과하며, `haechi/crypto` 대비 byte-for-byte `canonicalize` parity 검사를 포함한다.
121
+
122
+ ### 6.2 PR2 — `haechi-crypto-kms` (실제 AWS 클라이언트)
123
+
124
+ - in-memory **및 AWS** 클라이언트(AWS는 KMS `encrypt`/`decrypt` ops의 **주입된 mock**으로 구동 — SDK·네트워크 없음)가 cross-key/손상-blob **거부** 경로와 HMAC 결정성/domain-separation을 포함해 `assertCryptoProviderConformance`를 통과; `createRuntime`을 통한 end-to-end(암호화 + 토큰화 round-trip).
125
+ - `createAwsKmsClient`는 `keyId` 없으면 throw; `hmacRootCiphertext` 없으면 `deriveHmacKey`가 throw하고 provider는 encrypt-only로 conformance 통과(`requireHmac:false`).
126
+ - 배포 매니페스트가 `publishConfig.access: public`을 설정하고 `@aws-sdk/client-kms`를 `peerDependencies` + `peerDependenciesMeta.optional`로 선언(runtime `dependency` 아님); 배포 satellite tarball은 `dependencies: {}`이고 core tarball은 zero-dep 유지(§6.1 게이트 계속 통과).
127
+ - satellite publish 워크플로(`crypto-kms-v<semver>`)가 0.7 서명 아티팩트 경로로 존재; core 워크플로는 satellite 릴리스 태그가 `haechi`를 발행하지 않도록 가드.
128
+
129
+ ### 6.3 PR3 — `haechi-auth-jwt` (보안 게이트)
130
+
131
+ - 유효한 RS256/ES256 JWT(테스트 키 서명, stub JWKS)가 **audit에 raw `sub` 없이** PII-safe identity로 인증; `subjectHash`/`issuerHash`는 64-hex자 HMAC-SHA-256.
132
+ - 다음이 각각 **거부**됨: `alg:"none"`; RSA 공개키로 위조한 `HS256` 토큰(alg-confusion); JWE/`typ` 불일치; 만료(`exp`); 아직 유효하지 않음(`nbf`); `exp` 누락; `sub` 누락/빈 값; 잘못된 `aud`(문자열·배열 형태); 잘못된 `iss`; 알 수 없는 `kid`; 잘못된 서명; `< 2048` 비트 RSA JWK; `use:"enc"`/`key_ops:["encrypt"]` JWK.
133
+ - 생성이 거부함: non-HTTPS 또는 cross-origin `jwksUri`; 비-URL `issuer`; `clockSkewSeconds > 300`; `cryptoProvider.hmac` 누락. `127.0.0.1`, `169.254.169.254`, `::1`, 또는 RFC1918 CIDR로 resolve되는 `jwksUri`는 거부.
134
+ - 알 수 없는 `kid` 폭주가 60초 cooldown 내 **정확히 1회** JWKS refetch를 트리거; `> 1 MiB` JWKS 응답은 거부.
135
+
136
+ ### 6.4 모든 satellite
137
+
138
+ - 각각 provenance + sigstore attestation으로 배포(0.7처럼 배포 후 검증).
139
+
140
+ ## 7. 제안 PR 분해 (스택)
141
+
142
+ 1. **Workspaces 전환**(새 배포 패키지 없음): 루트 `workspaces: [".", "satellites/*"]`, 루트를 **0.8.0**으로 bump, `crypto-kms`를 `satellites/crypto-kms/`로 이동(core `peer + dev` 의존성), 인라인 `canonicalize`를 `haechi/crypto`로 전환(+ parity 테스트), 테스트 재지정, 재생성된 `package-lock.json` 커밋, **no-leak + zero-dep CI 게이트** 추가(음성 테스트 포함), 루트 CI가 루트 `node --test`로 모든 workspace 테스트 실행. → §6.1
143
+ 2. **`haechi-crypto-kms`:** 실제 AWS KMS 클라이언트(satellite만의 `@aws-sdk/client-kms` 의존성) + 충실한 mocked-AWS conformance CI + `publishConfig` + 접두사 태그 배포 워크플로(엄격한 정규식 가드) + Trusted Publisher 부트스트랩. → §6.2
144
+ 3. **`haechi-auth-jwt`:** §2.4 전체 보안 명세를 구현하는 JWKS 검증 provider + identity 매핑 + §6.3 보안 게이트 테스트 + `publishConfig` + 접두사 태그 배포 워크플로. → §6.3
145
+ 4. **0.8.0 릴리스 컷:** EN/KO 문서, packaging/roadmap/risk-register(OIDC+dashboard를 0.9로 이동)/api-stability, wiki, npm org / Trusted Publisher 런북(매핑 표 + 부트스트랩 순서 + 실패 모드).
@@ -0,0 +1,145 @@
1
+ # Haechi 0.8 Implementation Scope
2
+
3
+ - Status: Draft 0.3 (design — not yet implemented)
4
+ - Date: 2026-06-10
5
+ - Target version: 0.8.0 (after 0.7.0)
6
+ - Type: ecosystem foundation
7
+
8
+ ## 1. Release Goal
9
+
10
+ Stand up the `haechi-*` package ecosystem: convert the repo to an npm workspaces monorepo and publish the first two satellites — `haechi-crypto-kms` (promoting the 0.7 reference) and `haechi-auth-jwt` (JWKS bearer verification), both unscoped (the `@haechi` scope is taken; no org needed). This realizes operational key custody as an installable package and grows the auth ecosystem without touching core's zero-dependency posture.
11
+
12
+ **Scope decision (2026-06-10):** 0.8 is the **packaging foundation + satellites**. The `haechi-dashboard` read-only audit viewer (a UI build) and full interactive `haechi-auth-oidc` move to **0.9** so 0.8 stays code-light and focused on the monorepo + two headless-friendly adapters.
13
+
14
+ Core (`haechi`, unscoped) stays **zero runtime dependency**. Satellite dependencies (e.g. an AWS SDK) live in the satellite's own `package.json` only and never enter core's tarball or SBOM.
15
+
16
+ ## 2. Scope
17
+
18
+ ### 2.1 npm workspaces monorepo (verified resolution mechanic)
19
+
20
+ The naive layout (`"workspaces": ["satellites/*"]` + a satellite peer range `"haechi": ">=0.8.0"`) **does not work** and was rejected after empirical testing: npm treats the unmet peer range as a registry lookup (`ETARGET: no matching version for haechi@>=0.8.0`), never symlinks the root project into `node_modules/haechi`, and the satellite's `import "haechi/crypto"` throws `ERR_MODULE_NOT_FOUND`. The root project is **not** a workspace member by default, so npm never links it.
21
+
22
+ The verified working layout:
23
+
24
+ - **Root lists itself as a workspace member:** `"workspaces": [".", "satellites/*"]`. The `"."` entry is what makes npm create the `node_modules/haechi → ..` symlink so satellites resolve core. Without it, satellites fall back to the registry.
25
+ - **The repo root remains the published `haechi` package** — its `exports`, `bin`, and `files` allowlist are unchanged. The only `package.json` delta is the added `workspaces` field and the version bump. Satellites are **not** in core's `files`, so they never ship inside the `haechi` tarball (verified: a root `npm pack --dry-run` lists only the `files` allowlist; `satellites/` is excluded).
26
+ - **Satellites declare a dual dependency on core:**
27
+ - `"peerDependencies": { "haechi": ">=0.8.0 <1.0.0" }` — the contract a *consumer* installs against, so the satellite reuses the consumer's single `haechi` instance (one crypto/identity surface, no duplicate copies).
28
+ - `"devDependencies": { "haechi": "*" }` — the mechanism that makes npm link the **local workspace** during monorepo development/CI instead of resolving the peer range from the registry. `npm pack` strips devDependencies from the published tarball, so the consumer-facing manifest carries only the peer range — the `*` devDep is invisible downstream. (A repo/source scanner reading `satellites/*/package.json` will still see it; that source-vs-artifact difference is expected and harmless.)
29
+ - **Consumer peer-mismatch behavior:** installing a published satellite against an *incompatible* `haechi` (e.g. `haechi@0.7.0` already present) yields an npm `ERESOLVE` **warning**, not a hard failure; the consumer must upgrade `haechi` (or `--legacy-peer-deps` at their own risk). The satellite does not function correctly against an out-of-range core.
30
+ - Satellites import core by subpath (`haechi/crypto`, `haechi/auth`, `haechi/runtime`); these resolve through the workspace symlink in dev and through the consumer's installed `haechi` in production.
31
+ - `examples/crypto-kms-reference/` is **promoted** to `satellites/crypto-kms/`. The reference example inlined `canonicalize` because its nested `package.json` could not self-resolve `haechi/crypto` pre-workspaces; **under workspaces that import resolves**, so the satellite imports `canonicalize` from `haechi/crypto` rather than carrying a copy (avoiding AAD-canonicalization drift between core and satellite). The old `examples/` directory keeps a short README pointing at the published package. A conformance test asserts the satellite's AAD canonicalization is **byte-for-byte identical** to `haechi/crypto` (not merely semantically equivalent).
32
+ - **Lock file:** converting to workspaces regenerates `package-lock.json` with workspace-resolved entries (including the root self-member). The regenerated lock file is committed; CI uses `npm ci` (which fails on a stale/missing lock), so the conversion PR must commit the fresh lock.
33
+
34
+ **CI strategy (avoid double-runs):** root CI runs `node --test` directly from the root, which discovers `satellites/**/*.test.mjs` automatically (workspace symlinks make their `haechi/*` imports resolve). CI does **not** use `npm test --workspaces` (which would recurse into the root self-member and re-run the suite). Each satellite keeps its own `test` script for isolated local runs (`npm test -w haechi-crypto-kms`) only. Verified: root `node --test` runs core + satellite tests once, and the `node_modules/haechi → ..` symlink cycle does not hang the runner (node skips `node_modules`).
35
+
36
+ **Honest packaging note (was a "byte-stable" claim):** the root tarball is **not** byte-identical to 0.7.0 — `package.json` gains the `workspaces` field and the version bumps, which is expected for any release. The defensible, tested claim is narrower and is enforced by a CI gate (see §6.1): **(a) no satellite files appear in the `haechi` tarball, and (b) the `haechi` tarball's own `package.json` declares zero runtime `dependencies`.** The gate inspects the **packed manifest** (extract `package.json` from `npm pack` output and assert `dependencies` is empty/undefined) — not the installed `node_modules` SBOM, which would pass vacuously today and miss a future runtime-dep leak.
37
+
38
+ ### 2.2 Unscoped `haechi-*` names + per-package trusted publishing
39
+
40
+ - **Naming (decision 2026-06-10):** the `@haechi` npm org/scope is already taken by a third party, so satellites are published as **unscoped `haechi-*`** names — `haechi-crypto-kms`, `haechi-auth-jwt` (both verified free on npm). This needs **no npm org**, matches the unscoped core `haechi`, and each name is reserved + Trusted-Publisher-bound individually. (Trade-off vs a scope: no namespace grouping/defence; the `haechi-` prefix is the convention.)
41
+ - Each satellite is published with the **same OIDC trusted-publishing + sigstore + SHA256SUMS** path proven in 0.7 — its own npmjs.com Trusted Publisher link and a tag-triggered publish workflow.
42
+ - **Satellite `package.json` requirements (do not inherit from root):** each satellite still sets its own `"publishConfig": { "access": "public", "provenance": true }`. Unscoped packages are public by default, so `access: public` is belt-and-suspenders; `provenance: true` and not inheriting the root's `publishConfig` are the load-bearing parts. Post-publish, the runbook verifies `npm view haechi-<pkg> access` reports `public`.
43
+ - **Tag namespacing + workflow guards (avoid mis-triggers and collisions):**
44
+ - Core release tags: `v<semver>` (e.g. `v0.8.0`). The root publish workflow triggers on `push: tags: ['v[0-9]*.[0-9]*.[0-9]*']`. Because GitHub's tag glob treats `.` literally and `[0-9]*` loosely (it would also match `v1.2.3.4` or `v1a.2.3`), the workflow **re-validates** the tag against a strict `^v[0-9]+\.[0-9]+\.[0-9]+$` regex in a pre-publish step and fails closed on a non-match.
45
+ - Satellite tags are **prefixed**: `crypto-kms-v<semver>`, `auth-jwt-v<semver>`. Each satellite workflow triggers only on its own prefix glob and likewise re-validates against `^<prefix>-v[0-9]+\.[0-9]+\.[0-9]+$`.
46
+ - Each workflow re-asserts the package directory it publishes (`npm publish -w <dir>`) so a mistagged push can't publish the wrong package. The Trusted Publisher on npmjs.com is bound to a **specific workflow filename** — renaming the workflow without updating the npm config breaks OIDC auth (documented as a failure mode in the runbook, with a package→workflow-filename→tag-glob mapping table).
47
+ - **Independent semver** per satellite (a satellite patch never bumps core). Satellites start at `0.1.0`. **Pre-1.0 contract:** satellites follow standard npm semver where a `0.x` **minor** bump may carry breaking changes; consumers should pin `major.minor` (e.g. `haechi-crypto-kms@~0.1`). Satellites are pre-stable until their own `1.0.0`.
48
+ - **Bootstrapping the first publish (no org needed):** order per satellite — (1) on npmjs.com, **configure the Trusted Publisher** for the (not-yet-published) unscoped name, linking the repo + exact workflow filename; (2) push the satellite's first tag → the workflow's OIDC publish creates `0.1.0` with provenance and **claims the name** on first publish. No manual `npm publish` from a laptop is required (matches the 0.7 trusted-publishing posture). Because the names are unscoped and currently free, there is no org-membership prerequisite.
49
+
50
+ ### 2.3 `haechi-crypto-kms` (publish + real KMS client)
51
+
52
+ - Promote the 0.7 reference (`createKmsCryptoProvider` envelope encryption + `createInMemoryKms`) into the published package, switching its inlined `canonicalize` to `import { canonicalize } from "haechi/crypto"` (§2.1). The existing `kms` client interface (`keyId` / `wrap` / `unwrap` / `deriveHmacKey`) is **unchanged**, so the promoted provider and in-memory client stay byte-for-byte and their 0.7 tests carry over.
53
+ - Add a **real AWS KMS client** at `haechi-crypto-kms/aws`: `createAwsKmsClient({ keyId, region, client, hmacRootCiphertext })`. It implements the same `kms` interface: `wrap` = KMS `Encrypt` of a CSPRNG-generated 32-byte data key, `unwrap` = KMS `Decrypt` (envelope encryption — the master key never leaves KMS); `deriveHmacKey(domain)` = **HKDF-SHA256** over a single KMS-`Decrypt`ed 32-byte root (`hmacRootCiphertext`, cached), domain-separated — deterministic with no per-token network call. With no `hmacRootCiphertext`, `deriveHmacKey` throws and the provider is encrypt-only (valid via `requireHmac:false`).
54
+ - **`@aws-sdk/client-kms` is an OPTIONAL peer dependency, not a hard dependency** (decision 2026-06-10, revising the earlier "satellite's own dependency" wording). It is imported **lazily** only when no `client` is injected, so: the monorepo `npm ci`/CI never pulls the (large) AWS SDK; consumers on the in-memory or an injected client never install it; and `core` is trivially unaffected. The published satellite declares it under `peerDependencies` + `peerDependenciesMeta.optional`. This keeps the satellite dependency-light while still offering a real backend.
55
+ - The satellite's CI runs `assertCryptoProviderConformance` (imported from `haechi/crypto` via the workspace symlink) against the in-memory client **and** the AWS client driven by an **injected mock** of the two KMS ops (`encrypt`/`decrypt`) — **no SDK, no network**. The mock is a faithful envelope (AES-256-GCM under a per-mock master key): `Decrypt` returns the plaintext only for a blob this key wrapped and **rejects** a blob wrapped by a different key (cross-key isolation) or a corrupted blob. A trivial always-succeeds stub is insufficient; the suite exercises these rejection paths plus HMAC determinism/domain-separation. Live `createAwsKmsClient` validation against a sandbox KMS key is an **out-of-CI integration test** (documented, not gating).
56
+
57
+ ### 2.4 `haechi-auth-jwt` (JWKS bearer verification, dependency-light)
58
+
59
+ `createJwtAuthProvider({ issuer, audience, jwksUri, cryptoProvider, algorithms, clockSkewSeconds, claimMappings })` implements the `authProvider` contract for a **headless** gateway. It is implementable with `node:` builtins only (no `jose`): JWKS fetched via global `fetch`, JWK→key via `crypto.createPublicKey({ key: jwk, format: "jwk" })`, signatures verified via `crypto.verify`.
60
+
61
+ **Implementation note — ES256 signature encoding (verified):** a JWS ES256 signature is raw `R‖S` (IEEE-P1363, 64 bytes for P-256), but `node:crypto.verify` defaults to **DER** for EC keys and returns `false` for a raw signature — silently rejecting every valid ES256 token. The verifier MUST pass `dsaEncoding: "ieee-p1363"` for the EC algorithms. (Empirically confirmed: default DER ⇒ `false`; `ieee-p1363` ⇒ `true`.) This is an acceptance criterion, not an option.
62
+
63
+ **Security spec (mandatory — these are acceptance criteria, not options).** Concrete constants below are *decisions*, not implementation discretion.
64
+
65
+ - **Algorithm selection is server-side, never from the token.** The verifier picks the algorithm from the configured `algorithms` allowlist (default `["RS256","ES256"]`) and the JWK's `kty`/`crv`. The token's `alg` header is checked for *membership* in the allowlist **before** key selection; it never selects the verification routine.
66
+ - **Reject `alg: "none"`** unconditionally.
67
+ - **Block alg-confusion:** never feed an RSA public key into an HMAC verify. HMAC family (`HS*`) is **not allowed** by default; a JWKS-sourced public key is only ever used with its matching asymmetric algorithm.
68
+ - **`kid` is required**; the signing key is selected by `kid` from the JWKS, not by trying every key.
69
+ - **RSA key-strength floor:** an RSA JWK with modulus `< 2048` bits is rejected as invalid.
70
+ - **JWK usage intent:** if a JWK carries `use`, it must be `sig`; if it carries `key_ops`, it must include `verify`/`sign` and must not include `encrypt`/`decrypt`. Otherwise reject.
71
+ - **Header `typ` / no JWE:** if `typ` is present it must be `JWT`; encrypted (JWE) tokens are rejected unconditionally — only JWS is accepted.
72
+ - **Claims are mandatory and fully validated:**
73
+ - `iss` must equal the configured `issuer`.
74
+ - `aud` (token) may be a string or an array of strings (RFC 7519); the configured `audience` must equal the string or be a member of the array — exact, case-sensitive match.
75
+ - `sub` is required and must be a non-empty string (it is the input to `subjectHash`).
76
+ - `exp` and `nbf` are **required**. `exp`: reject when `now > exp + clockSkewSeconds`. `nbf`: reject when `now < nbf - clockSkewSeconds`. A token missing `exp` is rejected. `iat` is sanity-checked if present.
77
+ - **`clockSkewSeconds`** default `60`, **maximum `300`** — construction rejects a value `> 300` (a larger skew would gut expiry validation).
78
+ - **JWKS fetching is SSRF-hardened:**
79
+ - `issuer` must be a valid **HTTPS URL**; `jwksUri` must be HTTPS and its **hostname must exactly equal the `issuer` hostname** (port excluded). 0.8 supports **single-origin issuers only** — IdPs that serve JWKS from a different host than the issuer identifier (some CDN-fronted setups) are explicitly out of scope for 0.8 and rejected at construction. A non-URL issuer (URN-style) is rejected at construction with a clear error.
80
+ - Requests to private/loopback/link-local ranges and cloud-metadata endpoints are refused: `127.0.0.0/8`, `::1`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16` (incl. `169.254.169.254`), `fe80::/10`. Refusal is at fetch time after DNS resolution (guard against rebinding), with a fetch timeout.
81
+ - **JWKS response bounds:** reject a response body `> 1 MiB`; `JSON.parse` is guarded against pathological nesting (depth bound). JWT segments are decoded as **strict base64url** (`[A-Za-z0-9_-]`, no padding) before `JSON.parse`.
82
+ - **JWKS cache is bounded and DoS-resistant:** keys are cached with a TTL; an **unknown `kid` does not trigger an unbounded refetch** — at most one full JWKS refresh per **60 s cooldown**, so a flood of forged `kid`s can't become a fetch storm against the IdP.
83
+ - **Identity is PII-safe (fail-closed):** `cryptoProvider` is **required** and must expose `hmac`; if absent, the provider constructor throws (it cannot produce a PII-safe identity). `subjectHash` / `issuerHash` are keyed **HMAC-SHA-256** (`haechi:identity:hash:v1`), hex-encoded (64 chars) — raw `sub` / `iss` are never stored or logged. `scopes` come from a configured scope claim (`scp` / `scope`); `labels` from an allowlisted claim mapping only.
84
+ - **Fail-closed everywhere:** any verification error → `authenticate` returns `null` (deny), never throws into the request path; no token detail is echoed to the client.
85
+
86
+ Wired via **injection** (`createRuntime(config, { authProvider: createJwtAuthProvider(...) })`); `auth.provider: external`. Dynamic loading stays banned until the 1.0 plugin sandbox.
87
+
88
+ ### 2.5 Release process for the monorepo
89
+
90
+ - The existing root workflow keeps publishing `haechi` (guarded to the `v*` tag glob + strict regex re-validation).
91
+ - Each satellite has its own prefixed-tag publish workflow reusing the 0.7 signed-artifacts steps (pack → checksum → attest → publish → upload), scoped to its own directory, each bound to its own Trusted Publisher (specific workflow filename).
92
+ - The per-package release runbook (`release-process.md`) documents: tag conventions + the package→workflow-filename→tag-glob mapping table, the Trusted Publisher bootstrap order (reserve → configure → tag), the workflow-rename failure mode, and post-publish verification (provenance, `npm view ... access`).
93
+
94
+ ## 3. Explicit non-scope (deferred to 0.9+)
95
+
96
+ - `haechi-dashboard` read-only audit viewer (UI build, its own tech-stack decision).
97
+ - `haechi-auth-oidc` full interactive OIDC (authorization-code flow) — `haechi-auth-jwt` covers the headless case first.
98
+ - `haechi-auth-jwt` multi-origin/CDN-fronted JWKS (issuer host ≠ JWKS host).
99
+ - `haechi-classifier-*` ML/heuristic classifier plugins.
100
+ - `haechi-crypto-kms` Vault/GCP/Azure backends (AWS only in 0.8).
101
+ - Dynamic loading of satellites (1.0 plugin sandbox).
102
+
103
+ The risk-register and roadmap are updated to move `haechi-auth-oidc` and `haechi-dashboard` out of the 0.8 row and into a new **0.9** row, so the public docs match this scope.
104
+
105
+ ## 4. Backward compatibility
106
+
107
+ Core behavior is unchanged: the root package's `exports`, `bin`, `files`, and zero-dep runtime posture are identical. Adding `workspaces` (including the `"."` self-entry) to the root `package.json` is inert for anyone installing `haechi` as a single dependency. Existing config and APIs are untouched; satellites are purely additive and opt-in.
108
+
109
+ ## 5. 1.0 relationship
110
+
111
+ 0.8 does not itself close a 1.0 blocker, but it **realizes operational key custody as an installable, attested package** (`haechi-crypto-kms`) and proves the satellite model end-to-end. The remaining 1.0 gates stay: API-stability freeze and plugin sandbox + real-environment validation.
112
+
113
+ ## 6. Test criteria (mapped to the PR breakdown)
114
+
115
+ ### 6.1 PR1 — workspaces conversion (no new published package)
116
+
117
+ - Root `npm install` exits 0 with **no ERESOLVE/ETARGET**; `node_modules/haechi` is the workspace symlink; the committed `package-lock.json` makes `npm ci` succeed on a fresh checkout.
118
+ - A satellite test that does `import { ... } from "haechi/crypto"` runs green under root `node --test`.
119
+ - **No-leak + zero-dep gate:** core `npm pack --dry-run` contains **no `satellites/` paths**; the **packed** `haechi` `package.json` (extracted from the tarball) has empty/undefined `dependencies`. The gate is **negatively tested**: temporarily adding `satellites/` to core's `files`, or a runtime dep to core's `package.json`, makes the gate fail with a clear error (so the gate isn't a vacuous pass).
120
+ - In-memory crypto provider (promoted 0.7 code) passes `assertCryptoProviderConformance` through the workspace symlink, including the byte-for-byte `canonicalize` parity check vs `haechi/crypto`.
121
+
122
+ ### 6.2 PR2 — `haechi-crypto-kms` (real AWS client)
123
+
124
+ - In-memory **and AWS** clients (the AWS one driven by an **injected mock** of the KMS `encrypt`/`decrypt` ops — no SDK, no network) pass `assertCryptoProviderConformance`, including the cross-key/corrupted-blob **rejection** paths and HMAC determinism/domain-separation; end-to-end through `createRuntime` (encrypt + tokenization round-trip).
125
+ - `createAwsKmsClient` without a `keyId` throws; with no `hmacRootCiphertext`, `deriveHmacKey` throws and the provider passes conformance as encrypt-only (`requireHmac:false`).
126
+ - The published manifest sets `publishConfig.access: public` and declares `@aws-sdk/client-kms` under `peerDependencies` + `peerDependenciesMeta.optional` (NOT a runtime `dependency`); the published satellite tarball has `dependencies: {}`, and core's tarball stays zero-dep (the §6.1 gate still passes).
127
+ - A satellite publish workflow (`crypto-kms-v<semver>`) exists with the 0.7 signed-artifacts path; the core workflow is guarded so a satellite release tag never publishes `haechi`.
128
+
129
+ ### 6.3 PR3 — `haechi-auth-jwt` (security gates)
130
+
131
+ - A valid RS256/ES256 JWT (test-key-signed, stub JWKS) authenticates into a PII-safe identity with **no raw `sub` in the audit**; `subjectHash`/`issuerHash` are 64-hex-char HMAC-SHA-256.
132
+ - Each of the following is **denied**: `alg:"none"`; an `HS256` token forged with the RSA public key (alg-confusion); a JWE/`typ` mismatch; expired (`exp`); not-yet-valid (`nbf`); missing `exp`; missing/empty `sub`; wrong-`aud` (string and array forms); wrong-`iss`; unknown-`kid`; bad-signature; an RSA JWK `< 2048` bits; a JWK with `use:"enc"`/`key_ops:["encrypt"]`.
133
+ - Construction rejects: a non-HTTPS or cross-origin `jwksUri`; a non-URL `issuer`; `clockSkewSeconds > 300`; a missing `cryptoProvider.hmac`. A `jwksUri` resolving to `127.0.0.1`, `169.254.169.254`, `::1`, or any RFC1918 CIDR is rejected.
134
+ - An unknown-`kid` flood triggers **exactly one** JWKS refetch within the 60 s cooldown; a JWKS response `> 1 MiB` is rejected.
135
+
136
+ ### 6.4 All satellites
137
+
138
+ - Each publishes with provenance + sigstore attestation, verified post-release like 0.7.
139
+
140
+ ## 7. Suggested PR breakdown (stacked)
141
+
142
+ 1. **Workspaces conversion** (no new published package): root `workspaces: [".", "satellites/*"]`, bump root to **0.8.0**, move `crypto-kms` to `satellites/crypto-kms/` with `peer + dev` core deps, switch the inlined `canonicalize` to `haechi/crypto` (+ parity test), repoint tests, commit the regenerated `package-lock.json`, add the **no-leak + zero-dep CI gate** (with negative tests), root CI runs all workspace tests via root `node --test`. → §6.1
143
+ 2. **`haechi-crypto-kms`:** real AWS KMS client (satellite-only `@aws-sdk/client-kms` dep) + faithful mocked-AWS conformance CI + `publishConfig` + prefixed-tag publish workflow (strict regex guard) + Trusted Publisher bootstrap. → §6.2
144
+ 3. **`haechi-auth-jwt`:** JWKS verification provider implementing the full §2.4 security spec + identity mapping + the §6.3 security-gate tests + `publishConfig` + prefixed-tag publish workflow. → §6.3
145
+ 4. **0.8.0 release cut:** docs EN/KO, packaging/roadmap/risk-register (move OIDC+dashboard to 0.9)/api-stability, wiki, npm org / Trusted Publisher runbook (mapping table + bootstrap order + failure modes).