haechi 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +20 -6
- package/README.md +20 -6
- package/docs/current/api-stability.ko.md +95 -45
- package/docs/current/api-stability.md +95 -45
- package/docs/current/configuration.ko.md +106 -2
- package/docs/current/configuration.md +106 -2
- package/docs/current/release-0.9-implementation-scope.ko.md +231 -0
- package/docs/current/release-0.9-implementation-scope.md +231 -0
- package/docs/current/release-1.0-implementation-scope.ko.md +170 -0
- package/docs/current/release-1.0-implementation-scope.md +164 -0
- package/docs/current/release-process.ko.md +7 -1
- package/docs/current/release-process.md +7 -1
- package/docs/current/risk-register-release-gate.ko.md +30 -7
- package/docs/current/risk-register-release-gate.md +28 -5
- package/docs/current/threat-model.ko.md +29 -1
- package/docs/current/threat-model.md +29 -1
- package/haechi.config.example.json +2 -1
- package/package.json +4 -3
- package/packages/audit/index.mjs +24 -1
- package/packages/auth/index.mjs +173 -0
- package/packages/cli/runtime.mjs +189 -6
- package/packages/core/index.mjs +23 -4
- package/packages/filter/index.mjs +58 -3
- package/packages/plugin/index.mjs +83 -17
- package/packages/plugin/sandbox.mjs +608 -0
- package/packages/plugin/signing.mjs +393 -0
- package/packages/proxy/index.mjs +3 -0
|
@@ -53,11 +53,12 @@ upstream JSON 응답을 검사한다(기본적으로 꺼져 있음 — 모델로
|
|
|
53
53
|
| 키 | 타입 / 값 | 기본값 | 설명 |
|
|
54
54
|
|---|---|---|---|
|
|
55
55
|
| `responseProtection.enabled` | boolean | `false` | 마스터 스위치. `detokenizeResponses`가 작동하려면 반드시 활성화되어 있어야 한다. |
|
|
56
|
-
| `responseProtection.mode` | `dry-run` \| `report-only` \| `enforce` | `enforce` | 응답 방향의 집행 모드. |
|
|
56
|
+
| `responseProtection.mode` | `dry-run` \| `report-only` \| `enforce` | `enforce` | 응답 방향의 집행 모드. **실제 LLM upstream엔 `report-only` 권장:** envelope 메타데이터(id, unix 타임스탬프 `created`, 긴 숫자 필드)가 PII/secret 모양으로 보일 수 있어 `enforce`면 정상 완성 응답을 502로 막는다. `report-only`도 탐지·감사·`detokenizeResponses`는 그대로 동작. (Haechi는 응답에서 자체 `[TOKEN:…]`/`[HAECHI_ENC:…]` 마커를 제외하고, phone 규칙도 맨 타임스탬프를 무시하며, 응답의 bare JSON number leaf는 검사하지 않으므로 실제 vLLM/Ollama 응답은 clean. 응답 *텍스트*까지 검사하려면 `enforce`가 더 엄격.) |
|
|
57
57
|
| `responseProtection.failureMode` | `fail-closed` \| `allow` | `fail-closed` | *검사 불가능한* 응답(비JSON, 잘못된 JSON, 압축)에 대한 처리 방식. `fail-closed`는 502를 반환하고, `allow`는 통과시킨다(audit 기록됨). |
|
|
58
58
|
| `responseProtection.allowNonJson` | boolean | `false` | 비JSON 응답을 검사 없이 통과시킨다. |
|
|
59
59
|
| `responseProtection.allowCompressed` | boolean | `false` | 압축 응답을 검사 없이 통과시킨다. |
|
|
60
60
|
| `responseProtection.maxBytes` | 양의 정수 | `1048576` | 응답 크기의 상한. `failureMode: allow` 상태에서도 적용되며, 크기를 초과한 응답은 항상 거부된다. |
|
|
61
|
+
| `responseProtection.scanNumbers` | boolean | `false` | 응답의 **bare JSON number leaf**에 탐지를 돌릴지 여부. 기본 off — 응답 숫자는 추론서버 메타데이터(`*_duration`, count, timestamp)라 검사하면 `card`/`kr_rrn` 오탐만 발생. 모델이 숫자 필드로 유출할 수 있다고 보는 엄격 위협모델에서만 `true`; `mode: report-only`와 함께 써서 차단 없이 감사만. 요청 방향은 항상 숫자 검사. |
|
|
61
62
|
|
|
62
63
|
## `streaming`
|
|
63
64
|
|
|
@@ -230,4 +231,107 @@ haechi proxy --config haechi.config.json --host 0.0.0.0 --allow-remote-bind
|
|
|
230
231
|
|
|
231
232
|
## 검증 요약
|
|
232
233
|
|
|
233
|
-
다음은 로드 시 오류(fail-closed)를 발생시킨다: 알 수 없는 `keys.provider`; 빈 `proxy.host`; 범위를 벗어난 `proxy.port`; `jsonl`이 아닌 `audit.sink`; `local`이 아닌 `tokenVault.provider`; 잘못된 `revealPolicy`; 양수가 아닌 `retentionDays`; boolean이 아닌 `deterministic`/`detokenizeResponses`; 비어 있거나 문자열이 아닌 `deterministicTypes`; 비어 있거나 문자열이 아닌 `mcp.allowedMethods`; boolean이 아닌 `mcp.*` 플래그; 알 수 없는 `privacy.profile`; 잘못된 `responseProtection.failureMode`; 양수가 아닌 `responseProtection.maxBytes`; 잘못된 `streaming.requestMode`; 잘못된 `streaming.responseMode`; 양수가 아닌 `streaming.maxMatchBytes`; 잘못된 `auth.provider`; 빈 `auth.store`; 문자열이 아닌 `auth.allowedLabelKeys`; 객체가 아닌 `policy.profiles`; 유효한 `default` 없는 `policy.profileBinding`; 문자열이 아닌 `policy.modelAllowlist`; 양수가 아닌 `policy.rate.requestsPerMinute`; 양수가 아닌 `limits.*`; 알 수 없는 `target.type`/`adapter`; 안전하지 않은 커스텀 정규식; `allowUnsafeOverrides` 없이 action을 약화하려는 시도.
|
|
234
|
+
다음은 로드 시 오류(fail-closed)를 발생시킨다: 알 수 없는 `keys.provider`; 빈 `proxy.host`; 범위를 벗어난 `proxy.port`; `jsonl`이 아닌 `audit.sink`; `local`이 아닌 `tokenVault.provider`; 잘못된 `revealPolicy`; 양수가 아닌 `retentionDays`; boolean이 아닌 `deterministic`/`detokenizeResponses`; 비어 있거나 문자열이 아닌 `deterministicTypes`; 비어 있거나 문자열이 아닌 `mcp.allowedMethods`; boolean이 아닌 `mcp.*` 플래그; 알 수 없는 `privacy.profile`; 잘못된 `responseProtection.failureMode`; 양수가 아닌 `responseProtection.maxBytes`; boolean이 아닌 `responseProtection.scanNumbers`; 잘못된 `streaming.requestMode`; 잘못된 `streaming.responseMode`; 양수가 아닌 `streaming.maxMatchBytes`; 잘못된 `auth.provider`; 빈 `auth.store`; 문자열이 아닌 `auth.allowedLabelKeys`; 객체가 아닌 `policy.profiles`; 유효한 `default` 없는 `policy.profileBinding`; 문자열이 아닌 `policy.modelAllowlist`; 양수가 아닌 `policy.rate.requestsPerMinute`; 양수가 아닌 `limits.*`; 알 수 없는 `target.type`/`adapter`; 안전하지 않은 커스텀 정규식; `allowUnsafeOverrides` 없이 action을 약화하려는 시도.
|
|
235
|
+
|
|
236
|
+
# Satellite 운영자 설정 (0.9)
|
|
237
|
+
|
|
238
|
+
아래 두 섹션은 0.9에서 도입된 **독립적으로 배포되는 satellite 패키지** — `haechi-dashboard`와 `haechi-auth-oidc`의 설정을 다룬다. **이들은 코어 `haechi.config.json` / `normalizeConfig` 스키마의 키가 아니다.** 각 satellite는 팩토리 함수(`createDashboardServer(options)` / `createOidcSessionBroker(options)`)에 **옵션 객체**를 전달해 설정하며, 각자의 `normalizeDashboardConfig` / `normalizeOidcConfig`가 검증한다. 검증은 코어와 동일한 **strict, fail-closed** 원칙을 따른다: 알 수 없는 옵션 키는 오류를 발생시키고, 아래의 모든 필드는 fail-closed throw 조건을 명시한다. 소스: `satellites/dashboard/index.mjs`, `satellites/auth-oidc/index.mjs`. 위협 모델 커버리지: **P1-OPS-005**(dashboard audit 노출 / DNS-rebinding / remote bind), **P1-SEC-009**(broker session/login 보안), `docs/current/release-0.9-implementation-scope.md` §6 참고.
|
|
239
|
+
|
|
240
|
+
## `haechi-dashboard` (satellite)
|
|
241
|
+
|
|
242
|
+
audit JSONL과 그 hash-chain 상태를 제공하는 zero-dependency **read-only** audit 뷰어(`node:http`)다. 런타임이 아닌 **경로**를 받는다. `createDashboardServer(options)`로 설정하며, `normalizeDashboardConfig(options)`가 검증 후 실제 적용 설정을 반환한다. 소스: `satellites/dashboard/index.mjs`.
|
|
243
|
+
|
|
244
|
+
| 옵션 | 타입 / 값 | 기본값 | 설명 / fail-closed throw |
|
|
245
|
+
|---|---|---|---|
|
|
246
|
+
| `auditPath` | 비어 있지 않은 문자열 | **필수** | audit JSONL 경로. 누락되거나 비어 있지 않은 문자열이 아니면 throw. |
|
|
247
|
+
| `anchorPath` | string \| `null` | `null` | tail 절단 탐지를 위해 `verifyAuditChain`에 전달되는 anchor 스트림 경로. 존재하지만 비어 있지 않은 문자열이 아니면 throw. |
|
|
248
|
+
| `host` | 비어 있지 않은 문자열 | `127.0.0.1` | 바인드 주소. loopback이 아니면 `allowRemoteBind`와 아래 remote-bind 전제 조건을 모두 충족해야 한다. 존재하지만 비어 있거나 문자열이 아니면 throw. |
|
|
249
|
+
| `port` | 정수 0–65535 | `1018` | 리슨 포트; `0` = OS 할당 임시 포트(의도된 affordance). `[0,65535]` 정수가 아니면 throw. |
|
|
250
|
+
| `allowRemoteBind` | boolean | `false` | loopback이 아닌 `host`를 허용한다. boolean이 아니면 throw. 설정만으로는 충분하지 않다 — remote-bind 전제 조건 참고. |
|
|
251
|
+
| `sessionGuard` | object \| `null` | `null` | `authenticate(req) -> session\|null`과 선택적 `handlers` 맵을 구현하는 guard. object가 아니거나 `authenticate`가 함수가 아니면 throw. `handlers` 키는 고정된 broker 경로 `/auth/login`, `/auth/callback`, `/auth/logout`만 허용되며, 다른 키(특히 `/api/*`, `/healthz`, `/`)는 throw — guard가 audit 경로를 게이트에서 면제시키는 auth-bypass를 차단한다. `haechi-auth-oidc` broker를 주입하면 충족된다(아래 참고). |
|
|
252
|
+
| `window` | 정수 4096–67108864 | `1048576` | `/api/events`와 `/api/summary`의 tail-read 윈도우(최대 바이트). `[4096, 67108864]`(4 KiB–64 MiB) 정수가 아니면 throw. |
|
|
253
|
+
| `tlsContext` | object \| `null` | `null` | dashboard가 직접 HTTPS를 종단하기 위한 TLS 자료. object가 아니거나, non-null인데 **사용 가능한 자료**가 없으면 throw — `(key && cert)` 또는 `pfx`를 반드시 포함해야 한다(빈 `{}`는 거부되어 loopback이 아닌 plaintext 리스너를 green-light하지 못하게 한다). |
|
|
254
|
+
| `trustProxy` | string \| `null` | `null` | 신뢰하는 fronting-proxy 주소/CIDR를 명시한다. 문자열이 아니거나, 비어 있거나, falsy 모양 문자열(`"false"`/`"0"`)이면 throw. **`trustProxy`만으로는 loopback이 아닌 바인드를 절대 인가하지 못한다** — 실제 `tlsContext`만 가능하다. |
|
|
255
|
+
|
|
256
|
+
### 라우트
|
|
257
|
+
|
|
258
|
+
모든 라우트는 **GET/HEAD 전용**(그 외 method → `405`)이며, asset 맵은 in-code로 고정되어 있다(파일시스템 traversal 없음):
|
|
259
|
+
|
|
260
|
+
- `/api/events` — audit JSONL의 bounded tail read, 최신순. `limit`은 `[1,200]` 정수(기본 50); `cursor`는 opaque `auditIntegrity.sequence`(파일시스템 오프셋이 아님). 각 이벤트는 **recursive key-by-key allowlist projection**으로 재구성된다(blind spread 없음; identity는 scope/label/raw subject 없이 `subjectHash`/`issuerHash`만 보유). 요청된 페이지가 유지된 윈도우보다 오래되면 `windowExceeded`를 반환한다.
|
|
261
|
+
- `/api/chain` — `verifyAuditChain`을 감싸며, 파생된 `truncationDetected` boolean을 노출한다(raw 실패 reason은 **절대** 반환하지 않음). mtime+size 캐시(동시 재-walk 없음); 32 MiB 상한 초과 시 `{valid:null}`과 함께 `413`; `HEAD`는 walk를 강제하지 않고 헤더만 반환한다.
|
|
262
|
+
- `/api/summary` — tail 윈도우에 대한 집계 탐지 카운트(`byType`/`byAction`/`detectionCount`).
|
|
263
|
+
- `/healthz` — liveness 전용(`{status:"ok"}`); loopback 밖에서도 session 불필요.
|
|
264
|
+
|
|
265
|
+
### 보안 기본값
|
|
266
|
+
|
|
267
|
+
- **기본 loopback 바인드.** `host` 기본값은 `127.0.0.1`이며, loopback이 아닌 host 바인드는 코어의 `assertSafeProxyBind`(재-표현)를 재사용하고 `allowRemoteBind`를 요구한다.
|
|
268
|
+
- **Remote bind는 fail-closed.** loopback이 아닌 바인드는 `allowRemoteBind: true`, `sessionGuard`, **그리고** 유효한 `tlsContext`(dashboard가 직접 TLS 종단)를 **모두** 요구한다. `trustProxy`는 이를 충족하지 못한다 — loopback이 아닌 plaintext 리스너는 audit 데이터를 평문으로 제공하면서 HSTS를 방출하므로 거부된다. HSTS는 서버가 실제로 HTTPS를 제공할 때**만** 방출된다.
|
|
269
|
+
- **anti-DNS-rebinding Host allowlist**가 모든 요청(`/api/*`, `/healthz`, 모든 method 포함)의 무조건적 첫 게이트다; 잘못되거나 중복된 `Host` 헤더 → method 검사 이전에 `403`.
|
|
270
|
+
- **strict CSP + Trusted Types**(`require-trusted-types-for 'script'`, `textContent` 렌더링) 및 `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy`/`-Opener-Policy: same-origin`, `X-Content-Type-Options: nosniff`, `Cache-Control: no-store`; CORS 헤더는 의도적으로 절대 설정하지 않는다.
|
|
271
|
+
- **sessionGuard seam.** guard가 존재하면 모든 `/api/*` 라우트는 `authenticate()` 뒤에 게이트된다; 미인증 요청은 `401`(`302` 리다이렉트가 아님). auth-면제 집합은 고정된 broker-path allowlist와 guard가 선언한 handlers의 **교집합**(exact match)이다 — guard는 audit-data 라우트를 절대 면제시킬 수 없다.
|
|
272
|
+
- **generic 오류.** 5xx는 `{error:"internal"}`만 반환한다 — stack, OS code, 파일시스템 경로는 절대 없음. satellite-local fixed-window rate limiter(소스별 120 req/60s)가 `/api/*` 앞단을 막는다.
|
|
273
|
+
|
|
274
|
+
bin `haechi-dashboard`(workspace)가 서버를 구동하며, publish 워크플로는 `.github/workflows/dashboard-publish.yml`(태그 `dashboard-v<semver>`)이다. `peerDependencies: { haechi: ">=0.8.0 <1.0.0" }`.
|
|
275
|
+
|
|
276
|
+
## `haechi-auth-oidc` (satellite)
|
|
277
|
+
|
|
278
|
+
zero-dependency **interactive OIDC session broker**(authorization-code + PKCE) — dashboard의 사람-로그인 메커니즘이다. opaque server-side session을 생성하고, **주입을 통해 dashboard `sessionGuard` 계약을 충족한다**(`{ authenticate(req), handlers: { "/auth/login", "/auth/callback", "/auth/logout" } }`). per-request bearer validator가 **아니다**(그 역할은 `haechi-auth-jwt`에 남는다). `createOidcSessionBroker(options)`로 설정하며 `normalizeOidcConfig(options)`가 검증한다. 소스: `satellites/auth-oidc/index.mjs`. `peerDependencies: { haechi: ">=0.8.0 <1.0.0", haechi-auth-jwt: ">=0.2.0 <1.0.0" }`.
|
|
279
|
+
|
|
280
|
+
| 옵션 | 타입 / 값 | 기본값 | 설명 / fail-closed throw |
|
|
281
|
+
|---|---|---|---|
|
|
282
|
+
| `cryptoProvider` | `hmac()`를 가진 object | **필수** | PII-safe identity 해시와 `sessionIdHash`를 위한 keyed-HMAC를 제공한다. `hmac`이 함수가 아니면 throw. |
|
|
283
|
+
| `issuer` | HTTPS URL 문자열 | **필수** | OIDC issuer; 정확한 string-equal discovery와 single-origin endpoint 검사를 위해 pin된다. 누락되거나 `https`가 아니면 throw. |
|
|
284
|
+
| `clientId` | 비어 있지 않은 문자열 | **필수** | OAuth client id(ID-token의 기대 `aud`이기도 함). 누락/비어 있으면 throw. |
|
|
285
|
+
| `clientSecret` | string \| 생략 | 생략 | 존재 ⇒ confidential client; 생략 ⇒ public(PKCE 전용) client. 존재하지만 비어 있으면 throw. |
|
|
286
|
+
| `redirectUri` | 절대 URL 문자열 | **필수** | `https`(또는 carve-out 하의 **loopback** `http`)여야 하고, broker와 **same-origin**이며, path가 정확히 `/auth/callback`이어야 한다. 그 외에는 throw. |
|
|
287
|
+
| `scopes` | 문자열 배열 | `["openid"]` | `openid`는 강제 포함(dedup)되고, `offline_access`는 제거된다(refresh rotation은 0.9 범위 밖). 비어 있지 않은 문자열 배열이 아니면 throw. |
|
|
288
|
+
| `returnToAllowlist` | 문자열 배열 | `["/"]` | **relative same-origin** 복귀 경로의 allowlist(단일 `/`로 시작, scheme/host/`//`/백슬래시 없음). 배열이 아니거나 비적합 항목이 있으면 throw. |
|
|
289
|
+
| `sessionTtlSeconds` | 정수 1–2592000 | `28800`(8h) | 절대 session 수명. `[1, 2592000]`(30d 상한)을 벗어나면 throw. |
|
|
290
|
+
| `idleTtlSeconds` | 정수 1–2592000 | `1800`(30m) | idle 타임아웃(sliding `lastSeen`). 범위를 벗어나면 throw. |
|
|
291
|
+
| `maxAgeSeconds` | 정수 1–2592000 \| `null` | `null` | 설정 시 OIDC `max_age`를 보내고 `auth_time`이 `maxAge + skew` 이내일 것을 요구한다. 존재하지만 범위를 벗어나면 throw. |
|
|
292
|
+
| `tokenEndpointAuthMethod` | `client_secret_basic` \| `client_secret_post` | `client_secret_basic` | token-endpoint 인증 방식. 알 수 없는 값이거나, `clientSecret` 없이 설정되면 throw(confidential client에서만 유효). |
|
|
293
|
+
| `secureCookies` | `true` \| `false` \| `"auto"` | `"auto"` | externally-visible scheme로부터 쿠키 `Secure`/`__Host-` 하드닝을 강제하거나 자동 도출한다. 그 외 값이면 throw. |
|
|
294
|
+
| `trustProxy` | string \| `null` | `null` | TLS를 종단하는 fronting proxy를 명시한다; browser-facing scheme를 HTTPS로 간주한다(쿠키 하드닝에 반영). 문자열이 아니거나 비어 있으면 throw. |
|
|
295
|
+
| `algorithms` | 비어 있지 않은 문자열 배열 | `["RS256","ES256"]` | 허용된 JWS 알고리즘(verifier로 전달). 비어 있지 않은 배열이 아니면 throw. |
|
|
296
|
+
| `clockSkewSeconds` | 수 0–300 | (verifier 기본값) | ID-token 시간 클레임의 여유. `[0,300]`을 벗어나면 throw. |
|
|
297
|
+
| `prompt` | string \| `null` | `null` | 선택적 OIDC `prompt`. 존재하지만 비어 있거나 문자열이 아니면 throw. |
|
|
298
|
+
| `pendingTtlSeconds` | 정수 1–3600 | `600`(10m) | 로그인 완료 제한 시간(pre-auth 레코드 TTL). `[1,3600]`을 벗어나면 throw. |
|
|
299
|
+
| `pendingCap` | 정수 1–1000000 | `1024` | 동시 진행 중 로그인의 hard cap; store가 가득 차면 **새** 로그인을 거부하고 진행 중 auth는 절대 evict하지 않는다(fail-closed). 범위를 벗어나면 throw. |
|
|
300
|
+
| `rateLimitMax` | 정수 1–1000000 | `60` | 소스별 60s 윈도우당 `/auth/login`+`/auth/callback`. 범위를 벗어나면 throw. |
|
|
301
|
+
| `fetchTimeoutMs` | 정수 1–120000 | `5000` | egress별 타임아웃(discovery / token / JWKS). 범위를 벗어나면 throw. |
|
|
302
|
+
| `fetchImpl` / `lookupImpl` / `now` | 함수 | 주입/전역 | `fetch` / DNS `lookup` / clock seam 주입. 존재하지만 함수가 아니면 throw. |
|
|
303
|
+
| `sessionStore` | object | in-memory | opaque-id → session store; `get`/`set`/`delete`를 구현해야 한다. 존재하지만 비적합하면 throw. |
|
|
304
|
+
| `pendingStore` | object | in-memory | pre-auth 레코드 store; `set`/`take`(원자적 단일-사용 `take`)를 구현해야 한다. 존재하지만 비적합하면 throw. |
|
|
305
|
+
| `auditSink` | 함수 \| `record()`를 가진 object | 없음 | PII-safe 이벤트 sink. 존재하지만 함수도 `record()` 가진 object도 아니면 throw. |
|
|
306
|
+
|
|
307
|
+
### 쿠키 하드닝 의미
|
|
308
|
+
|
|
309
|
+
session은 **server-side 전용**이다 — 쿠키는 클레임/토큰이 아닌 opaque id만 보유한다. 두 개의 쿠키를 사용한다(pending 레코드를 바인딩하는 pre-auth 쿠키, 그리고 session 쿠키). externally-visible scheme가 HTTPS이면(`https` `redirectUri`, `secureCookies: true`, 또는 non-null `trustProxy`) 쿠키는 **`__Host-` prefix + `Secure` + `HttpOnly` + `SameSite=Lax`**(`Path=/`, `Domain` 없음)를 사용한다; `SameSite=Lax`는 IdP의 top-level GET이 `/auth/callback`으로 쿠키를 실어 보내게 한다. 문서화된 **loopback-`http` carve-out** 하에서는 `__Host-`/`Secure` 속성이 제거되고(plaintext 리스너는 `Secure`를 설정할 수 없음) bare 쿠키 이름을 사용한다. **HTTPS가 확인되지 않은 off-loopback broker는 construction에서 fail-closed**된다 — `Secure`/`__Host-` 쿠키는 평문으로 전송되지 않으므로 로그인이 조용히 깨질 것이다. `/auth/callback`에서 **새** session id가 발급된다(fixation 없음); `/auth/logout`은 non-GET, CSRF-헤더 게이트(`x-haechi-csrf`)이며 server-side 상태를 파괴한다. access token은 폐기된다(절대 저장하지 않음). audit 이벤트(`oidc.login.start`/`success`/`failure{reasonCode}`/`logout`/`session.evict`)는 keyed-HMAC `subjectHash`/`issuerHash`/`sessionIdHash` + `provider` + 거친 `reasonCode` + timestamp만 보유한다.
|
|
310
|
+
|
|
311
|
+
### dashboard와의 연결
|
|
312
|
+
|
|
313
|
+
broker를 dashboard의 `sessionGuard`로 주입한다:
|
|
314
|
+
|
|
315
|
+
```js
|
|
316
|
+
import { createDashboardServer } from "haechi-dashboard";
|
|
317
|
+
import { createOidcSessionBroker } from "haechi-auth-oidc";
|
|
318
|
+
|
|
319
|
+
const broker = createOidcSessionBroker({
|
|
320
|
+
cryptoProvider,
|
|
321
|
+
issuer: "https://idp.example.com",
|
|
322
|
+
clientId: "haechi-dashboard",
|
|
323
|
+
clientSecret: "…",
|
|
324
|
+
redirectUri: "https://dash.example.com/auth/callback",
|
|
325
|
+
returnToAllowlist: ["/"]
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const dashboard = createDashboardServer({
|
|
329
|
+
auditPath: ".haechi/audit.jsonl",
|
|
330
|
+
host: "0.0.0.0",
|
|
331
|
+
allowRemoteBind: true,
|
|
332
|
+
tlsContext: { key, cert }, // remote bind: dashboard가 직접 TLS 종단
|
|
333
|
+
sessionGuard: broker // /api/*를 authenticate() 뒤로 게이트; /auth/* handlers 마운트
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
broker의 `handlers` 맵은 dashboard가 auth 게이트에서 면제하는 고정 broker 경로에서만 마운트되며, 모든 `/api/*` 라우트는 `broker.authenticate(req)` 뒤에 게이트된다. publish 워크플로: `.github/workflows/auth-oidc-publish.yml`(태그 `auth-oidc-v<semver>`).
|
|
@@ -53,11 +53,12 @@ Inspects upstream JSON responses (off by default — turn on to protect what com
|
|
|
53
53
|
| Key | Type / values | Default | Notes |
|
|
54
54
|
|---|---|---|---|
|
|
55
55
|
| `responseProtection.enabled` | boolean | `false` | Master switch. Required for `detokenizeResponses` to do anything. |
|
|
56
|
-
| `responseProtection.mode` | `dry-run` \| `report-only` \| `enforce` | `enforce` | Enforcement mode for the response direction. |
|
|
56
|
+
| `responseProtection.mode` | `dry-run` \| `report-only` \| `enforce` | `enforce` | Enforcement mode for the response direction. **For real LLM upstreams, prefer `report-only`:** envelope metadata (ids, a unix-timestamp `created`, long numeric fields) can look PII/secret-shaped, and `enforce` would 502 a legitimate completion. `report-only` still detects, audits, and runs `detokenizeResponses`. (Haechi already skips its own `[TOKEN:…]`/`[HAECHI_ENC:…]` markers on the response, the phone rule ignores bare timestamps, and bare JSON number leaves aren't scanned on the response — so real vLLM/Ollama responses scan clean. `enforce` remains stricter if you also want the response *text* policed.) |
|
|
57
57
|
| `responseProtection.failureMode` | `fail-closed` \| `allow` | `fail-closed` | What to do with an *uninspectable* response (non-JSON, invalid JSON, compressed). `fail-closed` returns 502; `allow` passes it through (audited). |
|
|
58
58
|
| `responseProtection.allowNonJson` | boolean | `false` | Permit non-JSON responses through without inspection. |
|
|
59
59
|
| `responseProtection.allowCompressed` | boolean | `false` | Permit compressed responses through without inspection. |
|
|
60
60
|
| `responseProtection.maxBytes` | positive integer | `1048576` | Hard response size cap. Enforced even under `failureMode: allow` — oversized responses are always denied. |
|
|
61
|
+
| `responseProtection.scanNumbers` | boolean | `false` | Whether to run detection on **bare JSON number leaves** of the response. Off by default — response numbers are inference-server metadata (`*_duration`, counts, timestamps) and scanning them only false-positives (`card`/`kr_rrn`). Set `true` only for a strict threat model (a model assumed able to exfiltrate via a numeric field); pair with `mode: report-only` to audit without blocking on metadata. Request-direction always scans numbers regardless. |
|
|
61
62
|
|
|
62
63
|
## `streaming`
|
|
63
64
|
|
|
@@ -230,4 +231,107 @@ haechi proxy --config haechi.config.json --host 0.0.0.0 --allow-remote-bind
|
|
|
230
231
|
|
|
231
232
|
## Validation cheatsheet
|
|
232
233
|
|
|
233
|
-
These throw at load (fail-closed): unknown `keys.provider`; empty `proxy.host`; out-of-range `proxy.port`; non-`jsonl` `audit.sink`; non-`local` `tokenVault.provider`; bad `revealPolicy`; non-positive `retentionDays`; non-boolean `deterministic`/`detokenizeResponses`; empty/non-string `deterministicTypes`; empty/non-string `mcp.allowedMethods`; non-boolean `mcp.*` flags; unknown `privacy.profile`; bad `responseProtection.failureMode`; non-positive `responseProtection.maxBytes`; bad `streaming.requestMode`/`streaming.responseMode`; non-positive `streaming.maxMatchBytes`; bad `auth.provider`; empty `auth.store`; non-string `auth.allowedLabelKeys`; non-object `policy.profiles`; `policy.profileBinding` without a valid `default`; non-string `policy.modelAllowlist`; non-positive `policy.rate.requestsPerMinute`; non-positive `limits.*`; unknown `target.type`/`adapter`; unsafe custom regex; weakening action without `allowUnsafeOverrides`.
|
|
234
|
+
These throw at load (fail-closed): unknown `keys.provider`; empty `proxy.host`; out-of-range `proxy.port`; non-`jsonl` `audit.sink`; non-`local` `tokenVault.provider`; bad `revealPolicy`; non-positive `retentionDays`; non-boolean `deterministic`/`detokenizeResponses`; empty/non-string `deterministicTypes`; empty/non-string `mcp.allowedMethods`; non-boolean `mcp.*` flags; unknown `privacy.profile`; bad `responseProtection.failureMode`; non-positive `responseProtection.maxBytes`; non-boolean `responseProtection.scanNumbers`; bad `streaming.requestMode`/`streaming.responseMode`; non-positive `streaming.maxMatchBytes`; bad `auth.provider`; empty `auth.store`; non-string `auth.allowedLabelKeys`; non-object `policy.profiles`; `policy.profileBinding` without a valid `default`; non-string `policy.modelAllowlist`; non-positive `policy.rate.requestsPerMinute`; non-positive `limits.*`; unknown `target.type`/`adapter`; unsafe custom regex; weakening action without `allowUnsafeOverrides`.
|
|
235
|
+
|
|
236
|
+
# Satellite operator configuration (0.9)
|
|
237
|
+
|
|
238
|
+
The two sections below document the **independently published satellite packages** introduced in 0.9 — `haechi-dashboard` and `haechi-auth-oidc`. **These are not keys of the core `haechi.config.json` / `normalizeConfig` schema.** Each satellite is configured by passing an **options object** to its factory function (`createDashboardServer(options)` / `createOidcSessionBroker(options)`), validated by its own `normalizeDashboardConfig` / `normalizeOidcConfig`. Validation is the same **strict, fail-closed** discipline as core: an unknown option key throws, and every field below lists its fail-closed throw condition. Source: `satellites/dashboard/index.mjs`, `satellites/auth-oidc/index.mjs`. Threat-model coverage: **P1-OPS-005** (dashboard audit exposure / DNS-rebinding / remote bind) and **P1-SEC-009** (broker session/login security), per `docs/current/release-0.9-implementation-scope.md` §6.
|
|
239
|
+
|
|
240
|
+
## `haechi-dashboard` (satellite)
|
|
241
|
+
|
|
242
|
+
A zero-dependency, **read-only** audit viewer (`node:http`) that serves the audit JSONL and its hash-chain status. It takes **paths**, not a runtime. Configured via `createDashboardServer(options)`; `normalizeDashboardConfig(options)` validates and returns the effective config. Source: `satellites/dashboard/index.mjs`.
|
|
243
|
+
|
|
244
|
+
| Option | Type / values | Default | Notes / fail-closed throw |
|
|
245
|
+
|---|---|---|---|
|
|
246
|
+
| `auditPath` | non-empty string | **required** | Path to the audit JSONL. Throws if missing or not a non-empty string. |
|
|
247
|
+
| `anchorPath` | string \| `null` | `null` | Anchor stream path passed to `verifyAuditChain` for tail-truncation detection. Throws if present but not a non-empty string. |
|
|
248
|
+
| `host` | non-empty string | `127.0.0.1` | Bind address. Non-loopback requires `allowRemoteBind` **and** the remote-bind preconditions below. Throws if present but empty/non-string. |
|
|
249
|
+
| `port` | integer 0–65535 | `1018` | Listen port; `0` = OS-assigned ephemeral (intentional affordance). Throws if not an integer in `[0,65535]`. |
|
|
250
|
+
| `allowRemoteBind` | boolean | `false` | Permit a non-loopback `host`. Throws if non-boolean. Config alone is not enough — see remote-bind preconditions. |
|
|
251
|
+
| `sessionGuard` | object \| `null` | `null` | A guard implementing `authenticate(req) -> session\|null` and an optional `handlers` map. Throws if non-object or `authenticate` is not a function. `handlers` keys may **only** be the fixed broker paths `/auth/login`, `/auth/callback`, `/auth/logout` — any other key (notably `/api/*`, `/healthz`, `/`) throws, closing the auth-bypass where a guard exempts an audit route from the gate. Satisfied by injecting a `haechi-auth-oidc` broker (see below). |
|
|
252
|
+
| `window` | integer 4096–67108864 | `1048576` | Tail-read window (max bytes) for `/api/events` and `/api/summary`. Throws if not an integer in `[4096, 67108864]` (4 KiB–64 MiB). |
|
|
253
|
+
| `tlsContext` | object \| `null` | `null` | TLS material for the dashboard to terminate HTTPS itself. Throws if non-object, or if non-null but lacking **usable material** — it must carry `(key && cert)` or `pfx` (an empty `{}` is rejected so it can't green-light a non-loopback plaintext listener). |
|
|
254
|
+
| `trustProxy` | string \| `null` | `null` | Names a trusted fronting-proxy address/CIDR. Throws if non-string, empty, or a falsy-looking string (`"false"`/`"0"`). **`trustProxy` alone never authorizes a non-loopback bind** — only a real `tlsContext` does. |
|
|
255
|
+
|
|
256
|
+
### Routes
|
|
257
|
+
|
|
258
|
+
All routes are **GET/HEAD only** (any other method → `405`); the asset map is fixed in-code (no filesystem traversal):
|
|
259
|
+
|
|
260
|
+
- `/api/events` — bounded tail read of the audit JSONL, newest-first. `limit` is an integer in `[1,200]` (default 50); `cursor` is the opaque `auditIntegrity.sequence` (never a filesystem offset). Each event is rebuilt by a **recursive key-by-key allowlist projection** (no blind spread; identity carries only `subjectHash`/`issuerHash`, never scopes/labels/raw subject). Returns `windowExceeded` when a requested page predates the retained window.
|
|
261
|
+
- `/api/chain` — wraps `verifyAuditChain`; surfaces a derived `truncationDetected` boolean (the raw failure reason is **never** returned). mtime+size cached (no concurrent re-walk); over the 32 MiB cap returns `413` with `{valid:null}`; `HEAD` returns headers only without forcing a walk.
|
|
262
|
+
- `/api/summary` — aggregated detection counts (`byType`/`byAction`/`detectionCount`) over the tail window.
|
|
263
|
+
- `/healthz` — liveness only (`{status:"ok"}`); no session required even off-loopback.
|
|
264
|
+
|
|
265
|
+
### Security defaults
|
|
266
|
+
|
|
267
|
+
- **Loopback bind by default.** `host` defaults to `127.0.0.1`; binding a non-loopback host reuses core's `assertSafeProxyBind` (re-worded) and requires `allowRemoteBind`.
|
|
268
|
+
- **Remote bind is fail-closed.** A non-loopback bind requires **all** of: `allowRemoteBind: true`, a `sessionGuard`, **and** a valid `tlsContext` (the dashboard terminates TLS itself). `trustProxy` does not satisfy this — a non-loopback plaintext listener would serve audit data in cleartext while emitting HSTS, so it is refused. HSTS is emitted **only** when the server actually serves HTTPS.
|
|
269
|
+
- **Anti-DNS-rebinding Host allowlist** is the unconditional first gate on every request (including `/api/*`, `/healthz`, and any method); a bad/duplicate `Host` header → `403` before the method check.
|
|
270
|
+
- **Strict CSP + Trusted Types** (`require-trusted-types-for 'script'`, `textContent` rendering) plus `X-Frame-Options: DENY`, `Cross-Origin-Resource-Policy`/`-Opener-Policy: same-origin`, `X-Content-Type-Options: nosniff`, and `Cache-Control: no-store`; CORS headers are intentionally never set.
|
|
271
|
+
- **sessionGuard seam.** When a guard is present, every `/api/*` route is gated behind `authenticate()`; an unauthenticated request gets `401` (never a `302` redirect). The auth-exempt set is the **intersection** of the fixed broker-path allowlist and the guard's declared handlers (exact match) — a guard can never exempt an audit-data route.
|
|
272
|
+
- **Generic errors.** A 5xx returns `{error:"internal"}` only — never a stack, OS code, or filesystem path. A satellite-local fixed-window rate limiter (120 req/60s per source) fronts `/api/*`.
|
|
273
|
+
|
|
274
|
+
The bin `haechi-dashboard` (workspace) launches the server; the publish workflow is `.github/workflows/dashboard-publish.yml` (tag `dashboard-v<semver>`). `peerDependencies: { haechi: ">=0.8.0 <1.0.0" }`.
|
|
275
|
+
|
|
276
|
+
## `haechi-auth-oidc` (satellite)
|
|
277
|
+
|
|
278
|
+
A zero-dependency **interactive OIDC session broker** (authorization-code + PKCE) — the dashboard's human-login mechanism. It produces an opaque server-side session and **satisfies the dashboard `sessionGuard` contract by injection** (`{ authenticate(req), handlers: { "/auth/login", "/auth/callback", "/auth/logout" } }`). It is **not** a per-request bearer validator (that role stays with `haechi-auth-jwt`). Configured via `createOidcSessionBroker(options)`; `normalizeOidcConfig(options)` validates. Source: `satellites/auth-oidc/index.mjs`. `peerDependencies: { haechi: ">=0.8.0 <1.0.0", haechi-auth-jwt: ">=0.2.0 <1.0.0" }`.
|
|
279
|
+
|
|
280
|
+
| Option | Type / values | Default | Notes / fail-closed throw |
|
|
281
|
+
|---|---|---|---|
|
|
282
|
+
| `cryptoProvider` | object with `hmac()` | **required** | Supplies the keyed-HMAC for PII-safe identity hashes and `sessionIdHash`. Throws if `hmac` is not a function. |
|
|
283
|
+
| `issuer` | HTTPS URL string | **required** | OIDC issuer; pinned for exact string-equal discovery and single-origin endpoint checks. Throws if missing or not `https`. |
|
|
284
|
+
| `clientId` | non-empty string | **required** | OAuth client id (also the expected ID-token `aud`). Throws if missing/empty. |
|
|
285
|
+
| `clientSecret` | string \| omitted | omitted | Present ⇒ confidential client; omitted ⇒ public (PKCE-only) client. Throws if present but empty. |
|
|
286
|
+
| `redirectUri` | absolute URL string | **required** | Must be `https` (or **loopback** `http` under the carve-out), **same-origin** with the broker, and path exactly `/auth/callback`. Throws otherwise. |
|
|
287
|
+
| `scopes` | string array | `["openid"]` | `openid` is force-included (deduped); `offline_access` is stripped (refresh rotation is out of scope for 0.9). Throws if not an array of non-empty strings. |
|
|
288
|
+
| `returnToAllowlist` | string array | `["/"]` | Allowlist of **relative same-origin** return paths (must start with a single `/`, no scheme/host/`//`/backslash). Throws on a non-array or any non-conforming entry. |
|
|
289
|
+
| `sessionTtlSeconds` | integer 1–2592000 | `28800` (8h) | Absolute session lifetime. Throws if out of `[1, 2592000]` (30d ceiling). |
|
|
290
|
+
| `idleTtlSeconds` | integer 1–2592000 | `1800` (30m) | Idle timeout (sliding `lastSeen`). Throws if out of range. |
|
|
291
|
+
| `maxAgeSeconds` | integer 1–2592000 \| `null` | `null` | If set, sends OIDC `max_age` and requires `auth_time` within `maxAge + skew`. Throws if present but out of range. |
|
|
292
|
+
| `tokenEndpointAuthMethod` | `client_secret_basic` \| `client_secret_post` | `client_secret_basic` | Token-endpoint auth method. Throws on an unknown value, **or** if set without a `clientSecret` (only valid for a confidential client). |
|
|
293
|
+
| `secureCookies` | `true` \| `false` \| `"auto"` | `"auto"` | Forces or auto-derives the cookie `Secure`/`__Host-` hardening from the externally-visible scheme. Throws on any other value. |
|
|
294
|
+
| `trustProxy` | string \| `null` | `null` | Names a TLS-terminating fronting proxy; treats the browser-facing scheme as HTTPS (folds into cookie hardening). Throws if non-string or empty. |
|
|
295
|
+
| `algorithms` | non-empty string array | `["RS256","ES256"]` | Allowed JWS algorithms (passed to the verifier). Throws if not a non-empty array. |
|
|
296
|
+
| `clockSkewSeconds` | number 0–300 | (verifier default) | Leeway for ID-token time claims. Throws if out of `[0,300]`. |
|
|
297
|
+
| `prompt` | string \| `null` | `null` | Optional OIDC `prompt`. Throws if present but empty/non-string. |
|
|
298
|
+
| `pendingTtlSeconds` | integer 1–3600 | `600` (10m) | Time to complete a login (pre-auth record TTL). Throws if out of `[1,3600]`. |
|
|
299
|
+
| `pendingCap` | integer 1–1000000 | `1024` | Hard cap on concurrent in-flight logins; a full store rejects **new** logins and never evicts an in-flight auth (fail-closed). Throws if out of range. |
|
|
300
|
+
| `rateLimitMax` | integer 1–1000000 | `60` | `/auth/login`+`/auth/callback` per source per 60s window. Throws if out of range. |
|
|
301
|
+
| `fetchTimeoutMs` | integer 1–120000 | `5000` | Per-egress timeout (discovery / token / JWKS). Throws if out of range. |
|
|
302
|
+
| `fetchImpl` / `lookupImpl` / `now` | function | injected/global | Injectable `fetch` / DNS `lookup` / clock seams. Throws if present but not a function. |
|
|
303
|
+
| `sessionStore` | object | in-memory | Opaque-id → session store; must implement `get`/`set`/`delete`. Throws if present but non-conforming. |
|
|
304
|
+
| `pendingStore` | object | in-memory | Pre-auth record store; must implement `set`/`take` (atomic single-use `take`). Throws if present but non-conforming. |
|
|
305
|
+
| `auditSink` | function \| object with `record()` | none | PII-safe event sink. Throws if present but neither a function nor an object with `record()`. |
|
|
306
|
+
|
|
307
|
+
### Cookie hardening semantics
|
|
308
|
+
|
|
309
|
+
Sessions are **server-side only** — the cookie carries only an opaque id, never claims or tokens. Two cookies are used (a pre-auth cookie binding the pending record, and the session cookie). When the externally-visible scheme is HTTPS (an `https` `redirectUri`, `secureCookies: true`, or a non-null `trustProxy`), cookies use the **`__Host-` prefix + `Secure` + `HttpOnly` + `SameSite=Lax`** (`Path=/`, no `Domain`); `SameSite=Lax` lets the IdP top-level GET to `/auth/callback` carry the cookie. Under the documented **loopback-`http` carve-out** the `__Host-`/`Secure` attributes are dropped (a plaintext listener cannot set `Secure`), using the bare cookie names. An **off-loopback broker without confirmed HTTPS fails closed at construction** — a `Secure`/`__Host-` cookie is never sent over plaintext, so login would silently break. At `/auth/callback` a **fresh** session id is minted (no fixation); `/auth/logout` is non-GET, CSRF-header gated (`x-haechi-csrf`), and destroys server-side state. The access token is discarded (never stored). Audit events (`oidc.login.start`/`success`/`failure{reasonCode}`/`logout`/`session.evict`) carry only keyed-HMAC `subjectHash`/`issuerHash`/`sessionIdHash` + `provider` + a coarse `reasonCode` + timestamp.
|
|
310
|
+
|
|
311
|
+
### Wiring into the dashboard
|
|
312
|
+
|
|
313
|
+
Inject the broker as the dashboard's `sessionGuard`:
|
|
314
|
+
|
|
315
|
+
```js
|
|
316
|
+
import { createDashboardServer } from "haechi-dashboard";
|
|
317
|
+
import { createOidcSessionBroker } from "haechi-auth-oidc";
|
|
318
|
+
|
|
319
|
+
const broker = createOidcSessionBroker({
|
|
320
|
+
cryptoProvider,
|
|
321
|
+
issuer: "https://idp.example.com",
|
|
322
|
+
clientId: "haechi-dashboard",
|
|
323
|
+
clientSecret: "…",
|
|
324
|
+
redirectUri: "https://dash.example.com/auth/callback",
|
|
325
|
+
returnToAllowlist: ["/"]
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const dashboard = createDashboardServer({
|
|
329
|
+
auditPath: ".haechi/audit.jsonl",
|
|
330
|
+
host: "0.0.0.0",
|
|
331
|
+
allowRemoteBind: true,
|
|
332
|
+
tlsContext: { key, cert }, // remote bind: dashboard terminates TLS itself
|
|
333
|
+
sessionGuard: broker // gates /api/* behind authenticate(); mounts /auth/* handlers
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
The broker's `handlers` map mounts only at the fixed broker paths the dashboard exempts from its auth gate; every `/api/*` route is gated behind `broker.authenticate(req)`. Publish workflow: `.github/workflows/auth-oidc-publish.yml` (tag `auth-oidc-v<semver>`).
|
|
@@ -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 부트스트랩).
|