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,231 @@
1
+ # Haechi 0.9 구현 범위
2
+
3
+ - 상태: Draft 0.2 (설계 — 아직 미구현; 2026-06-11 적대적 보안 리뷰 후 강화)
4
+ - 날짜: 2026-06-11
5
+ - 목표 버전: 0.9.0 (0.8.0 다음)
6
+ - 유형: 관측가능성(observability) + 인터랙티브 인증
7
+
8
+ ## 1. 릴리스 목표
9
+
10
+ 0.8을 code-light하게 유지하려고 의도적으로 미뤘던 **관측가능성 + 인터랙티브 인증** 쌍을 전달한다:
11
+
12
+ - **`haechi-dashboard`** — zero-dependency, 읽기 전용 **감사 뷰어**: `node:http` 서버가 자체완결형 정적 페이지 한 장(바닐라 JS, 프레임워크 없음, 빌드 스텝 없음)과 감사 로그 + 해시체인 상태에 대한 읽기 전용 JSON API를 서빙한다.
13
+ - **`haechi-auth-oidc`** — **인터랙티브 세션 브로커**: 사람이 브라우저로 로그인해 서버측 세션을 얻는 OIDC authorization-code + PKCE 플로우. 이는 대시보드의 로그인 메커니즘이며 — 요청마다 *사전취득* bearer JWT를 검증하는 `haechi-auth-jwt`와는 별개의 관심사다.
14
+
15
+ 둘 다 0.8 패키징 모델을 따르는 신규 **언스코프드 위성**(`haechi-dashboard`, `haechi-auth-oidc`)이다: core에 peer-dep, 프로토콜이 허용하는 곳은 zero-dep, 무거운 SDK는 optional-peer, provenance + sigstore가 붙는 OIDC trusted publishing.
16
+
17
+ **범위 결정 (2026-06-11).** 메인테이너와 확정:
18
+
19
+ 1. **릴리스 단위:** `haechi-dashboard` + `haechi-auth-oidc`를 0.9.0 테마로 **짝지어** 출시한다(대시보드는 사람 로그인이 필요하고 auth-oidc가 이를 제공). **`haechi-crypto-kms` Vault/GCP/Azure 백엔드는 독립적으로** `haechi-crypto-kms@0.2.0`으로 출시한다 — 이 위성은 자체 버전 관리되며 core 0.9.0 컷과 **무관**하다. 본 문서는 셋 다 명세하되 crypto-kms 0.2.0은 병렬·분리 트랙으로 다룬다(§2.4).
20
+ 2. **대시보드 스택:** **zero-dependency 바닐라** — `node:http` + 정적 HTML/JS/CSS, 프레임워크/빌드 스텝 없음. core의 `node:`-빌트인-only 기풍과 위성의 의존성-경량 자세와 일관.
21
+ 3. **`haechi-auth-oidc` 형태:** **인터랙티브 세션 브로커**(authorization-code + PKCE + `/callback` + 서버측 세션). 요청별 토큰 검증기가 아님 — 그 역할은 `haechi-auth-jwt`가 유지.
22
+ 4. **대시보드 데이터 범위:** **감사 뷰어만** — 감사 이벤트 스트림 + `verifyAuditChain` 체인 상태 + decision/action 집계. 토큰볼트/정책 시각화는 0.9 범위 밖(reveal governance 경계를 건드리지 않도록).
23
+
24
+ core(`haechi`, 언스코프드)는 **zero runtime dependency**를 유지하고 0.9에서 **동작이 변경되지 않는다**. 기존 패키지에 대한 유일한 변경은 `haechi-auth-jwt` 위성을 *가산적·동작보존* 리팩터하여 재사용 가능한 JWS 검증기를 export하는 것(§2.2)뿐 — **`packages/*`(core) 코드 변경은 불필요**하다. 대시보드의 loopback 가드는 이미 export된 `haechi/proxy`의 `assertSafeProxyBind`를 재사용한다(core 재배치 없음 — §2.1).
25
+
26
+ ### 버전 전제 (2026-06-11 기준 라이브 상태)
27
+
28
+ | 패키지 | 현재 | 0.9 목표 | 이유 |
29
+ |---|---|---|---|
30
+ | `haechi` (core) | `0.8.0` (발행됨) | `0.9.0` | 릴리스 컷; 동작 불변 |
31
+ | `haechi-auth-jwt` | `0.1.1` (발행됨) | **`0.2.0`** | 가산적 검증기 export(§2.2) — 발행 워크플로의 tag==package-version 게이트가 명시적 bump를 요구 |
32
+ | `haechi-crypto-kms` | `0.1.1` (발행됨) | **`0.2.0`** | 가산적 GCP/Azure/Vault 백엔드(§2.4); 하드코딩된 provider `version` 필드도 정합(§2.4) |
33
+ | `haechi-dashboard` | — (신규) | `0.1.0` | 첫 발행이 언스코프드 이름을 선점 |
34
+ | `haechi-auth-oidc` | — (신규) | `0.1.0` | 첫 발행이 언스코프드 이름을 선점 |
35
+
36
+ workspace-lockfile 규칙(이전에 물린 적 있음)대로, **신규** `satellites/*` 디렉터리 두 개를 추가하면 그 workspace 엔트리를 포함하도록 `npm install`로 `package-lock.json`을 재생성해 같은 PR에 커밋해야 하며, 아니면 CI `npm ci`가 실패한다.
37
+
38
+ ## 2. 범위
39
+
40
+ ### 2.1 `haechi-dashboard` — zero-dep 읽기 전용 감사 뷰어
41
+
42
+ `createDashboardServer(options)`와 선택적 bin(`haechi-dashboard`)을 노출하는 위성. 기존 감사 JSONL(과 anchor 스트림)을 읽어 읽기 전용으로 서빙한다. **프레임워크를 import하지 않고, 빌드 스텝이 없으며, 정확히 세 개의 정적 자산**(HTML 1, JS 1, CSS 1)을 **코드 내 고정 자산 맵**에서 서빙한다 — 요청 URL에서 파생한 `fs` 경로는 절대 사용하지 않는다(path traversal 없음).
43
+
44
+ **구성 + fail-closed 검증 (config 불변식 동등성).** 위성은 core 설정 파일이 아니라 명시적 주입으로 연결되므로, 대시보드는 `normalizeConfig`의 규율을 그대로 따르는 export형 **`normalizeDashboardConfig(options)`**를 제공한다: **생성 시점에 strict·fail-closed·열거형 throw**(모든 옵션 타입 체크; 알 수 없는 키 거부). 필드: `auditPath`(문자열, 필수), `anchorPath`(문자열|null), `host`(기본 `127.0.0.1`), `port`(정수 1–65535), `allowRemoteBind`(불리언), `sessionGuard`(객체|null), `window`(bounded int), `tlsContext`/`trustProxy`(§ remote-bind). 잘못된 옵션마다 안정적 에러를 throw하고, `configuration.md`(+ `.ko.md`)에 모든 옵션·타입·기본값·throw 조건을 열거하는 대시보드 섹션을 추가한다. `createDashboardServer`는 먼저 `normalizeDashboardConfig`를 호출한다.
45
+
46
+ **생성 시점 bind/guard 우선순위 (fail-closed, 정확한 순서):**
47
+
48
+ 1. `!isLoopback(host) && !allowRemoteBind` → **throw** (loopback 가드; 아래 참조).
49
+ 2. `!isLoopback(host) && allowRemoteBind && !sessionGuard` → **throw** `"remote bind requires a sessionGuard"`.
50
+ 3. `!isLoopback(host)` (원격, 가드 있음) → **확인된 HTTPS 종단을 요구**(`tlsContext`, 또는 구성된 신뢰 프록시 주소에서 온 `X-Forwarded-Proto`만 신뢰하는 `trustProxy`) — 아니면 **throw**(Secure/`__Host-` 세션 쿠키는 평문 http로는 전송되지 않으므로 비-TLS 원격 bind는 로그인을 조용히 깨뜨림; fail closed). 원격 경로에는 `Strict-Transport-Security`를 추가한다.
51
+
52
+ **Loopback bind**은 core가 이미 export한 `assertSafeProxyBind`를 재사용한다(`import { assertSafeProxyBind } from "haechi/proxy"` — **core 재배치 없음**, 신규 `haechi/net` export 없음). 그 throw 텍스트는 proxy 문구이고 `--allow-remote-bind`를 언급하므로, 대시보드는 이를 **catch 후 자체 메시지로 rethrow**한다(대시보드는 그 CLI 플래그가 아니라 `allowRemoteBind` 옵션을 노출).
53
+
54
+ **Anti-DNS-rebinding Host 헤더 allowlist (필수, bind 체크와 구분).** loopback bind만으로는 미인증 localhost 뷰어를 보호하지 못한다: 운영자가 방문하는 임의 사이트가 짧은 TTL DNS 이름을 `127.0.0.1`로 재해석시키면, 피해자 브라우저가 대시보드로 동일출처 요청을 보내 공격자 JS가 감사 JSON을 읽을 수 있다. 따라서 **모든** 요청(`/api/*`·`/healthz` 포함)은 **`Host` 헤더** 호스트 부분이 allowlist `{localhost, 127.0.0.1, [::1], ::1, ::ffff:127.0.0.1, 구성된 bind host}`에 없으면 `403`으로 거부된다. 이는 **bind-string 체크와 별개의 요청-헤더 함수**이며(`assertSafeProxyBind`는 신뢰 못 할 헤더가 아니라 bind 문자열을 검증), 자체 정규화를 갖는다: `Host`를 host+port로 파싱, 변형/중복 `Host` 거부, 단일 후행 점(`localhost.`) 제거, IPv4-mapped IPv6·대괄호 IPv6 처리. CORS는 **부재** — `Access-Control-Allow-Origin`을 설정/반사하지 않는다.
55
+
56
+ **API (전부 GET/HEAD, 읽기 전용):**
57
+
58
+ - `GET /api/events?cursor=&limit=` — 최신순, **bounded-window** 감사 이벤트 페이지. **엄격한 쿼리 파싱:** `limit`은 `[1,200]` 정수(NaN/음수/비정수 거부); `cursor`는 서버 발급 불투명 토큰 = `auditIntegrity.sequence`(단조·안정), 변형 시 `400`, **fs offset으로 직접 쓰지 않음**. 이벤트는 **실제** 감사 스키마(아래)에 맞춘 **재귀적·키별 필드 allowlist projection**을 통과한다 — 서버는 중첩 하위 객체(`detections`, `identity`, `summary`, `auditIntegrity`)를 **통째로 spread/통과시키지 않으므로** 어느 계층의 미래 필드도 새지 않는다(core `FORBIDDEN_KEYS` 위의 심층 방어). bounded tail 윈도보다 오래된 페이지는 에러가 아니라 `"window exceeded"` 마커로 빈 응답; 동시 append로 인한 찢어진 후행 줄은 (`readAnchors`처럼) 허용·스킵하고 `500`을 내지 않는다.
59
+ - `GET /api/chain` — `verifyAuditChain(auditPath, { anchorPath })`의 **실제** 출력에서 파생: 성공 `{ valid:true, records, headHash, anchored?:{count,lastSequence} }`, 실패 `{ valid:false, records }`. **`truncationDetected`는** 대시보드가 `valid===false && reason.startsWith("tail truncation")`로 **파생**하고, **원문 `reason`은 노출하지 않는다**(`eventHash`/sequence가 박힐 수 있음 — 예: `"anchor hash mismatch at sequence N"`). `valid===false`는 눈에 띄게 표시(유일한 변조 신호). **bounded compute:** 단일 직렬화 in-process 작업(동시 재-walk 없음), 감사 파일 `mtime+size` 변경 시에만 재계산(캐시 키 = `mtime+size`); 하드 최대 파일 크기 초과 시 walk 대신 `413`/`{valid:null}`. `HEAD /api/chain`은 헤더만 반환하고 새 walk를 강제하지 않는다.
60
+ - `GET /api/summary` — 이벤트 윈도의 `summary.byType`/`summary.byAction`/`summary.detectionCount` 집계.
61
+ - `GET /healthz` — liveness만(감사 데이터·경로·버전·config 없음); **세션 없이도, loopback 밖에서도 의도적으로 도달 가능**(가드된 원격 대시보드도 liveness probe에 응답해야 함).
62
+
63
+ **실제 감사 이벤트 스키마 (projection의 진실 원천).** 디스크 레코드(`packages/core/index.mjs` `buildAuditEvent`에서 생성, integrity는 `packages/audit/index.mjs`가 추가):
64
+
65
+ ```
66
+ { id, timestamp, protocol, operation, identity, profile, mode, enforced, blocked,
67
+ payloadShapeHash,
68
+ detections: [ { type, ruleId, path, kind, confidence, action, enforced } ], // `path`는 옛 "pathText" — XSS 위험·클라이언트 키 파생 필드, 여기 NESTED
69
+ summary: { byType, byAction, detectionCount },
70
+ auditIntegrity: { alg, canonicalization, sequence, previousHash, eventHash } } // proxy 기록 이벤트는 top-level `direction`을 추가할 수 있음
71
+ ```
72
+
73
+ projection은 키별로 다음을 방출: top-level `id, timestamp, protocol, operation, mode, enforced, blocked, direction?`; detection별 `type, ruleId, path, kind, confidence, action, enforced`; `summary.{byType, byAction, detectionCount}`; `auditIntegrity.{sequence, previousHash, eventHash}`; `identity.{id, type, subjectHash, issuerHash, provider}`(`scopes`/`labels`/원본 subject는 **절대** 아님). `payloadShapeHash`는 포함 가능(shape-only 해시, 비민감).
74
+
75
+ **웹 보안 명세 (선택지 아닌 합격 기준):**
76
+
77
+ - **XSS.** allowlist된 `detections[].path`는 클라이언트 JSON 키에서 파생됨(요청 키 `<img onerror>`가 로그에 도달). **allowlist는 필드 *이름*을 한정(누출 봉쇄); CSP + `textContent` 렌더링이 악성 *값*을 무력화** — 둘 다 필수이며 독립적. 클라이언트는 `createElement` + `textContent`만으로 DOM 구성(보간 `innerHTML` 금지). CSP(모든 응답, 그대로): `default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; require-trusted-types-for 'script'` — Trusted Types가 잔류 `innerHTML` 싱크를 브라우저에서 throw시켜 관례를 강제 보장으로 전환. 인라인 스크립트/스타일 없음(동일출처 자산 파일), 외부 CDN 없음, `eval` 없음.
78
+ - **보안 헤더 (모든 응답):** `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`(레거시 clickjacking 폴백), `Cross-Origin-Resource-Policy: same-origin` 및 `Cross-Origin-Opener-Policy: same-origin`(CORP same-origin은 교차출처 페이지가 `/api/*`를 리소스로 읽는 것을 차단 — Host 체크와 독립인 rebinding/no-cors 유출 2차 방어). `Cache-Control: no-store`를 `/api/*` **및** HTML 셸(라이브 감사 데이터 렌더)에; JS/CSS는 짧은 validated cache(또는 전역 `no-store` — localhost 도구는 캐시 이득 없음).
79
+ - **메서드 allowlist:** `GET`/`HEAD`만; 그 외 → `405`. `POST`/`DELETE` 표면 **없음**(reveal·purge·정책 편집 없음 — CLI에서 reveal governance 하에 유지). "읽기 전용"은 **감사 데이터 변경 없음 + 특권 동작 없음**을 뜻하며, `/api/chain`은 bounded compute 부작용이 있음(인정) — 캐시 + 크기 상한 + (아래) rate limit으로 한정.
80
+ - **일반화된 에러 (정보 노출 없음).** 핸들러 에러는 고정 `{ error: "internal" }` 5xx 반환 — 스택·메시지·OS 에러코드·절대 경로(`auditPath`/`anchorPath`는 민감; anchor 경로는 out-of-band truncation 방어) **절대 금지**. `verifyAuditChain` `reason`은 서버측 로그만.
81
+ - **Rate limiting / DoS.** proxy의 export된 `createRateLimiter`를 재사용해 `/api/*`에 source별 상한(체인 검증 `mtime+size` 캐시에 추가), 미인증 loopback 호출자(또는 rebinding 페이지)가 `/api/chain`으로 CPU 코어를 점유 못 하게. 이벤트 읽기는 **bounded 바이트/줄 윈도**를 tail·stream-parse — 전체 파일 미적재.
82
+ - **원격 bind은 세션 가드 *와* TLS 요구**(위 우선순위): 유일한 미인증 모드는 **loopback**(거기서도 Host-allowlist + CORP 적용).
83
+ - **plaintext 절대 없음.** 이미 정제된 필드만, projection; identity는 `subjectHash`/`issuerHash`/`id`만 표시.
84
+
85
+ **패키징:** 신규 위성 `haechi-dashboard`, **zero runtime dependency**(`node:` 빌트인만), `peerDependencies: { haechi: ">=0.8.0 <1.0.0" }` + `devDependencies: { haechi: "*" }`, 자체 bin과 `publishConfig: { access: "public", provenance: true }`. core CLI 변경 없음 — 위성이 자체 진입점 소유; core는 위성을 참조하지 않음.
86
+
87
+ ### 2.2 `haechi-auth-oidc` — 인터랙티브 OIDC 세션 브로커
88
+
89
+ `createOidcSessionBroker(options)`를 노출(§2.1 규율을 미러링하는 export형 fail-closed **`normalizeOidcConfig`** 포함 — `issuer`/`clientId`/`clientSecret`/`redirectUri`/`scopes`/`cookie`/`returnToAllowlist`/`sessionTtlSeconds`/`idleTtlSeconds`/`maxAgeSeconds`/`tokenEndpointAuthMethod`에 대한 열거형 throw, 모두 `configuration.md`에 문서화). **PKCE를 동반한 authorization-code 플로우**를 구현하고 대시보드가 소비할 **서버측 세션**을 생성한다(대시보드 `sessionGuard` seam을 충족 — §2.3). `authProvider`(요청별 bearer)가 **아니다** — 그 역할은 `haechi-auth-jwt`.
90
+
91
+ **생성 시점 체크 (fail-closed):** `cryptoProvider.hmac` 필수(없으면 PII-safe identity 불가); `issuer`는 유효 HTTPS URL; `redirectUri`는 유효 절대 URL, **https(또는 동일 carve-out 하 loopback http)·브로커와 동일출처**, 그 **path가 마운트된 `/auth/callback`과 동일**(동일한 `redirect_uri`를 인가 요청과 토큰 교환 양쪽에 전송, RFC 6749); `openid`는 항상 `scopes`에 강제 포함(dedup)·`offline_access`는 제거(refresh 처리는 범위 밖, §3); loopback 밖에서 외부 HTTPS 미확인 시 거부(쿠키 강화는 로컬 소켓이 아니라 **외부 가시** 스킴 기준 — `secureCookies: true|'auto'`/`trustProxy` 제공으로 TLS 종단 역프록시가 `Secure` + `__Host-` 강제; 기본 fail-closed).
92
+
93
+ **플로우 핸들러 (대시보드가 정확한 리터럴 경로로 마운트):**
94
+
95
+ - `GET /auth/login` — CSPRNG `state`·`nonce`·PKCE `code_verifier` 생성; `code_challenge = S256(code_verifier)`(**S256 필수, `plain` 절대 금지**); 트리오 + **고정(pinned)된 resolved `issuer`/`token_endpoint`/`jwks_uri`**를 짧은 TTL **pre-auth 쿠키**에 키된 서버측 **pending-auth** 레코드에 저장; 정확한 `redirect_uri`로 discovered `authorization_endpoint`에 `302`. `maxAgeSeconds` 구성 시 `max_age` 전송(콜백에서 `auth_time` 요구).
96
+ - `GET /auth/callback` — **state-first 단락**(타이밍/오라클 갭 차단): pre-auth 쿠키로 pending 레코드를 **원자적 `take()`**하고 `record.state === query.state`를 **모든 아웃바운드 요청 전**에 단언; 누락/사용됨/불일치 state 또는 누락/불일치 pre-auth 쿠키 → IdP 왕복 **없이** deny(authorization-code injection / login-CSRF·replay TOCTOU 격퇴). 이후 **고정된 `token_endpoint`에서만** `code_verifier`(+ 아래 클라이언트 인증)로 `code` 교환; ID 토큰 **검증**(아래 공유 검증기 + ID-token 프로필) — **`nonce` 일치**와 (RFC 9207) 반환된 `iss` 응답 파라미터가 고정 issuer와 동일(mix-up 방어) 포함; **새 세션 id 발급**(pre-auth 쿠키와 기존 세션 폐기 — fixation 없음); 세션 쿠키 설정; **allowlist된 상대** 반환 경로(기본 `/`)로 `302`.
97
+ - `POST /auth/logout` — **비-GET, CSRF 보호**(세션별 synchronizer 토큰 또는 `connect-src 'self'`가 이미 함의하는 동일출처 커스텀 헤더 fetch — `SameSite`에만 의존 금지). **서버측 세션 상태 완전 파기**(이후 옛 쿠키 재생 → `401`), 쿠키 제거. 선택적 RP-initiated logout: `id_token_hint` + 새 `state` 전송; `post_logout_redirect_uri`는 사전 등록/allowlist된 절대 URL(`returnToAllowlist` 규율 재사용)이거나 생략(logout open-redirect 없음).
98
+
99
+ **OIDC discovery (SSRF-강화):**
100
+
101
+ - `<issuer>/.well-known/openid-configuration`를 **HTTPS만**으로 fetch, body 한정(≤ 1 MiB), 엄격 JSON depth; **`metadata.issuer`가 구성 `issuer`와 문자열 동일하지 않으면 거부**(OIDC Discovery §4.3 / RFC 8414 — issuer-confusion 가드)하고 검증기의 기대 `iss`를 거기에 고정.
102
+ - **single-origin만 (0.9):** `authorization_endpoint`·`token_endpoint`·`jwks_uri`·`end_session_endpoint`가 **issuer 호스트명**을 공유해야 함 — `haechi-auth-jwt` 0.8과 동일 제약·근거(multi-origin/CDN-fronted IdP는 범위 밖). 교차출처 엔드포인트는 discovery/생성 시 거부.
103
+
104
+ **모든 아웃바운드 egress가 동일 가드를 실행(JWKS/discovery뿐 아님).** authorization-code 플로우는 공유 JWKS 검증기가 절대 만들지 않는 **`token_endpoint` POST**를 추가한다; discovery와 교환 사이에 `169.254.169.254`로 DNS-rebind하는 token endpoint가 전형적 메타데이터 유출 경로다. 그래서 **discovery GET·JWKS GET(공유 검증기)·토큰 교환 POST·end-session 리다이렉트**가 각각 **요청 직전에 `lookup`→`isBlockedAddress` 재확인**(post-DNS, rebinding 가드 — `127/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16` 포함 `169.254.169.254`, `fe80::/10` 거부)을 `redirect: "error"`·**한정된 응답 body**·fetch 타임아웃과 함께 실행. (auth 생태계는 **하나의** 사본을 공유한다: `haechi-auth-jwt`가 `isBlockedAddress`를 export하고 `haechi-auth-oidc`가 재사용. `haechi-crypto-kms` Vault 백엔드는 auth 패키지에 런타임 의존하는 대신 **자체** 사본을 의도적으로 유지한다 — §2.4 참조 — dev-only parity 테스트로 정합 유지.)
105
+
106
+ **공유 JWS 검증기 + ID-token 프로필.** 0.9는 `haechi-auth-jwt@0.2.0`을 리팩터해 기존 내부 `resolveJwk`/`verifySignature`/클레임 검증에서 떼어낸 독립 검증기 프리미티브(예: `createJwtVerifier`/`verifyJwt`)를 **가산적으로 export** — **동작 보존**: 프리미티브는 **서명 + `alg`/`kid`/RSA-bits + `iss`/`aud`/`exp`/`nbf`만** 검증(정확히 0.8 표면)하며, **`nonce`는 프리미티브에 넣지 않는다**(bearer JWT엔 nonce 없음) — auth-oidc가 프리미티브가 검증된 클레임을 반환한 *뒤*에 검증(또는 생략 시 no-op인 선택적 `expectedNonce`). `createJwtAuthProvider`는 프리미티브 위에 재구현되고 Bearer-헤더 파싱을 계속 소유하므로 **0.8 §6.3 테스트가 전부 그대로 통과**. `haechi-auth-oidc`는 **`haechi-auth-jwt >=0.2.0 <1.0.0`에 peer-depend**하고 프리미티브를 사용해 **단일 감사 JWS/JWKS 검증 경로**를 만든다.
107
+
108
+ 0.8 JWT 보안 명세 전체가 ID-token 검증에 그대로 적용(서버측 `alg` 선택, `alg:none` 거부, alg-confusion 차단, `kid` 필수, RSA ≥ 2048, JWK `use`/`key_ops` 의도, `typ`/no-JWE, `exp`/`nbf` 필수, `clockSkew` ≤ 300 s, SSRF-강화 bounded JWKS, 60s당 ≤ 1 refetch). **여기에 lenient bearer `aud` 체크와 구분되는 OIDC ID-token 프로필**(0.8 `audienceMatches`는 audience를 포함하는 임의 배열을 허용 — ID token엔 비준수): `aud`는 `clientId`를 포함해야 하고; **`aud`가 다중값이면 `azp`가 있어야 하며 `azp === clientId`**; 단일값 `aud`는 `clientId`와 동일(OIDC Core §3.1.3.7 — cross-client/mix-up 차단). 브로커는 **순수 로그인** 소비자: access token을 **폐기**(저장·사용 안 함)하여 서버측 비밀 표면을 줄이고 `at_hash`/`c_hash` 검증을 의도적 범위 밖으로(문서화).
109
+
110
+ **토큰 엔드포인트 클라이언트 인증:** 기본 **`client_secret_basic`**(HTTP Basic, RFC 6749 §2.3.1), `client_secret_post`는 명시적 opt-in; discovery에서 구성 메서드가 `token_endpoint_auth_methods_supported`에 있는지 단언하고 **confidential 클라이언트를 `none`으로 다운그레이드 금지**. `client_secret`은 Basic 헤더 또는 POST body에만 — URL/query **절대 금지**, 로깅 **절대 금지**. public 클라이언트(PKCE-only, 비밀 없음)도 지원.
111
+
112
+ **세션 보안 (합격 기준):**
113
+
114
+ - **서버측 세션; 토큰은 브라우저에 절대 도달 안 함.** 세션 id = 고엔트로피 CSPRNG 불투명 값(≥ 256-bit); **쿠키는 id만** 운반. ID/access/refresh 토큰·`client_secret`은 서버측에만(access 토큰은 폐기) 보관되고 클라이언트로 전송/로깅 **절대 안 함**. 기본 저장소는 in-memory이며, 동시성 하 단일사용 의미를 위해 **원자적 `take()`**(consume-and-delete)를 요구하는 주입형 `sessionStore`/`pendingStore` 계약 문서화; TTL + idle eviction.
115
+ - **구분된 두 쿠키, 둘 다 강화.** `__Host-haechi_preauth`(로그인 시, 단일사용, **콜백에서 제거**)와 `__Host-haechi_session`(콜백 후). 둘 다 `HttpOnly`, `SameSite=Lax`(Lax — Strict 아님 — IdP→`/callback` top-level GET이 쿠키를 운반하도록; Strict면 누락되어 로그인 깨짐), `Path=/`, 외부 가시 스킴이 https일 때 **`Secure` + `__Host-` 접두사(Domain 금지·`Path=/` 강제) 필수**(로컬 소켓 아닌 forwarded/선언 스킴 기준 — 생성 체크 참조).
116
+ - **PII-safe identity**는 core `buildExternalIdentity` 경유(ID-token `sub`에서 키드-HMAC `subjectHash`, 도메인 `haechi:identity:hash:v1`; `provider: "oidc"`); 원본 `sub`/email/name은 **절대** 로깅/저장 안 함.
117
+ - **Open-redirect 방어:** 로그인 후 `return_to`는 **상대·동일출처 경로**여야 하며 `returnToAllowlist`로 검증; 절대/교차출처 URL은 거부 → `/`로 폴백.
118
+ - **Rate-limiting / anti-DoS:** **하드 pending-auth 상한** + 명시적 오버플로 = **새 `/auth/login`을 일반화된 `429`/`503`으로 거부(fail-closed; in-flight 인증을 조용히 evict 금지)**, 그리고 `/auth/login`·`/auth/callback`에 source별 rate limit(`createRateLimiter` 재사용)으로 pending 저장소 고갈·CSPRNG/PKCE CPU 점유 차단.
119
+ - **fail-closed 전반:** discovery/교환/검증/state-불일치 에러 → 세션 없음, 일반화된 deny, **IdP 에러 detail 미반향**, 모든 콜백 실패에 동일 status+body(state-first 단락이 이미 아웃바운드 부작용으로 unknown-state와 bad-code를 구분 못 하게 함).
120
+
121
+ **브로커 감사 추적 (PII-safe, 대시보드의 존재 이유).** `createOidcSessionBroker`는 주입형 **`auditSink`**를 받아 `oidc.login.start`·`oidc.login.success`·`oidc.login.failure{ reasonCode }`·`oidc.logout`·`oidc.session.evict`를 방출 — 각각 **오직** `subjectHash`/`issuerHash`/`sessionIdHash`(키드-HMAC; 원본 세션 id 절대 아님)·`provider:"oidc"`·coarse `reasonCode` enum(`state_mismatch|nonce_mismatch|token_invalid|exchange_failed|host_blocked|expired`)·timestamp를 운반 — `/auth/callback` 대상 실패-로그인/브루트포스가 **가시화**된다(auth-jwt 같은 요청별 검증기는 생략 가능하지만 인터랙티브 로그인은 불가). 브로커는 자체 allowlist로 projection하며(그리고 core `FORBIDDEN_KEYS`를 `access_token`/`id_token`/`refresh_token`/`code`/`code_verifier`/`client_secret`/`state`/`nonce`/`sub`/`email`까지 **확장**) 미래 필드가 절대 새지 않게 한다. 방출 이벤트마다 `JSON.stringify`에 그 토큰/비밀/원본-클레임 문자열이 없음을 테스트로 단언. *(주: `FORBIDDEN_KEYS` 확장이 `packages/audit`에 대한 유일한 touch — 가산적 set 멤버, 기존 이벤트 동작 불변.)*
122
+
123
+ **패키징:** 신규 위성 `haechi-auth-oidc`, **zero runtime dependency**(`node:` `fetch`/`crypto`/`http`로 충분), `peerDependencies: { haechi: ">=0.8.0 <1.0.0", "haechi-auth-jwt": ">=0.2.0 <1.0.0" }` — **core peer는 `>=0.8.0` 유지**(auth-oidc는 0.6/0.8부터 있는 `buildExternalIdentity`만 사용; `>=0.9.0`으로 **과도 강화 금지**), **auth-jwt peer는 `>=0.2.0`**(검증기 export가 신규이므로). 추가로 `devDependencies: { haechi: "*" }`, `publishConfig: { access: "public", provenance: true }`, 접두 태그 발행 워크플로 `auth-oidc-v<semver>`.
124
+
125
+ ### 2.3 대시보드 ↔ OIDC 통합 seam (주입, 하드 의존 아님)
126
+
127
+ 두 위성은 **릴리스에서 짝지어지되 코드에서 분리** — 주입으로:
128
+
129
+ - `haechi-dashboard`는 `sessionGuard` 계약을 정의: `{ authenticate(request) -> session | null, handlers: { "/auth/login", "/auth/callback", "/auth/logout" } }`. 대시보드가 `handlers`를 마운트하고 모든 `/api/*`를 `authenticate` 뒤로 게이트.
130
+ - `haechi-auth-oidc`의 `createOidcSessionBroker(...)`가 그 계약을 충족하는 객체를 반환.
131
+ - 연결은 명시적: `createDashboardServer({ ..., sessionGuard: createOidcSessionBroker({ ... }) })`. 대시보드는 auth-oidc에 **peer 의존 없음**(가드는 `cryptoProvider`처럼 주입); 어느 위성이든 독립 사용 가능. 필요한 짝지음은 **fail-closed 규칙**: 원격 bind ⇒ 가드 존재 필수(§2.1).
132
+ - **게이트 정밀도:** 가드된 대시보드의 미인증 `/api/*` 요청은 **`401` 반환(`302` 절대 아님** — 리다이렉트된 XHR/fetch는 로그인 URL 누출·루프; 정적 셸이 리다이렉트 수행). **정확히** 세 리터럴 핸들러 경로만 **정확 일치(`/auth/` 접두 아님)**로 게이트 면제 — 그 외 경로(미지 `/auth/*` 포함)는 게이트 또는 `404`, 미래 브로커 경로가 미인증 우회가 되지 못함. `/healthz`는 loopback 밖에서도 세션 없이 도달 가능(liveness만).
133
+
134
+ ### 2.4 `haechi-crypto-kms` Vault / GCP / Azure 백엔드 (독립 `0.2.0`)
135
+
136
+ 병렬·분리 트랙: 가산적 백엔드를 **`haechi-crypto-kms@0.2.0`**(가산적 minor — 새 subpath export, AWS·in-memory 클라이언트 불변)으로 출시, core 0.9.0 컷과 **무관**. 각각 AWS 클라이언트가 0.8에서 정립한 **동일 `kms` 인터페이스**(`keyId`/`wrap(Buffer)->string`/`unwrap(string)->Buffer`/`deriveHmacKey`)를 구현하며, 동일한 **optional-peer + lazy-import + injected-client** 모델과 동일한 **faithful-mock conformance** 기준(cross-key 거부, corrupted-blob 거부, HMAC 결정성/도메인 분리 — CI에 SDK·네트워크 없음).
137
+
138
+ - **`./gcp`** — Google Cloud KMS, optional peer `@google-cloud/kms`(lazy). `wrap` = CSPRNG 32바이트 데이터키의 `encrypt`; `unwrap` = `decrypt`; `deriveHmacKey(domain)` = 복호화된 32바이트 root 1개(`hmacRootCiphertext`, 캐시)에 대한 HKDF-SHA256, 도메인 분리 — `aws.mjs`와 동일 형태.
139
+ - **`./azure`** — Azure Key Vault, optional peer `@azure/keyvault-keys` + `@azure/identity`(lazy). 네이티브 `wrapKey`/`unwrapKey`로 데이터키 envelope; `deriveHmacKey` = unwrap된 root에 대한 HKDF.
140
+ - **`./vault`** — HashiCorp Vault Transit, **optional-peer 없음**(Transit 엔진은 `node:` `fetch`로 닿는 평범한 HTTP API — 가장 의존성-경량 백엔드). 정확한 wire 형태(load-bearing): `wrap` = `POST {addr}/v1/transit/encrypt/{key}` with `plaintext = base64(dataKey)`, `data.ciphertext`(`vault:v1:…`) 반환; `unwrap` = `POST .../decrypt/{key}` 후 **`Buffer.from(data.plaintext, "base64")`**(32바이트 Buffer로 base64 디코드는 필수, 아니면 HKDF root가 쓰레기); 결정성을 위해 **non-derived** transit 키(또는 고정 `context`) 요구; `hmacRootCiphertext`는 transit-암호화된 32바이트 root를 한 번 복호·캐시, `aws.mjs` `hmacRoot()`와 동일. Vault `fetch` egress는 auth egress와 **동일 `lookup`→`isBlockedAddress` 가드 + `redirect:"error"` + bounded body + timeout** 실행(운영자 공급 `VAULT_ADDR`가 클라우드에서 메타데이터로 rebind 가능). **이 가드는 위성 로컬 `isBlockedAddress`** — `haechi-auth-jwt`에 대한 런타임 의존이 *아님*: 키 보관 패키지가 IP 판별 하나 때문에 auth 생태계를 끌어오면 안 된다. IP 범위 로직은 RFC-안정적이고 auth-jwt와 동일하며, **dev-only 크로스-패키지 parity 테스트**(auth-jwt를 `devDependency`로)가 두 사본이 범위 테이블에서 일치함을 단언해 드리프트를 막고, 발행되는 `haechi-crypto-kms`는 auth 결합 없이 **zero runtime dependency**를 유지한다. (이는 앞선 "재사용, 사본 금지" 의도를 의도적으로 개정한 것 — crypto의 auth 런타임 디커플링이 단일 공유 사본보다 우선.)
141
+
142
+ 모든 백엔드는 **provider 에러를 일반화된 fail-closed 에러로 매핑**하고 (키 ARN/경로를 반향할 수 있는) **KMS/provider 에러 detail을 audit에 절대 기록 안 함**. 각각 자체 subpath export + `files` 엔트리, SDK 기반은 `peerDependenciesMeta.optional`; **`haechi` tarball은 zero-dep 유지**(0.8 패키징 게이트 무영향). **하드코딩된 provider `version` 필드 정합**(`satellites/crypto-kms/index.mjs`가 `version: "0.1.0"` 반환, 이미 패키지 `0.1.1`과 stale) — 제거/파생해 `0.2.0`이 오보하지 않도록. `0.2.0` 릴리스는 0.8에서 부트스트랩한 `crypto-kms-v<semver>` 태그 + Trusted Publisher 재사용.
143
+
144
+ ## 3. 명시적 비범위 (0.9.x / 1.0으로 연기)
145
+
146
+ - **대시보드 쓰기 동작**(reveal·purge·정책 편집) — 읽기 전용만; 변경은 CLI에서 reveal governance 하에. `POST`/`DELETE` 표면 없음.
147
+ - **대시보드 토큰볼트/정책 시각화** — 0.9는 감사만.
148
+ - **프레임워크 SPA / 빌드 스텝** — 바닐라 zero-dep만.
149
+ - **multi-origin / CDN-fronted IdP**(issuer 호스트 ≠ JWKS/엔드포인트 호스트) — single-origin만, `haechi-auth-jwt` 0.8과 동일.
150
+ - **Refresh-token 회전 / 무음 갱신 / 장수명 세션** — 0.9 세션은 절대-TTL + idle-timeout만; `offline_access` 제거; access token 폐기.
151
+ - **`at_hash`/`c_hash` 검증** — 브로커가 access token을 쓰지 않으므로 정확히 범위 밖.
152
+ - **비-OIDC 인터랙티브 인증**(SAML, LDAP).
153
+ - **위성 동적 로딩** — 1.0 plugin sandbox까지 금지; 대시보드·브로커는 **명시적 주입**으로 연결, 구성된 패키지명의 동적 `import()` 절대 아님.
154
+
155
+ ## 4. 하위 호환
156
+
157
+ core 동작 **불변** — zero-dep 자세 유지, 기존 config/API 불변. 기존 패키지에 대한 두 touch는 둘 다 **가산적·동작보존**: (a) `haechi-auth-jwt@0.2.0`이 검증기 프리미티브를 export하고 그 위에 `createJwtAuthProvider` 재구현(0.8 테스트 그대로); (b) `packages/audit`가 `FORBIDDEN_KEYS`에 멤버 추가(브로커 토큰/클레임 키) — 기존 이벤트 형태 불변. `assertSafeProxyBind`는 **이미 export된 `haechi/proxy`에서 재사용**(재배치·신규 core export 없음). 모든 0.9 산출물은 신규·가산적·opt-in 위성.
158
+
159
+ ## 5. 1.0 관계
160
+
161
+ 0.9 자체가 1.0 블로커를 닫지는 않지만 두 1.0 스토리를 진전시킨다: **운영 관측가능성**(대시보드가 [[audit-integrity]] 해시체인 상태 + decision 스트림을 점검 가능하게 — real-environment-validation 종료 기준 지원)과 **인터랙티브 인증**(브로커가 `haechi-auth-jwt`가 남긴 사람-로그인 절반을 완성). 남은 1.0 게이트는 불변: API-stability freeze와 plugin sandbox + 동적 로딩 스토리.
162
+
163
+ ## 6. 위협 모델 & 리스크 레지스터 델타 (구체적, "TBD" 아님)
164
+
165
+ 릴리스 컷에서 `threat-model.md`(+ `.ko`) §3 Threats-and-Controls에 다음 행을 추가하고, 리스크 레지스터 ID를 추가한다(레지스터 목표 버전 헤더 `0.7.0 → 0.9.0`, 새 게이트 행):
166
+
167
+ | 신규 위협 / 표면 | 통제 | 잔여 |
168
+ |---|---|---|
169
+ | 공격자 제어 `detections[].path` 통한 대시보드 감사 뷰어 **XSS** | CSP(`require-trusted-types-for`) + `textContent`-only 렌더 | 실질 없음 |
170
+ | 뷰어 통한 **감사 필드 누출**(미래 필드) | 재귀 키별 allowlist projection(+ `FORBIDDEN_KEYS`) | 새 중첩 필드는 기본 drop |
171
+ | localhost-bound 뷰어에서 **DNS-rebinding** 감사 JSON 읽기 | Host 헤더 allowlist(요청별) + CORP/COOP same-origin | 실질 없음 |
172
+ | **원격** bind에서 미인증 감사 읽기 | fail-closed: 원격 ⇒ `sessionGuard` **및** TLS 필수 | 운영자 TLS 종단 필요 |
173
+ | OIDC **login CSRF / authorization-code injection / open-redirect / session fixation** | state↔pre-auth-cookie 바인딩, 원자적 `take()`, PKCE S256, 콜백 시 새 세션 id, `returnToAllowlist`, logout CSRF 토큰 | 단일 IdP엔 실질 없음 |
174
+ | OIDC **mix-up**(잘못된 IdP / 잘못된 RP) | issuer/엔드포인트를 pending 레코드에 고정, RFC 9207 `iss` 체크, ID-token `aud`/`azp` 프로필, `metadata.issuer` == config | multi-origin IdP 범위 밖 |
175
+ | token-endpoint POST(및 Vault `fetch`) 통한 브로커 **클라우드 메타데이터 SSRF** | egress별 post-DNS `isBlockedAddress` 재확인 + bounded body + timeout + `redirect:"error"` | 운영자-신뢰 엔드포인트만 |
176
+ | audit/logs로 **토큰/비밀 누출** | 브로커 allowlist projection + 확장 `FORBIDDEN_KEYS`; access token 폐기 | 실질 없음 |
177
+ | KMS 백엔드 egress(Vault HTTP, GCP/Azure SDK) | optional-peer + injected-client conformance, 일반화 fail-closed 에러, audit에 provider detail 없음 | 라이브 백엔드 검증은 CI 밖 |
178
+
179
+ 제안 리스크 ID: **P1-SEC-009**(브로커 세션/로그인 보안), **P1-OPS-005**(대시보드 감사 노출 / rebinding / 원격 bind), **P2-CRYPTO-00x**(KMS 백엔드 egress). 신규 §4 제외: multi-origin IdP, refresh 회전, 대시보드 쓰기 동작, `at_hash` 검증.
180
+
181
+ ## 7. 테스트 기준 (PR 분해에 매핑)
182
+
183
+ ### 7.1 PR1 — `haechi-auth-jwt@0.2.0` 검증기 추출 (가산적, 동작보존)
184
+
185
+ - **`satellites/auth-jwt/package.json` `0.1.1 → 0.2.0` bump**(발행 워크플로 tag==package-version 게이트 요구).
186
+ - 신규 `createJwtVerifier`/`verifyJwt` 프리미티브가 0.8 §6.3 보안 게이트 스위트 전체 통과(모든 deny 케이스); **`nonce`는 프리미티브 일부가 아님**(생략 시 no-op `expectedNonce`).
187
+ - 프리미티브 위에 재구현된 `createJwtAuthProvider`가 기존 0.8 테스트를 **그대로** 통과(동작보존 회귀 가드); Bearer-헤더 파싱 계속 소유.
188
+ - 위성 tarball `dependencies: {}` 유지; core tarball zero-dep 유지.
189
+
190
+ ### 7.2 PR2 — `haechi-dashboard` (zero-dep 읽기 전용 뷰어)
191
+
192
+ - 기본 loopback bind; `allowRemoteBind` 없는 non-loopback → 거부(대시보드 문구로 rethrow); `sessionGuard` 없는 `allowRemoteBind:true` → throw; TLS/trusted-proxy 미확인 원격 → throw. `normalizeDashboardConfig`가 잘못된 옵션마다 안정적 에러로 거부.
193
+ - **anti-rebinding:** loopback 대시보드에 `Host: evil.example` → `403`; Host 매트릭스(`localhost.`, `127.0.0.1:PORT`, `::ffff:127.0.0.1`, 예상 외 FQDN, 중복 `Host`)가 올바르게 동작; `Access-Control-Allow-Origin` 절대 방출 안 함.
194
+ - `GET /api/events`: 상한 `limit`(`-1`/`abc`/`1e9` 거부), 불투명 `cursor`(변형 → `400`), **재귀 allowlist**가 **각** 계층(top, `detections[]`, `identity`, `summary`, `auditIntegrity`)에 주입된 합성 추가 필드를 drop; window-exceeded 페이지는 에러 아닌 마커; 찢어진 후행 줄 `500` 안 남.
195
+ - `GET /api/chain`: 형태가 **실제** `verifyAuditChain` 출력과 일치; truncated-with-anchor 픽스처가 `valid:false` + 파생 `truncationDetected`를 원문 `reason`/`eventHash` **누출 없이** 표면화; 동시 폴이 **1회** walk(mtime+size 캐시) 유발; 초과 크기 픽스처 → `413`; `HEAD`는 walk 미강제.
196
+ - **XSS:** `detections[].path`에 `<script>`/`<img onerror>` 있는 이벤트가 inert 렌더(서빙 JS가 `textContent` 사용); 정확한 CSP 헤더 문자열(`object-src 'none'`, `require-trusted-types-for 'script'` 포함) + `nosniff` + `XFO:DENY` + `CORP/COOP same-origin` + `no-store` 단언.
197
+ - **메서드/자산/에러:** `POST`/`DELETE` → `405`; `/../../etc/passwd`가 고정 자산 맵 탈출 불가(`404`, fs 읽기 없음); 강제 fs 에러가 경로 부분문자열/스택 **없는** `{error:"internal"}` 산출; `/healthz`는 아무것도 누출 안 함.
198
+ - **DoS:** 다수-MB 감사 픽스처가 bounded tail 윈도로 서빙; `/api/*` rate-limited.
199
+ - tarball `dependencies: {}`; provenance로 발행.
200
+
201
+ ### 7.3 PR3 — `haechi-auth-oidc` (인터랙티브 브로커, 보안 게이트)
202
+
203
+ - **정상 경로**(stub discovery + token endpoint + JWKS, RS256 ID token): `/auth/login` → `state`+`nonce`+`code_challenge`(S256) + 등록된 `redirect_uri` 있는 `302`; 일치 state + 유효 code의 `/auth/callback`이 교환·검증·로그인 전 쿠키와 무관한 **새** 세션 id 발급; 쿠키는 `__Host-` 네임·`HttpOnly`·`SameSite=Lax`, non-loopback 구성 하 `Secure`; pre-auth 쿠키 제거됨.
204
+ - **각각 deny**(세션 없음, 일반화된 동일 응답, 미반향, state 실패 시 아웃바운드 없음): 불일치/재생/만료 `state`(원자적 `take()`로 동시 재생이 레코드 못 찾음); 누락/불일치 pre-auth 쿠키(login-CSRF/code-injection); `nonce` 불일치; `alg:none`/alg-confusion; 만료/`nbf`/잘못된 `aud`/잘못된 `iss` ID token; **`azp` 없는 다중 `aud`**, **`azp !== clientId`**; `metadata.issuer` ≠ config; RFC 9207 `iss` ≠ pinned; code 교환 실패; **교차출처** `token_endpoint`/`jwks_uri` discovery doc; discovery/JWKS/**token_endpoint** 호스트가 **요청 시점**(post-DNS) private/metadata 범위로 해석; 초과 크기 token-endpoint 응답.
205
+ - **토큰 무누출:** 로그인 후 브라우저 가시 쿠키는 불투명 id만; 모든 클라이언트 응답 **및** audit 로그의 `JSON.stringify`에 ID/access/refresh 토큰·`client_secret`·`code`·`state`·`nonce`·원본 `sub` **없음**. access token **폐기**(저장 안 됨 단언).
206
+ - **세션/로그아웃:** `POST /auth/logout` 후 옛 쿠키 재생 → `401`·서버측 레코드 소멸; logout이 CSRF 토큰 요구(위조 교차출처 POST 거부); allowlist 밖 `post_logout_redirect_uri` 거부.
207
+ - **Open-redirect:** `return_to=https://evil.example`(또는 교차출처/절대) → `/`로 폴백; allowlist된 상대 경로 존중.
208
+ - **Rate/DoS:** N회 빠른 `/auth/login`이 pending 상한에 닿아 메모리 고갈 없이 일반 `429`/`503` 반환; `/auth/login` + `/auth/callback` rate-limited.
209
+ - **감사:** `oidc.login.{start,success,failure}` / `oidc.logout` / `oidc.session.evict`가 `*Hash`/`reasonCode`/`provider`/timestamp만으로 방출; 확장 `FORBIDDEN_KEYS` 테스트 통과.
210
+ - **생성 fail-closed:** `cryptoProvider.hmac` 누락; non-https/교차출처 `issuer`/`redirectUri`; `redirectUri` path ≠ `/auth/callback`; TLS/Secure 없는 loopback 밖; `normalizeOidcConfig`가 잘못된 옵션마다 거부.
211
+ - **seam:** 브로커가 대시보드 `sessionGuard` 충족; 마운트 시 원격-bound 대시보드의 미인증 `/api/events` → **`401`**(`302` 아님); `/auth/anything-else`는 미인증 우회 아님; `/healthz`는 loopback 밖 미인증 `200`인데 `/api/events`는 `401`.
212
+
213
+ ### 7.4 PR4 — `haechi-crypto-kms@0.2.0` (GCP / Azure / Vault 백엔드)
214
+
215
+ - GCP/Azure/Vault 각각 **faithful injected mock**(SDK·네트워크 없음)으로 `assertCryptoProviderConformance` 통과, cross-key + corrupted-blob **거부**·HMAC 결정성/도메인 분리 포함; `createRuntime` end-to-end(encrypt + tokenization 왕복).
216
+ - **Vault** 백엔드는 **`node:` `fetch`만**(optional peer 없음), **base64 왕복**(encrypt `plaintext=base64(dataKey)` → decrypt → `Buffer.from(...,"base64")`)·non-derived 키·`VAULT_ADDR` SSRF 가드 행사; GCP/Azure는 SDK를 `peerDependenciesMeta.optional`로 선언, 클라이언트 미주입 시에만 lazy import.
217
+ - provider 에러가 일반화 fail-closed 에러로 매핑; provider/키-ARN detail이 audit에 안 닿음.
218
+ - 하드코딩 provider `version` 필드 정합(`"0.1.0"` 아님).
219
+ - 발행된 `haechi-crypto-kms@0.2.0` tarball `dependencies: {}`; core tarball zero-dep 유지; 기존 `crypto-kms-v<semver>` 태그로 provenance 발행.
220
+
221
+ ### 7.5 모든 위성
222
+
223
+ - 신규/갱신 위성 각각 provenance + sigstore attestation으로 발행, 0.7/0.8처럼 릴리스 후 검증.
224
+
225
+ ## 8. 제안 PR 분해 (스택)
226
+
227
+ 1. **`haechi-auth-jwt@0.2.0` 검증기 추출** — 가산적 `createJwtVerifier`/`verifyJwt`(nonce는 외부 유지), `createJwtAuthProvider` 재구현, **0.2.0으로 bump**, 0.8 테스트 그대로. → §7.1
228
+ 2. **`haechi-dashboard`** — zero-dep `node:http` 뷰어: `normalizeDashboardConfig` + bind/guard/TLS 우선순위, anti-rebinding Host allowlist, 엄격 쿼리 파싱 + bounded reads + 재귀 allowlist + mtime-캐시 체인의 읽기 전용 event/chain/summary API, 엄격 CSP/Trusted Types + `textContent` 정적 페이지, 보안 헤더, 일반화 에러, rate limit, `sessionGuard` seam, 발행 워크플로 `dashboard-publish.yml`(guard `startsWith(tag,'dashboard-v')`, regex `^dashboard-v[0-9]+\.[0-9]+\.[0-9]+$`). 신규 dir용 lockfile 재생성. → §7.2
229
+ 3. **`haechi-auth-oidc`** — 인터랙티브 authorization-code + PKCE 브로커: `normalizeOidcConfig`, SSRF-강화 discovery + egress별 가드, §2.2 공유 검증기 통한 ID-token 프로필(nonce 외부), 원자적 `take()` pending 저장소, 강화 두-쿠키 세션 + 새-id 회전, open-redirect/CSRF/logout 방어, 브로커 감사 이벤트 + 확장 `FORBIDDEN_KEYS`, `sessionGuard` 구현, 발행 워크플로 `auth-oidc-publish.yml`. 신규 dir용 lockfile 재생성. → §7.3
230
+ 4. **`haechi-crypto-kms@0.2.0`** — GCP/Azure(optional-peer) + Vault(zero-dep, 위성 로컬 SSRF 가드 + auth-jwt 대비 dev-only parity 테스트) 백엔드, faithful-mock conformance, version 필드 정합; 기존 태그로 bump + 발행. → §7.4
231
+ 5. **0.9.0 릴리스 컷** — docs EN/KO(`configuration.md`의 대시보드/브로커 config, §6 위협모델 + 리스크 레지스터 델타와 구체 ID + 목표 버전 bump, 본 scope doc), 로드맵 행, api-stability, wiki ingest(신규 `haechi-dashboard`/`haechi-auth-oidc` 페이지 + `packaging-and-distribution`/`identity-and-auth` 갱신), 패키지별 Trusted Publisher 런북 행(신규 워크플로 파일명 둘 + 태그 글롭 + 각 언스코프드 이름을 선점하는 configure-TP-**before**-first-tag 부트스트랩).
@@ -0,0 +1,231 @@
1
+ # Haechi 0.9 Implementation Scope
2
+
3
+ - Status: Draft 0.2 (design — not yet implemented; hardened after an adversarial security review, 2026-06-11)
4
+ - Date: 2026-06-11
5
+ - Target version: 0.9.0 (after 0.8.0)
6
+ - Type: observability + interactive auth
7
+
8
+ ## 1. Release Goal
9
+
10
+ Deliver the **observability + interactive-auth** pair that 0.8 deliberately deferred to stay code-light:
11
+
12
+ - **`haechi-dashboard`** — a zero-dependency, read-only **audit viewer**: a `node:http` server that serves a single self-contained static page (vanilla JS, no framework, no build step) plus a read-only JSON API over the audit log and its hash-chain status.
13
+ - **`haechi-auth-oidc`** — an **interactive session broker**: the OIDC authorization-code + PKCE flow that lets a human log in through a browser and obtain a server-side session. This is the dashboard's login mechanism — a different concern from `haechi-auth-jwt` (which validates a *pre-obtained* bearer JWT per request).
14
+
15
+ Both are new **unscoped satellites** (`haechi-dashboard`, `haechi-auth-oidc`) following the 0.8 packaging model: peer-dep on core, zero-dep where the protocol allows, optional-peer for any heavy SDK, OIDC trusted publishing with provenance + sigstore.
16
+
17
+ **Scope decision (2026-06-11).** Confirmed with the maintainer:
18
+
19
+ 1. **Release unit:** `haechi-dashboard` + `haechi-auth-oidc` ship **paired** as the 0.9.0 theme (the dashboard needs human login; auth-oidc provides it). The **`haechi-crypto-kms` Vault/GCP/Azure backends ship independently** as `haechi-crypto-kms@0.2.0` — that satellite is versioned on its own and is **not gated on the core 0.9.0 cut**. This doc specifies all three but treats crypto-kms 0.2.0 as a parallel, decoupled track (§2.4).
20
+ 2. **Dashboard stack:** **zero-dependency vanilla** — `node:http` + a static HTML/JS/CSS page, no framework, no build step. Consistent with core's `node:`-builtins-only ethos and the satellites' dependency-light posture.
21
+ 3. **`haechi-auth-oidc` shape:** **interactive session broker** (authorization-code + PKCE + `/callback` + server-side sessions). Not a per-request token validator — that overlap stays with `haechi-auth-jwt`.
22
+ 4. **Dashboard data scope:** **audit viewer only** — the audit event stream + `verifyAuditChain` chain status + decision/action aggregates. Token-vault and policy visualization are out of scope for 0.9 (avoids brushing against reveal governance).
23
+
24
+ Core (`haechi`, unscoped) stays **zero runtime dependency** and is **not modified for behavior** in 0.9. The only existing-package change is an *additive, behavior-preserving* refactor of the `haechi-auth-jwt` satellite to export a reusable JWS verifier (§2.2) — **no `packages/*` (core) code change is required**. The dashboard's loopback guard reuses the already-exported `assertSafeProxyBind` from `haechi/proxy` (no core relocation — §2.1).
25
+
26
+ ### Version preconditions (live state as of 2026-06-11)
27
+
28
+ | Package | Current | 0.9 target | Why |
29
+ |---|---|---|---|
30
+ | `haechi` (core) | `0.8.0` (published) | `0.9.0` | release cut; behavior unchanged |
31
+ | `haechi-auth-jwt` | `0.1.1` (published) | **`0.2.0`** | additive verifier export (§2.2) — the publish workflow's tag==package-version gate requires the explicit bump |
32
+ | `haechi-crypto-kms` | `0.1.1` (published) | **`0.2.0`** | additive GCP/Azure/Vault backends (§2.4); also reconcile the hard-coded provider `version` field (§2.4) |
33
+ | `haechi-dashboard` | — (new) | `0.1.0` | first publish claims the unscoped name |
34
+ | `haechi-auth-oidc` | — (new) | `0.1.0` | first publish claims the unscoped name |
35
+
36
+ Per the workspace-lockfile rule (it has bitten us before), adding the two **new** `satellites/*` directories requires an `npm install` to regenerate `package-lock.json` with their workspace entries, committed in the same PR, or CI `npm ci` fails.
37
+
38
+ ## 2. Scope
39
+
40
+ ### 2.1 `haechi-dashboard` — zero-dep read-only audit viewer
41
+
42
+ A satellite exposing `createDashboardServer(options)` plus an optional bin (`haechi-dashboard`). It reads the existing audit JSONL (and anchor stream) and serves them read-only. **It never imports a framework, never has a build step, and ships exactly three static assets** (one HTML, one JS, one CSS) served from a **fixed in-code asset map** — never an `fs` path derived from the request URL (no path traversal).
43
+
44
+ **Config + fail-closed validation (config invariant parity).** Because satellites are wired by explicit injection rather than the core config file, the dashboard ships an exported **`normalizeDashboardConfig(options)`** that mirrors `normalizeConfig`'s discipline: **strict, fail-closed, enumerated throws at construction** (every option type-checked; unknown keys rejected). Fields: `auditPath` (string, required), `anchorPath` (string|null), `host` (default `127.0.0.1`), `port` (integer 1–65535), `allowRemoteBind` (bool), `sessionGuard` (object|null), `window` (bounded int), `tlsContext`/`trustProxy` (§ remote-bind). Each invalid option throws a stable error; `configuration.md` (+ `.ko.md`) gets a dashboard section enumerating every option, type, default, and throw condition. `createDashboardServer` calls `normalizeDashboardConfig` first.
45
+
46
+ **Construction-time bind/guard precedence (fail-closed, exact order):**
47
+
48
+ 1. `!isLoopback(host) && !allowRemoteBind` → **throw** (the loopback guard; see below).
49
+ 2. `!isLoopback(host) && allowRemoteBind && !sessionGuard` → **throw** `"remote bind requires a sessionGuard"`.
50
+ 3. `!isLoopback(host)` (remote, guarded) → **require confirmed HTTPS termination** (a `tlsContext`, or `trustProxy` honoring `X-Forwarded-Proto` only from a configured trusted-proxy address) — otherwise **throw** (a Secure/`__Host-` session cookie is never sent over plaintext http, so a non-TLS remote bind silently breaks login; fail closed). `Strict-Transport-Security` is added on the remote path.
51
+
52
+ **Loopback bind** reuses core's exported `assertSafeProxyBind` (`import { assertSafeProxyBind } from "haechi/proxy"` — already exported, **no core relocation**, no new `haechi/net` export). Its thrown text is proxy-worded and names `--allow-remote-bind`; the dashboard **catches and rethrows its own message** (it exposes an `allowRemoteBind` option, not that CLI flag) so the error points at the right component.
53
+
54
+ **Anti-DNS-rebinding Host-header allowlist (mandatory, distinct from the bind check).** Loopback bind does **not** by itself protect an unauthenticated localhost viewer: any site the operator browses can publish a short-TTL DNS name that re-resolves to `127.0.0.1`, and the victim's browser will then make same-origin requests to the dashboard, letting the attacker's JS read the audit JSON. Therefore **every** request (incl. `/api/*` and `/healthz`) is rejected with `403` unless the **`Host` header** host-portion is in the allowlist `{localhost, 127.0.0.1, [::1], ::1, ::ffff:127.0.0.1, the configured bind host}`. This is a **separate request-header function from the bind-string check** (`assertSafeProxyBind` validates a bind string, not an untrusted header), with its own normalization: parse `Host` into host+port, reject malformed/duplicate `Host` headers, strip a single trailing dot (`localhost.`), handle IPv4-mapped IPv6 and bracketed IPv6. CORS is **absent** — `Access-Control-Allow-Origin` is never set/reflected.
55
+
56
+ **API (all GET/HEAD, read-only):**
57
+
58
+ - `GET /api/events?cursor=&limit=` — newest-first, **bounded-window** page of audit events. **Strict query parsing:** `limit` must be an integer in `[1,200]` (reject `NaN`/negative/non-integer); `cursor` is an opaque server-issued token = the `auditIntegrity.sequence` (monotonic, stable), `400` if malformed — **never used directly as an fs offset**. Events pass through a **recursive, key-by-key field allowlist projection** built against the **real** audit schema (below) — the server **never spreads or passes a nested sub-object (`detections`, `identity`, `summary`, `auditIntegrity`) through blind**, so a future field at any level can't leak (defense in depth over core's `FORBIDDEN_KEYS`). Pages older than the bounded tail window return empty with a `"window exceeded"` marker (not an error); a torn trailing line from a concurrent append is tolerated and skipped (as `readAnchors` already does), never a `500`.
59
+ - `GET /api/chain` — derived from `verifyAuditChain(auditPath, { anchorPath })`'s **real** output: success `{ valid:true, records, headHash, anchored?:{count,lastSequence} }`, failure `{ valid:false, records }`. **`truncationDetected` is derived** by the dashboard as `valid===false && reason.startsWith("tail truncation")`; **the raw `reason` string is NOT surfaced** (it can embed an `eventHash`/sequence — e.g. `"anchor hash mismatch at sequence N"`). `valid===false` is shown prominently (it is the one tamper signal). **Bounded compute:** a single serialized in-process job (no concurrent re-walks), recomputed only when the audit file's `mtime+size` changed (cache key = `mtime+size`); above a hard max file size, return `413`/`{valid:null}` instead of walking. `HEAD /api/chain` returns headers only and never forces a fresh walk.
60
+ - `GET /api/summary` — aggregates from the event window's `summary.byType` / `summary.byAction` / `summary.detectionCount`.
61
+ - `GET /healthz` — liveness only (no audit data, no paths/version/config); **intentionally reachable without a session even off-loopback** (a guarded remote dashboard must still answer liveness probes).
62
+
63
+ **Real audit event schema (the projection source of truth).** The on-disk record (built in `packages/core/index.mjs` `buildAuditEvent`, integrity added by `packages/audit/index.mjs`) is:
64
+
65
+ ```
66
+ { id, timestamp, protocol, operation, identity, profile, mode, enforced, blocked,
67
+ payloadShapeHash,
68
+ detections: [ { type, ruleId, path, kind, confidence, action, enforced } ], // `path` is the former "pathText" — the XSS-bearing, client-key-derived field, NESTED here
69
+ summary: { byType, byAction, detectionCount },
70
+ auditIntegrity: { alg, canonicalization, sequence, previousHash, eventHash } } // proxy-recorded events may also add a top-level `direction`
71
+ ```
72
+
73
+ The projection emits, key-by-key: top-level `id, timestamp, protocol, operation, mode, enforced, blocked, direction?`; per-detection `type, ruleId, path, kind, confidence, action, enforced`; `summary.{byType, byAction, detectionCount}`; `auditIntegrity.{sequence, previousHash, eventHash}`; `identity.{id, type, subjectHash, issuerHash, provider}` (**never** `scopes`/`labels`/a raw subject). `payloadShapeHash` may be included (shape-only hash, non-sensitive).
74
+
75
+ **Web-security spec (acceptance criteria, not options):**
76
+
77
+ - **XSS.** The allowlisted `detections[].path` derives from client-supplied JSON keys (a request key `<img onerror>` reaches the log). The **allowlist bounds field *names* (leak containment); CSP + `textContent` rendering neutralizes malicious *values*** — both are required and independent. The client builds DOM with `createElement` + `textContent` only (never `innerHTML` with interpolation). CSP (verbatim, every response): `default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'none'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; require-trusted-types-for 'script'` — Trusted Types makes any stray `innerHTML` sink throw in-browser, turning the convention into an enforced guarantee. No inline scripts/styles (same-origin asset files), no external CDN, no `eval`.
78
+ - **Security headers (every response):** `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY` (legacy clickjacking fallback), `Cross-Origin-Resource-Policy: same-origin` and `Cross-Origin-Opener-Policy: same-origin` (CORP same-origin blocks a cross-origin page from reading `/api/*` as a resource — a second layer against the rebinding/no-cors exfil, independent of the Host check). `Cache-Control: no-store` on `/api/*` **and** on the HTML shell (it renders live audit data); JS/CSS get a short validated cache (or `no-store` globally — a localhost tool gains nothing from caching).
79
+ - **Method allowlist:** only `GET`/`HEAD`; anything else → `405`. There is **no** `POST`/`DELETE` surface (no reveal, no purge, no policy edit — those stay in the CLI under reveal governance). "Read-only" means **no audit-data mutation and no privileged action**; `/api/chain` does have a bounded compute side effect (acknowledged), which the cache + size cap + (below) rate limit bound.
80
+ - **Generic errors (no info disclosure).** Handler errors return a fixed `{ error: "internal" }` 5xx — **never** a stack, message, OS error code, or an absolute path (`auditPath`/`anchorPath` are sensitive; the anchor path is the out-of-band truncation defense). `verifyAuditChain` `reason` text is logged server-side only.
81
+ - **Rate limiting / DoS.** Reuse the proxy's exported `createRateLimiter` for a per-source cap on `/api/*` (in addition to the chain-verify `mtime+size` cache), so an unauthenticated loopback caller (or a rebinding page) cannot pin a CPU core via `/api/chain`. Event reads tail a **bounded byte/line window** and stream-parse — never load the whole file.
82
+ - **Remote bind requires a session guard *and* TLS** (precedence above): the only unauthenticated mode is **loopback** (and even there, the Host-allowlist + CORP apply).
83
+ - **No plaintext, ever.** Only already-sanitized fields, projected; identity shown as `subjectHash`/`issuerHash`/`id` only.
84
+
85
+ **Packaging:** new satellite `haechi-dashboard`, **zero runtime dependency** (`node:` builtins only), `peerDependencies: { haechi: ">=0.8.0 <1.0.0" }` + `devDependencies: { haechi: "*" }`, its own bin and `publishConfig: { access: "public", provenance: true }`. No core CLI change — the satellite owns its entry point; core never references a satellite.
86
+
87
+ ### 2.2 `haechi-auth-oidc` — interactive OIDC session broker
88
+
89
+ A satellite exposing `createOidcSessionBroker(options)` (with an exported, fail-closed **`normalizeOidcConfig`** mirroring §2.1's discipline — enumerated throws for `issuer`/`clientId`/`clientSecret`/`redirectUri`/`scopes`/`cookie`/`returnToAllowlist`/`sessionTtlSeconds`/`idleTtlSeconds`/`maxAgeSeconds`/`tokenEndpointAuthMethod`, all documented in `configuration.md`). It implements the **authorization-code flow with PKCE** and produces a **server-side session** consumable by the dashboard (it satisfies the dashboard's `sessionGuard` seam, §2.3). It is **not** an `authProvider` (per-request bearer) — that role stays with `haechi-auth-jwt`.
90
+
91
+ **Construction-time checks (fail-closed):** `cryptoProvider.hmac` required (no PII-safe identity without it); `issuer` a valid HTTPS URL; `redirectUri` a valid absolute URL, **https (or loopback http under the same carve-out) and same-origin with the broker**, whose **path equals the mounted `/auth/callback`** (the identical `redirect_uri` is sent on both the authorization request and the token exchange, per RFC 6749); `openid` is always force-included in `scopes` (deduped) and `offline_access` is stripped (refresh handling is out of scope, §3); off-loopback without confirmed external HTTPS → reject (cookie hardening keys off the **externally-visible** scheme, not the local socket — provide `secureCookies: true|'auto'`/`trustProxy` so a TLS-terminating reverse proxy forces `Secure` + `__Host-`; default fail-closed).
92
+
93
+ **Flow handlers (the dashboard mounts these at exact literal paths):**
94
+
95
+ - `GET /auth/login` — generate CSPRNG `state`, `nonce`, PKCE `code_verifier`; `code_challenge = S256(code_verifier)` (**S256 mandatory, never `plain`**); persist the trio + the **pinned resolved `issuer`/`token_endpoint`/`jwks_uri`** in a server-side **pending-auth** record keyed to a short-TTL **pre-auth cookie**; `302` to the discovered `authorization_endpoint` with the exact `redirect_uri`. When `maxAgeSeconds` is configured, send `max_age` (and require `auth_time` at callback).
96
+ - `GET /auth/callback` — **state-first short-circuit** (closes the timing/oracle gap): **atomically `take()`** the pending record by the pre-auth cookie and assert `record.state === query.state` **before any outbound request**; a missing/used/mismatched state or a missing/mismatched pre-auth cookie → deny with **no** IdP round-trip (defeats authorization-code injection / login-CSRF and the replay TOCTOU). Then redeem `code` **only at the pinned `token_endpoint`** with the `code_verifier` (+ client auth, below); **verify the ID token** (shared verifier + ID-token profile, below) including **`nonce` match** and (RFC 9207) any returned `iss` response param equal to the pinned issuer (mix-up defense); **mint a fresh session id** (discard the pre-auth cookie and any prior session — no fixation); set the session cookie; `302` to an **allowlisted relative** return path (default `/`).
97
+ - `POST /auth/logout` — **non-GET, CSRF-protected** (a per-session synchronizer token or a same-origin custom-header fetch which `connect-src 'self'` already implies — do **not** rely on `SameSite` alone). **Fully destroys server-side session state** (replaying the old cookie afterward → `401`), clears the cookie. Optional RP-initiated logout: send `id_token_hint` + a fresh `state`; any `post_logout_redirect_uri` must be a pre-registered/allowlisted absolute URL (reuse the `returnToAllowlist` discipline) or be omitted (no logout open-redirect).
98
+
99
+ **OIDC discovery (SSRF-hardened):**
100
+
101
+ - Fetch `<issuer>/.well-known/openid-configuration` over **HTTPS only**, bounded body (≤ 1 MiB), strict JSON depth; **reject unless `metadata.issuer` string-equals the configured `issuer`** (OIDC Discovery §4.3 / RFC 8414 — issuer-confusion guard) and pin the verifier's expected `iss` to it.
102
+ - **Single-origin only (0.9):** `authorization_endpoint`, `token_endpoint`, `jwks_uri`, `end_session_endpoint` must share the **issuer hostname** — same constraint and rationale as `haechi-auth-jwt` 0.8 (multi-origin/CDN-fronted IdPs remain out of scope). Cross-origin endpoints rejected at discovery/construction.
103
+
104
+ **Every outbound egress runs the same guard (not just JWKS/discovery).** The authorization-code flow adds a **`token_endpoint` POST** the shared JWKS verifier never makes; a token endpoint that DNS-rebinds to `169.254.169.254` between discovery and exchange is the classic metadata-exfil path. So **discovery GET, JWKS GET (via the shared verifier), the token-exchange POST, and any end-session redirect** each run a **`lookup`-then-`isBlockedAddress` re-check immediately before the request** (post-DNS, rebinding guard — refusing `127/8`, `::1`, `10/8`, `172.16/12`, `192.168/16`, `169.254/16` incl. `169.254.169.254`, `fe80::/10`), with `redirect: "error"`, a **bounded response body**, and a fetch timeout. (The auth ecosystem shares **one** copy: `haechi-auth-jwt` exports `isBlockedAddress` and `haechi-auth-oidc` reuses it. The `haechi-crypto-kms` Vault backend deliberately keeps its **own** copy rather than runtime-depend on an auth package — see §2.4 — kept honest by a dev-only parity test.)
105
+
106
+ **Shared JWS verifier + ID-token profile.** 0.9 refactors `haechi-auth-jwt@0.2.0` to **additively export** a standalone verifier primitive (e.g. `createJwtVerifier`/`verifyJwt`) carved out of the existing internal `resolveJwk`/`verifySignature`/claim-validation — **behavior-preserving**: the primitive verifies **signature + `alg`/`kid`/RSA-bits + `iss`/`aud`/`exp`/`nbf` only** (the exact 0.8 surface), and **`nonce` is NOT baked into the primitive** (a bearer JWT has none) — it is verified by auth-oidc *after* the primitive returns validated claims (or via an optional `expectedNonce` that is a no-op when omitted). `createJwtAuthProvider` is reimplemented on the primitive and keeps owning Bearer-header parsing, so **all its 0.8 §6.3 tests pass unchanged**. `haechi-auth-oidc` **peer-depends on `haechi-auth-jwt >=0.2.0 <1.0.0`** and uses the primitive, giving exactly **one audited JWS/JWKS verification path**.
107
+
108
+ The full 0.8 JWT security spec applies to ID-token verification verbatim (server-side `alg` selection, reject `alg:none`, alg-confusion block, `kid` required, RSA ≥ 2048, JWK `use`/`key_ops` intent, `typ`/no-JWE, mandatory `exp`/`nbf`, `clockSkew` ≤ 300 s, SSRF-hardened bounded JWKS, ≤ 1-refetch-per-60 s). **Plus an OIDC ID-token profile distinct from the lenient bearer `aud` check** (the 0.8 `audienceMatches` accepts any array containing the audience — non-compliant for ID tokens): `aud` MUST contain `clientId`; **if `aud` is multi-valued, `azp` MUST be present and `azp === clientId`**; a single-valued `aud` MUST equal `clientId` (OIDC Core §3.1.3.7 — closes cross-client/mix-up). The broker is a **pure-login** consumer: it **discards the access token** (does not store or use it), which both shrinks the server-side secret surface and makes `at_hash`/`c_hash` validation intentionally out of scope (documented).
109
+
110
+ **Client authentication at the token endpoint:** default **`client_secret_basic`** (HTTP Basic, RFC 6749 §2.3.1), `client_secret_post` as explicit opt-in; on discovery, assert the configured method is in `token_endpoint_auth_methods_supported` and **never downgrade a confidential client to `none`**. The `client_secret` goes in the Basic header or POST body only — **never** the URL/query, **never** logged. Public clients (PKCE-only, no secret) are also supported.
111
+
112
+ **Session security (acceptance criteria):**
113
+
114
+ - **Server-side sessions; tokens never reach the browser.** Session id = high-entropy CSPRNG opaque value (≥ 256-bit); the **cookie carries only the id**. ID/access/refresh tokens and the `client_secret` are held server-side only (the access token is discarded; see above) and are **never** sent to the client or written to a log. Default store is in-memory with a documented injectable `sessionStore`/`pendingStore` contract requiring an **atomic `take()`** (consume-and-delete) for single-use semantics under concurrency; TTL + idle eviction.
115
+ - **Two distinct cookies, both hardened.** `__Host-haechi_preauth` (login-time, single-use, **cleared at callback**) and `__Host-haechi_session` (post-callback). Both `HttpOnly`, `SameSite=Lax` (Lax — not Strict — so the IdP→`/callback` top-level GET carries the cookie; Strict would drop it and break login), `Path=/`, and `Secure` + the `__Host-` prefix (which forbids `Domain` and forces `Path=/`) **mandatory whenever the externally-visible scheme is https** (keyed off the forwarded/declared scheme, not the local socket — see construction checks).
116
+ - **PII-safe identity** via core's `buildExternalIdentity` (keyed-HMAC `subjectHash` from the ID-token `sub`, domain `haechi:identity:hash:v1`; `provider: "oidc"`); raw `sub`/email/name **never** logged or stored.
117
+ - **Open-redirect prevention:** post-login `return_to` must be a **relative, same-origin path** validated against `returnToAllowlist`; an absolute/off-origin URL is rejected → falls back to `/`.
118
+ - **Rate-limiting / anti-DoS:** a **hard pending-auth cap** with explicit overflow = **reject new `/auth/login` with a generic `429`/`503` (fail-closed; never silently evict a legitimate in-flight auth)**, plus a per-source rate limit on `/auth/login` and `/auth/callback` (reuse `createRateLimiter`) so an attacker can't exhaust the pending store or pin CSPRNG/PKCE CPU.
119
+ - **Fail-closed everywhere:** any discovery/exchange/verification/state-mismatch error → no session, a generic deny, **no IdP error detail echoed**, same status+body for all callback failures (state-first short-circuit already prevents distinguishing unknown-state from bad-code by outbound side effect).
120
+
121
+ **Broker audit trail (PII-safe, the dashboard's reason to exist).** `createOidcSessionBroker` takes an **injectable `auditSink`** and emits `oidc.login.start`, `oidc.login.success`, `oidc.login.failure{ reasonCode }`, `oidc.logout`, `oidc.session.evict` — each carrying **only** `subjectHash`/`issuerHash`/`sessionIdHash` (keyed-HMAC; never the raw session id), `provider:"oidc"`, a coarse `reasonCode` enum (`state_mismatch|nonce_mismatch|token_invalid|exchange_failed|host_blocked|expired`), and a timestamp — so failed-login / brute-force against `/auth/callback` is **visible** (a per-request validator like auth-jwt could omit this; an interactive login can't). The broker projects through its own allowlist (and we **extend core's `FORBIDDEN_KEYS`** to also cover `access_token`/`id_token`/`refresh_token`/`code`/`code_verifier`/`client_secret`/`state`/`nonce`/`sub`/`email`) so a future field can never leak. A test asserts `JSON.stringify` of every emitted event contains none of those token/secret/raw-claim strings. *(Note: extending `FORBIDDEN_KEYS` is the one touch to `packages/audit` — additive set members, no behavior change to existing events.)*
122
+
123
+ **Packaging:** new satellite `haechi-auth-oidc`, **zero runtime dependency** (`node:` `fetch`/`crypto`/`http` suffice), `peerDependencies: { haechi: ">=0.8.0 <1.0.0", "haechi-auth-jwt": ">=0.2.0 <1.0.0" }` — the **core peer stays `>=0.8.0`** (auth-oidc uses only `buildExternalIdentity`, present since 0.6/0.8; do **not** over-tighten to `>=0.9.0`), while the **auth-jwt peer is `>=0.2.0`** because the verifier export is new. Plus `devDependencies: { haechi: "*" }`, `publishConfig: { access: "public", provenance: true }`, prefixed-tag publish workflow `auth-oidc-v<semver>`.
124
+
125
+ ### 2.3 Dashboard ↔ OIDC integration seam (injection, not a hard dependency)
126
+
127
+ The two satellites are **paired in the release but decoupled in code**, via injection:
128
+
129
+ - `haechi-dashboard` defines a `sessionGuard` contract: `{ authenticate(request) -> session | null, handlers: { "/auth/login", "/auth/callback", "/auth/logout" } }`. The dashboard mounts `handlers` and gates every `/api/*` route behind `authenticate`.
130
+ - `haechi-auth-oidc`'s `createOidcSessionBroker(...)` returns an object satisfying that contract.
131
+ - Wiring is explicit: `createDashboardServer({ ..., sessionGuard: createOidcSessionBroker({ ... }) })`. The dashboard has **no peer dependency on auth-oidc** (the guard is injected, like `cryptoProvider`); either satellite is usable independently. The required pairing is the **fail-closed rule**: remote bind ⇒ a guard must be present (§2.1).
132
+ - **Gate precision:** an unauthenticated `/api/*` request on a guarded dashboard returns **`401` (never a `302`** — a redirected XHR/fetch leaks the login URL or loops; the static shell performs the redirect). **Exactly** the three literal handler paths are exempt from the gate via **exact match (not a `/auth/` prefix)** — any other path (incl. unknown `/auth/*`) is gated or `404`, so a future broker route can't become an unauthenticated bypass. `/healthz` is reachable without a session even off-loopback (liveness only).
133
+
134
+ ### 2.4 `haechi-crypto-kms` Vault / GCP / Azure backends (independent `0.2.0`)
135
+
136
+ A parallel, decoupled track: additive backends shipped as **`haechi-crypto-kms@0.2.0`** (additive minor — new subpath exports, no change to AWS or the in-memory client), **not gated on core 0.9.0**. Each implements the **same `kms` interface** (`keyId`/`wrap(Buffer)->string`/`unwrap(string)->Buffer`/`deriveHmacKey`) the AWS client established in 0.8, with the same **optional-peer + lazy-import + injected-client** model and the same **faithful-mock conformance** bar (cross-key rejection, corrupted-blob rejection, HMAC determinism/domain-separation — no SDK, no network in CI).
137
+
138
+ - **`./gcp`** — Google Cloud KMS, optional peer `@google-cloud/kms` (lazy). `wrap` = `encrypt` of a CSPRNG 32-byte data key; `unwrap` = `decrypt`; `deriveHmacKey(domain)` = HKDF-SHA256 over one decrypted 32-byte root (`hmacRootCiphertext`, cached), domain-separated — identical shape to `aws.mjs`.
139
+ - **`./azure`** — Azure Key Vault, optional peers `@azure/keyvault-keys` + `@azure/identity` (lazy). Native `wrapKey`/`unwrapKey` to envelope the data key; `deriveHmacKey` = HKDF over an unwrapped root.
140
+ - **`./vault`** — HashiCorp Vault Transit, **zero optional-peer** (the Transit engine is a plain HTTP API reachable with `node:` `fetch` — the dependency-lightest backend). Precise wire shapes (load-bearing): `wrap` = `POST {addr}/v1/transit/encrypt/{key}` with `plaintext = base64(dataKey)`, return `data.ciphertext` (`vault:v1:…`); `unwrap` = `POST .../decrypt/{key}` then **`Buffer.from(data.plaintext, "base64")`** (the base64 decode back to the 32-byte Buffer is mandatory or the HKDF root is garbage); require a **non-derived** transit key (or a fixed `context`) so determinism holds; `hmacRootCiphertext` is a transit-encrypted 32-byte root decrypted once and cached, identical to `aws.mjs` `hmacRoot()`. The Vault `fetch` egress runs the **same `lookup`→`isBlockedAddress` guard + `redirect:"error"` + bounded body + timeout** as the auth egress (an operator-supplied `VAULT_ADDR` can rebind to metadata in cloud). **The guard is a satellite-local `isBlockedAddress`** — intentionally *not* a runtime dependency on `haechi-auth-jwt`: a key-custody package must not pull in the auth ecosystem just for an IP predicate. The IP-range logic is RFC-stable and identical to auth-jwt's; a **dev-only cross-package parity test** (auth-jwt as a `devDependency`) asserts the two copies agree on the range table so they can't drift, while the published `haechi-crypto-kms` stays **zero runtime dependency** with no auth coupling. (This is a deliberate revision of the earlier "reuse, not a third copy" intent — runtime decoupling of crypto from auth won over a single shared copy.)
141
+
142
+ All backends **map provider errors to a generic fail-closed error** and **never write KMS/provider error detail** (which can echo key ARNs/paths) to audit. Each lands behind its own subpath export + `files` entry, with `peerDependenciesMeta.optional` for the SDK-backed ones; the **`haechi` tarball stays zero-dep** (the 0.8 packaging gate is unaffected). **Reconcile the hard-coded provider `version` field** (`satellites/crypto-kms/index.mjs` returns `version: "0.1.0"`, already stale vs package `0.1.1`) — remove/derive it so `0.2.0` doesn't misreport. The `0.2.0` release reuses the `crypto-kms-v<semver>` tag + Trusted Publisher bootstrapped in 0.8.
143
+
144
+ ## 3. Explicit non-scope (deferred to 0.9.x / 1.0)
145
+
146
+ - **Dashboard write actions** (reveal, purge, policy edits) — read-only only; mutation stays in the CLI under reveal governance. No `POST`/`DELETE` surface exists.
147
+ - **Dashboard token-vault / policy visualization** — audit-only in 0.9.
148
+ - **Framework SPA / build step** — vanilla zero-dep only.
149
+ - **Multi-origin / CDN-fronted IdP** (issuer host ≠ JWKS/endpoint host) — single-origin only, same as `haechi-auth-jwt` 0.8.
150
+ - **Refresh-token rotation / silent renewal / long-lived sessions** — 0.9 sessions are absolute-TTL + idle-timeout only; `offline_access` is stripped; the access token is discarded.
151
+ - **`at_hash`/`c_hash` validation** — out of scope precisely because the broker never uses the access token.
152
+ - **Non-OIDC interactive auth** (SAML, LDAP).
153
+ - **Dynamic loading of satellites** — banned until the 1.0 plugin sandbox; the dashboard and broker are wired by **explicit injection**, never a dynamic `import()` of a configured package name.
154
+
155
+ ## 4. Backward compatibility
156
+
157
+ Core behavior is **unchanged** — zero-dep posture intact, existing config/APIs untouched. The two touches to existing packages are both **additive, behavior-preserving**: (a) `haechi-auth-jwt@0.2.0` exports a verifier primitive and reimplements `createJwtAuthProvider` on it (all 0.8 tests stay green); (b) `packages/audit` adds members to `FORBIDDEN_KEYS` (broker token/claim keys) — no change to existing event shapes. `assertSafeProxyBind` is **reused from `haechi/proxy` as already exported** (no relocation, no new core export). All 0.9 deliverables are new, additive, opt-in satellites.
158
+
159
+ ## 5. 1.0 relationship
160
+
161
+ 0.9 does not itself close a 1.0 blocker but advances two 1.0 stories: **operational observability** (the dashboard makes the [[audit-integrity]] hash-chain status + decision stream inspectable, supporting the real-environment-validation exit criterion) and **interactive auth** (the broker completes the human-login half `haechi-auth-jwt` left open). The remaining 1.0 gates are unchanged: API-stability freeze and the plugin sandbox + dynamic-loading story.
162
+
163
+ ## 6. Threat-model & risk-register deltas (concrete, not "TBD")
164
+
165
+ The release cut updates `threat-model.md` (+ `.ko`) §3 Threats-and-Controls with these rows, and adds risk-register IDs (the register's target-version header bumps `0.7.0 → 0.9.0` with a new gate row):
166
+
167
+ | New threat / surface | Control | Residual |
168
+ |---|---|---|
169
+ | Dashboard audit-viewer **XSS** via attacker-controlled `detections[].path` | CSP (`require-trusted-types-for`) + `textContent`-only rendering | none material |
170
+ | **Audit field leak** via the viewer (future field) | recursive key-by-key allowlist projection (+ `FORBIDDEN_KEYS`) | new nested field defaults to dropped |
171
+ | **DNS-rebinding** read of audit JSON from a localhost-bound viewer | Host-header allowlist (per-request) + CORP/COOP same-origin | none material |
172
+ | Unauthenticated audit read on **remote** bind | fail-closed: remote ⇒ `sessionGuard` **and** TLS required | operator must terminate TLS |
173
+ | OIDC **login CSRF / authorization-code injection / open-redirect / session fixation** | state↔pre-auth-cookie binding, atomic `take()`, PKCE S256, fresh session id at callback, `returnToAllowlist`, CSRF token on logout | none material for single-IdP |
174
+ | OIDC **mix-up** (wrong IdP / wrong RP) | issuer/endpoint pinned to the pending record, RFC 9207 `iss` check, ID-token `aud`/`azp` profile, `metadata.issuer` == config | multi-origin IdP out of scope |
175
+ | Broker **SSRF to cloud metadata** via the token-endpoint POST (and Vault `fetch`) | per-egress post-DNS `isBlockedAddress` re-check + bounded body + timeout + `redirect:"error"` | operator-trusted endpoints only |
176
+ | **Token/secret leak** into audit/logs | broker allowlist projection + extended `FORBIDDEN_KEYS`; access token discarded | none material |
177
+ | KMS backend egress (Vault HTTP, GCP/Azure SDK) | optional-peer + injected-client conformance, generic fail-closed errors, no provider detail in audit | live-backend validation is out-of-CI |
178
+
179
+ Proposed risk IDs: **P1-SEC-009** (broker session/login security), **P1-OPS-005** (dashboard audit exposure / rebinding / remote bind), **P2-CRYPTO-00x** (KMS backend egress). New §4 exclusions: multi-origin IdP, refresh rotation, dashboard write actions, `at_hash` validation.
180
+
181
+ ## 7. Test criteria (mapped to the PR breakdown)
182
+
183
+ ### 7.1 PR1 — `haechi-auth-jwt@0.2.0` verifier extraction (additive, behavior-preserving)
184
+
185
+ - **Bump `satellites/auth-jwt/package.json` `0.1.1 → 0.2.0`** (the publish workflow's tag==package-version gate requires it).
186
+ - The new `createJwtVerifier`/`verifyJwt` primitive passes the full 0.8 §6.3 security-gate suite (every deny case); **`nonce` is not part of the primitive** (a no-op `expectedNonce` when omitted).
187
+ - `createJwtAuthProvider` reimplemented on the primitive passes its existing 0.8 tests **unchanged** (behavior-preserving regression guard); it still owns Bearer-header parsing.
188
+ - Satellite tarball stays `dependencies: {}`; core tarball stays zero-dep.
189
+
190
+ ### 7.2 PR2 — `haechi-dashboard` (zero-dep read-only viewer)
191
+
192
+ - Binds loopback by default; non-loopback without `allowRemoteBind` → refused (rethrown dashboard-worded message); `allowRemoteBind:true` without `sessionGuard` → throws; remote without confirmed TLS/trusted-proxy → throws. `normalizeDashboardConfig` rejects each invalid option with a stable error.
193
+ - **Anti-rebinding:** `Host: evil.example` to a loopback dashboard → `403`; the Host matrix (`localhost.`, `127.0.0.1:PORT`, `::ffff:127.0.0.1`, an unexpected FQDN, duplicate `Host`) behaves correctly; no `Access-Control-Allow-Origin` is ever emitted.
194
+ - `GET /api/events`: capped `limit` (reject `-1`/`abc`/`1e9`), opaque `cursor` (malformed → `400`), **recursive allowlist** drops a synthetic extra field injected at **each** level (top, `detections[]`, `identity`, `summary`, `auditIntegrity`); a window-exceeded page returns the marker not an error; a torn trailing line doesn't `500`.
195
+ - `GET /api/chain`: shape matches the **real** `verifyAuditChain` output; a truncated-with-anchor fixture surfaces `valid:false` + a derived `truncationDetected` **without** leaking the raw `reason`/`eventHash`; concurrent polls trigger **one** walk (mtime+size cache); an oversized fixture → `413`; `HEAD` forces no walk.
196
+ - **XSS:** an event whose `detections[].path` contains `<script>`/`<img onerror>` renders inert (served JS uses `textContent`); the exact CSP header string (incl. `object-src 'none'`, `require-trusted-types-for 'script'`) + `nosniff` + `XFO:DENY` + `CORP/COOP same-origin` + `no-store` are asserted.
197
+ - **Method/asset/errors:** `POST`/`DELETE` → `405`; `/../../etc/passwd` cannot escape the fixed asset map (`404`, no fs read); a forced fs error yields `{error:"internal"}` with **no** path substring/stack; `/healthz` leaks nothing.
198
+ - **DoS:** a multi-MB audit fixture is served via a bounded tail window; `/api/*` is rate-limited.
199
+ - Tarball `dependencies: {}`; publishes with provenance.
200
+
201
+ ### 7.3 PR3 — `haechi-auth-oidc` (interactive broker, security gates)
202
+
203
+ - **Happy path** (stubbed discovery + token endpoint + JWKS, RS256 ID token): `/auth/login` → `302` with `state`+`nonce`+`code_challenge` (S256) + the registered `redirect_uri`; `/auth/callback` with matching state + valid code exchanges, verifies, mints a **fresh** session id unrelated to any pre-login cookie; cookies are `__Host-`-named, `HttpOnly`, `SameSite=Lax`, `Secure` under a non-loopback config; the pre-auth cookie is cleared.
204
+ - **Each denied** (no session, generic identical response, nothing echoed, no outbound request for a state failure): mismatched/replayed/expired `state` (atomic `take()` so a concurrent replay finds no record); missing/mismatched pre-auth cookie (login-CSRF/code-injection); `nonce` mismatch; `alg:none`/alg-confusion; expired/`nbf`/wrong-`aud`/wrong-`iss` ID token; **multi-`aud` without `azp`**, **`azp !== clientId`**; `metadata.issuer` ≠ config; RFC 9207 `iss` ≠ pinned; a code-exchange failure; a discovery doc with a **cross-origin** `token_endpoint`/`jwks_uri`; a discovery/JWKS/**token_endpoint** host resolving to a private/metadata range **at request time** (post-DNS); an oversized token-endpoint response.
205
+ - **No token leakage:** post-login, the browser-visible cookie is the opaque id only; `JSON.stringify` of every client-bound response **and** the audit log contain **no** ID/access/refresh token, `client_secret`, `code`, `state`, `nonce`, or raw `sub`. The access token is **discarded** (asserted not stored).
206
+ - **Sessions/logout:** after `POST /auth/logout`, replaying the old cookie → `401` and the server-side record is gone; logout requires the CSRF token (a forged cross-site POST is rejected); `post_logout_redirect_uri` off-allowlist is refused.
207
+ - **Open-redirect:** `return_to=https://evil.example` (or any off-origin/absolute) → falls back to `/`; an allowlisted relative path is honored.
208
+ - **Rate/DoS:** N rapid `/auth/login` hit the pending cap and return a generic `429`/`503` without exhausting memory; `/auth/login` + `/auth/callback` are rate-limited.
209
+ - **Audit:** `oidc.login.{start,success,failure}` / `oidc.logout` / `oidc.session.evict` are emitted with only `*Hash`/`reasonCode`/`provider`/timestamp; the extended `FORBIDDEN_KEYS` test passes.
210
+ - **Construction fail-closed:** missing `cryptoProvider.hmac`; non-https/cross-origin `issuer`/`redirectUri`; `redirectUri` path ≠ `/auth/callback`; off-loopback without TLS/Secure; `normalizeOidcConfig` rejects each bad option.
211
+ - **Seam:** the broker satisfies the dashboard `sessionGuard`; mounted, an unauthenticated `/api/events` on a remote-bound dashboard → **`401`** (not `302`); `/auth/anything-else` is not an unauthenticated bypass; `/healthz` is `200` unauthenticated off-loopback while `/api/events` is `401`.
212
+
213
+ ### 7.4 PR4 — `haechi-crypto-kms@0.2.0` (GCP / Azure / Vault backends)
214
+
215
+ - Each of GCP/Azure/Vault passes `assertCryptoProviderConformance` via a **faithful injected mock** (no SDK, no network), incl. cross-key + corrupted-blob **rejection** and HMAC determinism/domain-separation; end-to-end through `createRuntime` (encrypt + tokenization round-trip).
216
+ - The **Vault** backend uses **`node:` `fetch` only** (no optional peer), exercises the **base64 round-trip** (encrypt `plaintext=base64(dataKey)` → decrypt → `Buffer.from(...,"base64")`), a non-derived key, and the SSRF guard on `VAULT_ADDR`; GCP/Azure declare SDKs under `peerDependenciesMeta.optional`, lazily imported only when no client is injected.
217
+ - Provider errors map to a generic fail-closed error; no provider/key-ARN detail reaches audit.
218
+ - The hard-coded provider `version` field is reconciled (no longer `"0.1.0"`).
219
+ - Published `haechi-crypto-kms@0.2.0` tarball `dependencies: {}`; core tarball stays zero-dep; publishes on the existing `crypto-kms-v<semver>` tag with provenance.
220
+
221
+ ### 7.5 All satellites
222
+
223
+ - Each new/updated satellite publishes with provenance + sigstore attestation, verified post-release like 0.7/0.8.
224
+
225
+ ## 8. Suggested PR breakdown (stacked)
226
+
227
+ 1. **`haechi-auth-jwt@0.2.0` verifier extraction** — additive `createJwtVerifier`/`verifyJwt` (nonce kept outside), reimplement `createJwtAuthProvider`, **bump to 0.2.0**, all 0.8 tests green. → §7.1
228
+ 2. **`haechi-dashboard`** — zero-dep `node:http` viewer: `normalizeDashboardConfig` + bind/guard/TLS precedence, anti-rebinding Host allowlist, read-only event/chain/summary API with strict query parsing + bounded reads + recursive allowlist + mtime-cached chain, static page with strict CSP/Trusted Types + `textContent`, security headers, generic errors, rate limit, the `sessionGuard` seam, publish workflow `dashboard-publish.yml` (guard `startsWith(tag,'dashboard-v')`, regex `^dashboard-v[0-9]+\.[0-9]+\.[0-9]+$`). Regenerate the lockfile for the new dir. → §7.2
229
+ 3. **`haechi-auth-oidc`** — interactive authorization-code + PKCE broker: `normalizeOidcConfig`, SSRF-hardened discovery + per-egress guard, ID-token profile via the §2.2 shared verifier (nonce outside), atomic `take()` pending store, hardened two-cookie sessions + fresh-id rotation, open-redirect/CSRF/logout defenses, broker audit events + extended `FORBIDDEN_KEYS`, the `sessionGuard` implementation, publish workflow `auth-oidc-publish.yml`. Regenerate the lockfile for the new dir. → §7.3
230
+ 4. **`haechi-crypto-kms@0.2.0`** — GCP/Azure (optional-peer) + Vault (zero-dep, satellite-local SSRF guard + dev-only parity test vs auth-jwt) backends, faithful-mock conformance, version-field reconcile; bump + publish on the existing tag. → §7.4
231
+ 5. **0.9.0 release cut** — docs EN/KO (dashboard/broker config in `configuration.md`, the §6 threat-model + risk-register deltas with concrete IDs + target-version bump, this scope doc), roadmap row, api-stability, wiki ingest (new `haechi-dashboard`/`haechi-auth-oidc` pages + `packaging-and-distribution`/`identity-and-auth` updates), and the per-package Trusted Publisher runbook rows (both new workflow filenames + tag globs + the configure-TP-**before**-first-tag bootstrap that claims each unscoped name).