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.
@@ -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).
@@ -0,0 +1,170 @@
1
+ # Haechi 1.0 구현 범위
2
+
3
+ - 상태: Draft 0.2 (설계 — 아직 미구현; 2026-06-11 3-렌즈 적대적 보안 리뷰 후 강화)
4
+ - 날짜: 2026-06-11
5
+ - 목표 버전: 1.0.0 (0.9.0 다음)
6
+ - 유형: 안정 API 계약 + 플러그인 샌드박스 (첫 번째 안정 릴리스)
7
+
8
+ ## 1. 릴리스 목표
9
+
10
+ 1.0은 **첫 번째 안정 릴리스**다: (a) 지원 중단(deprecation)/마이그레이션 정책과 장기 감사 스키마를 갖춘 **안정적인 공개 API 계약을 동결**하고, (b) 0.1부터 의도적으로 유지해온 선을 넘는다 — **외부 플러그인 코드의 동적 로딩** — 단, **비대칭 서명, 기능(capability) 게이트, `worker_threads` 격리, 감사**가 갖춰진 샌드박스를 통해서만, 그리고 우선 **`authProvider`** 계약에 한해서만.
11
+
12
+ **범위 결정 (2026-06-11, 메인테이너 확인):**
13
+
14
+ 1. **샌드박스/로딩 모델:** 동적 로딩은 **서명(Ed25519, 비대칭)**되고, **기능(capability) 매니페스트 allowlist + 운영자 pin/revocation 체크**를 통과하며, **`node:worker_threads` 격리** 경계에서 실행되고, 전체 **라이프사이클 감사**를 갖춘 플러그인에 한해서만 활성화된다. `createRuntime(config, providers)` **주입(injection)은 기본이자 권장 경로로 유지된다**.
15
+ 2. **플러그인 범위:** 1.0에서는 **`authProvider` 전용**. Classifier/filter 및 crypto 플러그인은 1.x까지 주입 전용으로 유지.
16
+ 3. **API 동결:** **엄격** — 핵심 공개 API, **provider 계약**, **감사 이벤트 스키마**(중첩 하위 스키마 포함), **config 스키마**가 엄격한 semver와 지원 중단 정책 하에 동결된다.
17
+ 4. **릴리스 형태:** **단계적** — 1.0.0은 API 동결 + 서명된 플러그인 계약/적합성(conformance)/서명 + worker 격리 `authProvider` 샌드박스 MVP를 출시한다. 더 강력한 기능 **강제(enforcement)**(child-process + Node 권한 모델), 더 많은 플러그인 종류, 라이브 revocation 피드, 레지스트리는 1.x.
18
+
19
+ Core는 **zero runtime dependency**를 유지한다 — 샌드박스는 `node:worker_threads` + `node:crypto`(Ed25519 sign/verify는 `node:crypto` 내장) 위에 구축된다. `packages/policy-bundle`은 재사용하지 **않는다**(그것은 대칭 HMAC다 — §2.2 참조).
20
+
21
+ ### 정직한 보안 모델 (먼저 읽을 것)
22
+
23
+ **`node:worker_threads`는 악성 코드에 대한 보안 샌드박스가 아니다.** worker는 프로세스를 공유하며 파일시스템, 네트워크, `process.env`에 여전히 접근할 수 있다; 격리는 **V8 힙 전용**이다(Node의 권한 모델은 프로세스 전체에 걸쳐 적용되며 worker별로 적용되지 않는다; `SharedArrayBuffer`/transferable은 공유 메모리 채널을 다시 열 수도 있으므로 와이어 형식은 일반 JSON 문자열이다 — §2.3). 따라서 1.0 샌드박스는 다음을 제공한다:
24
+
25
+ - **메모리 격리** — 별도의 V8 힙; 플러그인은 호스트 메모리, 암호화 키, 토큰 볼트, 감사 싱크를 읽거나 오염시킬 수 없다(타입이 지정된 메시지 채널만이 경계를 넘는다).
26
+ - **크래시/행(hang) 격리 + 리소스 제한** — `resourceLimits`(힙 상한) + 각 호출에 **worker를 종료시키는 타임아웃**이 버그가 있거나 폭주하는 플러그인을 억제한다; 행(hang)은 fail-closed(거부)로 처리된다.
27
+ - **데이터 최소화** — 호스트는 worker에게 **크리덴셜 슬라이스**(`Authorization` 헤더 / bearer 토큰)만 전송하며, **요청 바디와 암호화 키는 절대 전달하지 않는다**; worker는 **raw 클레임**을 반환하고, **호스트**가 `buildExternalIdentity`를 통해 PII-safe identity를 구축한다(keyed-HMAC 키는 호스트를 벗어나지 않는다).
28
+ - **좁고 감사된 타입이 지정된 계약** — worker는 `authProvider` 메시지 프로토콜만 사용하며; 모든 로드/거부/종료 결정이 감사된다(§2.4).
29
+
30
+ 1.0에서 worker 경계가 보장하지 **않는** 것 — 이것들은 **수용된 잔여 위험으로, worker가 아닌 서명/검증 신뢰 모델에 의해서만 게이트된다**(§6):
31
+
32
+ - **악성 *서명된* 플러그인은 여전히 OS를 사용할 수 있다** — `fetch`, `fs`, `process.env`는 차단되지 않는다. 매니페스트의 `networkEgress: false`는 *선언*이며, 1.0에서 강제된 통제가 아니다.
33
+ - **악성 *서명된* auth 플러그인은 합법적으로 수신하는 라이브 크리덴셜을 유출할 수 있다**(bearer 토큰), 사실상 네트워크 egress를 갖기 때문이다. 1.0에는 **기술적 장벽이 없다** — 신뢰 게이트만 있을 뿐이다.
34
+
35
+ 진정한 플러그인별 기능 **강제**(fs/net 차단, 크리덴셜 봉쇄)는 **child-process 격리와 Node 권한 모델**(`--permission --allow-fs-read=…`)이 필요하며, 이는 문서화된 **1.x** 경로다. 이것이 주입이 기본으로 유지되고 신뢰 게이트(비대칭 서명 + 운영자 allowlist + pin + revocation)가 핵심인 이유다.
36
+
37
+ ## 2. 범위
38
+
39
+ ### 2.1 API 안정성 동결 (1.0 계약)
40
+
41
+ **동결된 공개 표면 (명시적 IN/OUT 테이블이 오늘날의 모호한 "0.x는 preview" 표현을 대체한다).** 모든 `package.json` `exports` 서브패스와 CLI가 분류된다:
42
+
43
+ | 표면 | 1.0 상태 |
44
+ |---|---|
45
+ | `haechi` / `haechi/core` (`createRuntime`, `createHaechi().protectJson`, `collectStringEntries`), `haechi/auth` (`authProvider` 계약, `buildExternalIdentity`, `buildIdentity`, `validateLabels`), `haechi/crypto` (`cryptoProvider` 계약, `assertCryptoProviderConformance`, `canonicalize`), `haechi/audit` (이벤트 스키마, `verifyAuditChain`, `sanitizeAudit`, `FORBIDDEN_KEYS`), `haechi/policy`, `haechi/filter` (룰 형태), `haechi/token-vault`, `haechi/runtime` (`normalizeConfig` 형태), `haechi/protocol-adapters`, `haechi/plugin` (매니페스트 + 신규 샌드박스) | **동결** (파괴적 변경 = major) |
46
+ | `haechi/proxy`, `haechi/mcp-stdio`, `haechi/stream-filter`, `haechi/policy-bundle`, `haechi/privacy-profiles`, 그리고 **CLI** (`bin/haechi.mjs`) | **동작 + wire/계약 동결**; 사람이 읽는 CLI/로그 **텍스트**는 여전히 변경 가능(계약 대상 아님) |
47
+ | `api-stability.md §3`에 아직 실험적으로 표시된 항목 | **졸업**(§3에서 제거)되거나 명시된 이유와 함께 **1.0 이후에도 명시적으로 preview로 유지** — 묵시적 모호함 없음 |
48
+
49
+ - **1.0부터 엄격한 semver** (파괴적 변경→major, 가산적 변경→minor, 수정→patch). core에 대한 "0.x minor는 파괴적 변경 가능" 여유가 끝난다.
50
+ - **지원 중단 정책.** 지원 중단된 export/필드/옵션은 **≥1 minor** 동안 유지되며, 문서화된 마이그레이션 노트와 **안정적인 `code` 접두사 `HAECHI_DEPRECATION_*`**가 있는 일회성 런타임 `process.emitWarning`을 방출하고(code/텍스트 자체도 계약의 일부), **다음 major**에서만 제거된다. **보안 예외(허용된 단 하나의 minor 내 파괴적 변경):** *공개된* 취약점을 닫기 위한 변경은 보안 권고문 + 마이그레이션 경로와 함께 **minor 내에서** 파괴적 변경/제거가 가능하다(기존 "안전하지 않은 config 차단은 패치에서 강화될 수 있다" 여유를 반영).
51
+ - **감사 이벤트 스키마 — 중첩 하위 스키마를 포함하여 동결**, 열거됨(최상위 레벨만이 아님): 최상위 `{id, timestamp, protocol, operation, identity, profile, mode, enforced, blocked, payloadShapeHash, detections, summary, auditIntegrity}`; `detections[].{type, ruleId, path, kind, confidence, action, enforced}`; **`identity.{id, type, subjectHash, issuerHash, provider}`**(PII-safe 프로젝션 — `scopes`/`labels`/raw subject는 감사 identity에 **포함되지 않음**); `summary.{byType, byAction, detectionCount}`; `auditIntegrity.{alg, canonicalization, sequence, previousHash, eventHash}`. **새 필드는 가산적으로만 추가되며 기존 필드의 정규화에 절대 영향을 미치지 않으므로**, 1.x 이벤트는 1.0 `verifyAuditChain`으로도 검증된다(이는 `canonicalize`가 리터럴 객체를 해시하고 검증기가 *동일하게 저장된 객체*를 재계산하기 때문에 유효하다 — 보장의 의미는 "미래에 가산적으로 추가된 필드가 새 레코드를 읽는 구버전 검증기를 깨뜨리지 않는다"는 것으로, 이전의 두루뭉술한 표현보다 정확하게 명시됨). 정규화 변경은 새 `canonicalization` 태그 + 리더 마이그레이션 경로와 함께 **major** 이벤트 스키마 bump다. 소비자가 파싱 없이 분기할 수 있도록 명시적 최상위 **`schemaVersion`**을 추가한다(리더 대면; 가산적).
52
+ - **Config 스키마 동결 단위:** config **키 존재 + 형태**가 동결됨; **기본값은 여전히 강화될 수 있음**(더 안전한 기본값은 파괴적 변경이 아님). 알 수 없는 키는 여전히 throw(fail-closed).
53
+
54
+ ### 2.1a 위성 호환성 전제 조건 (core 1.0.0 bump 전에 반드시 완료)
55
+
56
+ 네 위성 모두 `"haechi": ">=0.8.0 <1.0.0"`을 pin하고 있다 — 그리고 `<1.0.0`은 `1.0.0`을 **제외한다**(심지어 `1.0.0-rc.x`도). core를 1.0.0으로 bump하면 **모든 위성의 peer dependency를 충족 불가 상태로 만든다**(ERESOLVE / unmet peer). `haechi-auth-oidc`도 크로스-위성 동일 문제가 있다(`"haechi-auth-jwt": ">=0.2.0 <1.0.0"`). 따라서 **PR0**(어떤 core bump보다도 먼저):
57
+
58
+ - 모든 위성의 peer 범위를 다음 minor가 아닌 core **major**를 추적하도록 확장: `"haechi": ">=0.8.0 <2.0.0"`(동결의 정의상 유효 — ≥0.8로 빌드된 위성은 전체 1.x 라인에서 동작함), 그리고 `haechi-auth-oidc`의 `"haechi-auth-jwt": ">=0.2.0 <2.0.0"`. 네 위성 모두 패치 릴리스(`auth-jwt 0.2.x`, `crypto-kms 0.2.x`, `dashboard 0.1.x`, `auth-oidc 0.1.x`) + lockfile 재생성(workspace-lockfile 규칙 적용).
59
+ - `release:preflight` **게이트** 추가: 모든 `satellites/*/package.json` peer 범위를 파싱하여 발행할 core 버전에 대해 `semver.satisfies(coreVersion, range)`를 단언 — 미래의 core major가 위성이 여전히 제외하는 상태에서 출시되는 일을 방지.
60
+ - `api-stability.md §5`에 문서화: 위성 peer **상한은 core MAJOR를 추적**하며, 다음 minor 미만으로 pin되지 않는다.
61
+
62
+ ### 2.2 비대칭 서명 플러그인 계약 (Ed25519) + 핀닝 + revocation + 적합성(conformance)
63
+
64
+ **서명은 비대칭(Ed25519)이며, 대칭 `policy-bundle` HMAC이 아니다.** `policy-bundle`은 로컬 AES 키 파일로 keyed된 HMAC으로 서명한다 — 검증기가 서명하는 것과 동일한 비밀을 보유하므로 "제3자 저자가 서명하고 운영자가 공개 키로 검증한다"는 표현을 할 수 없다. 1.0은 **`node:crypto` Ed25519** 서명 매니페스트 프리미티브를 추가한다(새 의존성 없음): **저자가 Ed25519 개인 키를 보유**; **운영자가 Ed25519 공개 키를 신뢰 앵커(trust anchor)로 allowlist**. (플러그인 서명에 `policy-bundle`을 재사용하지 말 것.)
65
+
66
+ - **서명 봉투는 경로가 아닌 콘텐츠를 커버한다.** 서명 바이트는 `canonicalize({ pluginId, kind, version, capabilities, coreVersionRange, entrySha256, notBefore, notAfter })` — 즉 서명은 **정확한 엔트리 바이트의 sha256**, **kind**, **선언된 capabilities**, **호환 가능한 core 범위**, **유효 기간**을 바인딩한다. 경로에 서명하거나(`entrySha256`/`kind`/`capabilities`를 생략하는 것)는 교체(swap)/capability 다운그레이드 공격이 되므로 거부된다.
67
+ - **신뢰 앵커 전용 키 해석 (kid-by-claim 없음).** 검증 키는 **오직** 운영자의 `trustAnchors` allowlist에서만 해석된다; `manifest.signerKeyId`가 allowlist된 앵커에 없으면 **검증 전에 거부**한다. 알고리즘은 앵커별로 Ed25519로 고정된다(alg 민첩성 없음, HS/RS 혼동 없음). 플러그인 신뢰 앵커 세트는 **별도의 큐레이션된 목록**이며, AES 로테이션 키 파일과 절대 혼용하지 않는다(만료/로테이션된 AES kid가 서명자 앵커가 되어서는 안 됨).
68
+ - **핀닝 (악성 업데이트 방지 / 롤백 방지).** 운영자 config `plugin.pin = { version?, entrySha256?, manifestSha256? }`: 로더는 로드된 매니페스트 버전/엔트리 해시가 pin과 일치하지 않으면 fail-closed. **`pluginId`별 버전 플로어**는 이전 서명된 아티팩트로의 롤백을 거부한다. 따라서 *신뢰된 서명자*도 pin/플로어를 트리거하지 않고는 동일 앵커 하에 새(또는 구버전 취약한) 엔트리를 조용히 출시할 수 없다.
69
+ - **Revocation + 최신성.** 운영자 denylist `plugin.revokedSignerKeyIds` + `plugin.revokedEntrySha256`은 로드 시 확인됨(fail-closed: 취소된 서명자 또는 해시는 절대 로드되지 않음). 서명된 `notBefore`/`notAfter` 기간은 로드 시 강제됨. **메모리 내 revocation 동작 (1.0, 솔직하게 명시):** revocation은 **다음 로드/재시작 시**에 적용된다; **전역 kill-switch** (`plugins.enabled: false` 및 플러그인별 disable)로 운영자가 **라이브 플러그인을 즉시 강제 제거**할 수 있다. 라이브 CRL/피드는 1.x.
70
+ - **매 재시작(respawn)마다 재검증.** worker는 타임아웃-종료 후 지연 재시작되므로, **전체 게이트(서명 + 앵커 + pin + revocation + capability allowlist)가 최초 생성뿐 아니라 매 spawn마다 재실행**된다.
71
+ - **Capability allowlist (운영자 측).** `plugins.allowCapabilities`; 그 밖의 capability를 요청하는 매니페스트는 거부됨. `readsCredentials`는 `kind: authProvider`에서 **필수**다(bearer 토큰을 봄). `networkEgress`/`readsPlaintext`는 1.0에서 **선언되고 감사되지만 worker에 의해 강제되지 않는다**(§1 잔여 — 노출됨, 신뢰됨 아님).
72
+ - **적합성(conformance)은 정확성 게이트이지 악의 스크린이 아니다.** `assertAuthProviderConformance(provider, { now, vectors })`는 **샌드박스된** 플러그인을 열거된 보안 동작으로 실행한다: 크리덴셜 없음 → `null`; 형식 불량 크리덴셜 → `null`; 만료/아직 유효하지 않음(`now`를 통해 주입된 시각) → `null`; 내부 **throw는 호출자에게 `null`로 표면화**(절대 전파하지 않음); 반환된 identity는 반드시 `subjectHash`/`issuerHash`를 가져야 하며 raw 입력 subject/issuer와 동일한 필드를 **포함해서는 안 된다**(PII 안전성); 거부는 동일 입력에 대해 **결정론적**; 유효 크리덴셜 → 올바르게 형성된 PII-safe identity. 로더는 **이에 실패하는 플러그인의 연결을 거부한다**. 그러나 서명된 플러그인은 고정된 테스트를 감지하고 동작을 바꿀 수 있으므로: 적합성은 **로드마다 예측 불가능한 무작위 벡터**를 사용하며, — 핵심 — **호스트는 매 호출마다 PII 안전성을 재검증**한다(`buildExternalIdentity` + 아래 sanitizer가 요청별로 실행됨, 로드 시에만이 아님). **적합성 통과가 신뢰성을 의미하지 않는다**(그것은 서명+검증 게이트다); 테스트/프로덕션 분기는 수용된 잔여 위험이다(§6).
73
+
74
+ ### 2.3 `worker-isolated` `authProvider` 샌드박스 (MVP)
75
+
76
+ `createSandboxedAuthProvider({ manifestPath, trustAnchors, allowCapabilities, pin, revoked, cryptoProvider, auditSink, timeoutMs, maxPendingCalls, maxMessageBytes, resourceLimits, now })`는 동결된 계약을 만족하는 **호스트 측 `authProvider`**를 반환한다 — 따라서 **기존** 주입 심(seam)과 새 `auth.provider: "plugin"` config 경로를 통해 연결된다.
77
+
78
+ - **로드 시퀀스 (모든 단계에서 fail-closed, 각 단계 감사됨):** 매니페스트 검증(`worker-isolated` + `kind: authProvider`) → `signerKeyId`를 **`trustAnchors`에서만** 앵커 해석(아니면 거부) → **엔트리 바이트를 메모리로 읽어** sha256하고 **`entrySha256`을 포함하는 정규 봉투에 대해 Ed25519 서명 검증** → `notBefore/notAfter`, revocation denylist, pin/version-floor, capabilities ⊆ allowlist 확인 → **검증된 인메모리 소스에서** Worker를 spawn(`new Worker(code, { eval: true, resourceLimits, workerData: <비밀 없음> })`), **검증 후 경로를 재해석하지 않음**(TOCTOU 없음; symlink된 엔트리 거부) → 샌드박스된 provider에 대해 `assertAuthProviderConformance` 실행 → 그 이후에만 라이브 provider 반환. 실패 시 생성에서 throw하고 `plugin.load.refused{reason}`을 방출함(§2.4).
79
+ - **요청별 프로토콜 (데이터 최소화, correlation-id 적용):** `authenticate(request)`는 **크리덴셜 슬라이스**(`Authorization` 헤더/토큰 — 바디 절대 아님)만 추출하고, **고유한 correlation id**로 래핑하여 **MessagePort를 통해 JSON 문자열**로 post한다(structured-clone 객체 없음, `SharedArrayBuffer`/transferable 없음 → 공유 메모리 또는 객체 그래프 밀수 없음). `maxMessageBytes`가 와이어를 제한한다. worker는 크리덴셜을 검증하고(JWKS egress는 auth 플러그인에 고유) **raw 클레임** `{ subject, issuer, type, scopes, labels }` 또는 거부를 반환한다.
80
+ - **호스트 측 클레임 sanitizer (`buildExternalIdentity` 전에):** JSON 응답은 **null-prototype 객체**로 파싱된다(`JSON.parse` + `Object.create(null)`로 재구성); **고정된 own-enumerable 키 allowlist**만 허용됨; `__proto__`/`constructor`/`prototype` 제거됨; 배열 크기와 전체 identity 크기 제한됨; 모든 값은 경계에서 타입 검증/강제됨. 이후 **호스트**가 PII-safe identity를 구축한다(`buildExternalIdentity({ provider: "plugin:<pluginId>", subject, issuer, type, scopes, labels }, cryptoProvider)`) — keyed-HMAC 키는 worker에 진입하지 않으며, 적대적 클레임 객체가 prototype을 오염시키거나 raw 값을 밀수할 수 없다.
81
+ - **동시성 모델 (호출자 간 누출 없음 / 종료 경쟁 없음):** 각 in-flight 호출은 **correlation id**로 응답과 매칭됨; 일치하지 않는/중복된/늦은 응답은 **삭제됨**. worker는 **단일 점유(single-occupancy)**(하나의 in-flight 호출) — 따라서 호출별 타임아웃-종료는 *형제* 호출을 절대 죽이지 않음; 대기 호출 **상한(`maxPendingCalls`)**이 동시성을 제한함(초과 → 거부). 종료 후 재시작은 **single-flight**으로 보호됨. 플러그인은 **호출 간 무상태(stateless)**여야 하며; 잔여 크로스-요청 상태 위험은 §6 잔여다.
82
+ - **타임아웃 + 리소스 제한 (fail-closed):** 각 호출은 `timeoutMs`(필수 양의 정수 — 무한 기본값 없음)로 제한됨; 타임아웃 시 호스트는 worker를 **종료**(`plugin.worker.terminated{cause: timeout}`)하고 `null`을 반환하며 지연 재시작함(전체 게이트 재실행). `resourceLimits`가 힙을 제한함. (CPU/fd/소켓은 1.0에서 제한되지 않음 — §6 잔여.)
83
+ - **Config (`auth.provider: "plugin"`) — 열거형 fail-closed `normalizeConfig` 규칙** (`keys`/`tokenVault` 엄격함과 일치): `plugin.manifestPath`(비어 있지 않은 로컬 경로) 필수; `plugin.trustAnchors` 비어 있지 않은 `{ keyId: string, publicKey: string (Ed25519) }` 배열; `plugin.allowCapabilities` `CAPABILITY_KEYS ∪ {readsCredentials}` 부분집합인 배열(알 수 없는 것 거부); `kind: authProvider`에 `readsCredentials` 존재; `plugin.timeoutMs` 양의 정수; `resourceLimits.maxOldGenerationSizeMb` 양의 정수; 선택적 `plugin.pin`/`plugin.revoked*`/version-floor 올바르게 형성됨; `plugins.enabled` 준수(kill-switch). 모든 위반은 로드 시 throw. `createRuntime`은 호스트 측 identity 구축을 위해 주입된 `cryptoProvider`를 여전히 필요로 한다.
84
+
85
+ ### 2.4 플러그인 라이프사이클 감사 (보안 제품은 서드파티 코드 로딩을 반드시 기록해야 함)
86
+
87
+ 기존 해시-체인 `auditSink`를 재사용하여(`recordProxyDecision`/`auth_denied`가 이미 사용하는 동일 심), 샌드박스는 **PII-safe** 이벤트를 방출한다 — id/해시/카운트만:
88
+
89
+ - `plugin.load.accepted` `{ pluginId, version, entrySha256, signerKeyId, capabilitiesGranted }`
90
+ - `plugin.load.refused` `{ reason ∈ missing-signature | unknown-signer | tampered-entry | revoked | below-version-floor | pin-mismatch | expired-window | capability-not-allowlisted | conformance-failed | manifest-invalid, pluginId?, signerKeyId? }`
91
+ - `plugin.authenticate.deny` `{ pluginId, reason ∈ deny | invalid-claims | timeout | over-capacity | oversized }`
92
+ - `deny` — 플러그인이 일반 거부를 반환함 (worker 하네스가 변환한 내부 throw 포함)
93
+ - `invalid-claims` — 호스트 측 클레임 sanitize 또는 `buildExternalIdentity` 거부 (이전 `non-pii-safe-identity` 레이블 통합)
94
+ - `timeout` — 호출별 타임아웃 만료; worker 종료 및 재시작
95
+ - `over-capacity` — `maxPendingCalls` 초과; worker 큐에 진입하기 전에 호출 거부
96
+ - `oversized` — 크리덴셜 메시지가 `maxMessageBytes`를 초과; worker에 전송되지 않음
97
+ - `plugin.worker.terminated` `{ pluginId, cause ∈ timeout | oom | crash }`
98
+
99
+ `FORBIDDEN_KEYS`는 플러그인/클레임 표면(`claims`, `subject`, `issuer`, `credential`, `authorization`, `signature`, `entry`)으로 **확장**된다 — 심층 방어로서, 미래의 플러그인 이벤트가 raw 클레임/토큰/서명자 비밀을 체인 로그에 절대 누출하지 못하도록(위의 이벤트는 이미 id/해시만 운반함). 테스트는 거부된 로드와 worker 타임아웃이 각각 정확히 하나의 체인 이벤트를 방출함을 단언하고, raw 클레임이 있는 합성 플러그인 이벤트가 `sanitizeAudit`에 의해 제거됨을 단언한다.
100
+
101
+ ### 2.5 실제 환경 검증 종료 기준
102
+
103
+ - **충족됨:** 2026-06-11 실제 자체 호스팅 vLLM + Ollama([[2026-06-11-real-environment-validation]]) + `haechi-dashboard` 관측가능성에 대한 라이브 검증.
104
+ - **잔여 (문서화됨, 1.0 게이팅 아님):** (1) **라이브 KMS 백엔드 검증** (실제 AWS/GCP/Azure/Vault)은 CI 밖; (2) **worker 플러그인 샌드박스 자체는 실제 적대적 플러그인에 대해 미검증** — 보안은 신뢰 게이트 + §6 잔여에 기반하며, fail-closed/데이터 최소화 테스트로 검증됨(적대적 서드파티 플러그인 레드팀이 아닌 — 이상적으로는 child-process+permission 강제와 함께 1.x 과제).
105
+
106
+ ## 3. 명시적 비범위 (1.x로 연기)
107
+
108
+ - 악성 서명된 플러그인에 대한 **Capability *강제*** (fs/net 차단, 크리덴셜 봉쇄) — child-process 격리와 Node 권한 모델이 필요.
109
+ - **Classifier/filter 및 crypto 플러그인 로딩** — 1.0에서는 `authProvider`만.
110
+ - **라이브 revocation 피드 / CRL**, 플러그인 **레지스트리 / 마켓플레이스**, multi-origin, 핫 리로드, **미서명 dev 로더** (신뢰 게이트를 훼손하게 됨 — 개발은 주입을 사용).
111
+ - **Python SDK.**
112
+
113
+ ## 4. 하위 호환 & 1.0 안정성 계약
114
+
115
+ 기존 동작은 **불변** — 모든 provider 계약, config와 (이제 중첩 열거된) 감사 스키마, zero-dependency 자세가 0.9와 정확히 동일하다; 이것들이 **동결로 선언된다**. 플러그인 샌드박스는 **순수 가산적이며 opt-in**이다(`auth.provider: "plugin"`; 기본값은 `none`/`bearer`/`external`로 유지). 하나의 동작적 core 변경은 **가산적 `FORBIDDEN_KEYS` 확장**(§2.4)과 **`schemaVersion`** 필드(가산적)다. **위성 peer 범위 확장(§2.1a)은 전제 조건**으로, 네 위성이 core 1.0.0에 대해 설치를 유지하도록 한다.
116
+
117
+ ## 5. 1.0 관계 / 1.0이 닫는 것
118
+
119
+ 1.0은 두 오랜 1.0 게이트를 닫는다 — **API 안정성 동결**(§2.1)과 **플러그인 샌드박스 + 동적 로딩 스토리**(§2.2–2.4: 비대칭 서명 + 격리 + 감사 + auth 전용) — 그리고 **실제 환경 검증** 종료 기준이 문서화된 잔여와 함께 충족됨을 기록한다(§2.5). Haechi를 개발자 preview에서 안정적인 자체 호스팅 보안 게이트웨이로 졸업시키면서 core 약속을 유지한다: 작고 zero-dependency인 core, 모든 곳에서 fail-closed, "컴포넌트를 교체해도 동일한 보안 테스트가 통과된다."
120
+
121
+ ## 6. 위협 모델 & 리스크 레지스터 델타 (구체적)
122
+
123
+ | 신규 표면 (1.0) | 통제 | 잔여 |
124
+ |---|---|---|
125
+ | **악성/손상된 서명된 플러그인** 동적 로딩 | `entrySha256`+kind+capabilities에 대한 Ed25519 서명, 신뢰 앵커 전용 키 해석, pin + version-floor + revocation denylist, conformance 게이트, worker 메모리/크래시 격리, 전체 라이프사이클 감사 | **서명된 플러그인 자체의 fs/net/`process.env`는 차단되지 않으며, 수신하는 크리덴셜을 유출할 수 있다** — 서명/검증 신뢰 모델에 의해서만 게이트됨; 진정한 강제는 1.x child-process+permission 경로 |
126
+ | **플러그인으로의 PII/비밀 누출** | 크리덴셜 슬라이스만 전달됨(바디/키 절대 아님); JSON-string 와이어; null-proto sanitizer; 호스트가 keyed-HMAC identity 구축 | auth 플러그인이 합법적으로 검증하는 크리덴셜은 그것에 가시적임(위 행 참조) |
127
+ | **경계 간 객체/proto 밀수** | JSON-string 와이어(structured clone / SAB / transferable 없음) + `buildExternalIdentity` 전 null-proto allowlist sanitizer | 실질적 잔여 없음 |
128
+ | **엔트리 교체 / TOCTOU** | `entrySha256` 서명; 인메모리 읽기 + 해시 + 검증 + 인메모리 소스에서 spawn; 경로 재해석 없음; symlink 거부 | 실질적 잔여 없음 |
129
+ | **서명자 키 혼동 / 다운그레이드 / 롤백 / 악성 업데이트** | 신뢰 앵커 전용 해석, 알고리즘 고정, pin/version-floor, revocation | 운영자가 앵커/pin을 큐레이션해야 함 |
130
+ | **플러그인 DoS** | 호출별 `timeoutMs` 종료, 힙 `resourceLimits`, `maxPendingCalls`, `maxMessageBytes`, 단일 점유 worker | 서명된 플러그인이 타임아웃 내 할당된 CPU를 소진할 수 있음(CPU/fd는 1.0에서 제한되지 않음) |
131
+ | **미감사 코드 로드** | `plugin.load.*` / `authenticate.deny` / `worker.terminated` 감사 이벤트; 확장된 `FORBIDDEN_KEYS` | — |
132
+ | **적합성 테스트/프로덕션 분기** | 로드마다 무작위화된 벡터 + 호출별 호스트 PII 안전성 재검증 | 악성 플러그인이 적합성을 통과한 후 오동작 가능(서명+검증으로 커버되며 적합성으로 아님) |
133
+ | **API/감사 스키마 드리프트** | 엄격한 semver + 지원 중단 기간(+ 보안 예외) + 가산적 전용 중첩 열거 감사 스키마 + `schemaVersion` | major bump는 설계상 파괴적 변경 가능(문서화된 마이그레이션) |
134
+
135
+ 제안 리스크 ID: **P1-SEC-024**(동적 플러그인 실행 / 샌드박스 신뢰 모델 — P1-SEC-004의 매니페스트 전용 입장을 새 통제 하에 수퍼세드, 해제됨), **P1-SEC-025**(플러그인 서명/신뢰 앵커/revocation 라이프사이클), **P2-API-001**(안정적 계약 동결 + 지원 중단 정책), **P2-OPS-006**(위성 peer 범위 / major 추적 게이트). 신규 §4 제외: 악성 서명된 플러그인에 대한 capability 강제, 크리덴셜 봉쇄, classifier/crypto 플러그인 로딩, 미서명 dev 로더, 라이브 CRL.
136
+
137
+ ## 7. 테스트 기준 (PR 분해에 매핑)
138
+
139
+ ### 7.1 PR0 — 위성 peer 범위 확장 + preflight 게이트
140
+
141
+ - 네 위성의 `haechi` peer 범위가 `>=0.8.0 <2.0.0`으로 확장됨(그리고 auth-oidc의 `haechi-auth-jwt`는 `<2.0.0`); lockfile 재생성; `release:preflight`가 위성의 범위가 `!semver.satisfies(coreVersionToPublish, satelliteRange)`이면 실패. 테스트가 core `1.0.0`을 시뮬레이션하고 모든 위성 범위가 충족됨을 단언.
142
+
143
+ ### 7.2 PR1 — API 안정성 동결 (문서 + 계약 테스트)
144
+
145
+ - `api-stability.md`(+ko)가 IN/OUT 테이블, 엄격한 semver + 지원 중단 정책(`HAECHI_DEPRECATION_*` 런타임 경고 계약 및 보안 예외 포함), 위성 major 추적 규칙을 담음.
146
+ - **계약/스냅샷 테스트**가 서브패스별 동결된 export + **non-null `identity`와 하나의 `detections[]` 항목을 포함하는 전체 감사 이벤트**(중첩 하위 스키마가 최상위 레벨만이 아닌 것으로 보호됨) + config 스키마 키 세트 + `schemaVersion`을 pin함. 가산적 필드는 통과; 제거/이름 변경된 필드(최상위 또는 중첩)는 실패. `verifyAuditChain`이 동결 스키마 픽스처를 검증하고 합성 가산 필드가 있어도 여전히 검증함.
147
+
148
+ ### 7.3 PR2 — Ed25519 서명 플러그인 계약 + 핀닝/revocation + 적합성 하네스
149
+
150
+ - `packages/plugin`이 Ed25519 봉투와 함께 `worker-isolated`+`authProvider` 매니페스트를 수락; **거부**(각각 `plugin.load.refused{reason}`를 방출하는 별개의 fail-closed 테스트): 누락/무효 서명; `trustAnchors`에 없는 서명자(kid-not-allowlisted, **검증 전**에 해석됨); **서명 후 엔트리 바이트 변조, 경로 변경 없음**; revoked 서명자 / revoked entryHash; version-floor 미달; pin 불일치; `notBefore/notAfter` 외부; capability allowlist에 없음; alg ≠ Ed25519.
151
+ - `assertAuthProviderConformance` 존재; 참조 provider 통과; 깨진 것(throw / raw-subject identity 반환 / 만료 크리덴셜 수락 / 비결정론적)이 각 경우마다 **실패**(네거티브 테스트). 벡터는 실행마다 무작위화됨.
152
+ - `FORBIDDEN_KEYS` 확장 테스트: `claims`/`credential`/`signature`가 있는 합성 플러그인 이벤트가 `sanitizeAudit`에 의해 제거됨; 체인이 유효한 상태로 유지됨.
153
+
154
+ ### 7.4 PR3 — `worker-isolated` authProvider 샌드박스
155
+
156
+ - 참조 **서명된** auth 플러그인이 로드되어 worker 내에서 적합성 통과, 유효 bearer/JWT를 **호스트가 구축한 PII-safe identity**로 인증함; 단언: worker는 **크리덴셜 슬라이스만** 수신함(계측된 echo-plugin이 바디/감사 싱크/토큰 볼트/키를 절대 받지 못했음을 증명), raw subject가 감사에 나타나지 않음, `plugin.load.accepted`가 해석된 `entrySha256`/`signerKeyId`와 함께 방출됨.
157
+ - **Fail-closed + 격리 매트릭스:** 미서명/잘못된 서명자/변조/revoked/pin 불일치/capability-not-allowlisted → 생성 throw + `load.refused`; **타임아웃 → `null` + worker 종료 + `worker.terminated{timeout}`**; throw → `null`; `__proto__`/추가 키가 있는 클레임 객체 → sanitize됨(prototype 오염 없음, 추가 키 제거됨) 및 PII-safe; 별개의 correlation id를 가진 두 동시 호출이 절대 응답을 교차하지 않음; 하나의 호출 종료가 형제를 죽이지 않음(단일 점유); `maxPendingCalls`/`maxMessageBytes` 강제됨; `plugins.enabled:false`(kill-switch)가 로드를 거부함.
158
+ - `normalizeConfig` `auth.provider:"plugin"` 열거형 fail-closed 테스트(각 잘못된 옵션이 throw); `createRuntime` + proxy auth 게이트를 통한 end-to-end(요청이 플러그인을 통해 인증됨; identity keyed-HMAC; 감사에 raw subject/크리덴셜 없음).
159
+
160
+ ### 7.5 전체
161
+
162
+ - Core가 zero runtime dependency를 유지함(`node:`만 — Ed25519는 `node:crypto`); `check:packaging` + `check:satellite-packaging` 통과; 동결된 계약 스냅샷 테스트 + peer 범위 preflight 게이트가 미래 PR을 보호함.
163
+
164
+ ## 8. 제안 PR 분해 (스택)
165
+
166
+ 1. **PR0 — 위성 peer 범위 확장 + preflight 게이트** (전제 조건; 네 위성 패치 릴리스). → §7.1
167
+ 2. **API 동결** — `api-stability.md`(+ko) IN/OUT 테이블 + 지원 중단/보안 예외 정책 + 중첩 스키마 계약/스냅샷 테스트 + `schemaVersion`. → §7.2
168
+ 3. **Ed25519 서명 플러그인 계약 + 적합성** — 비대칭 프리미티브(`node:crypto`), 서명 봉투(entryHash/kind/capabilities/기간), 신뢰 앵커 전용 해석, pin/version-floor/revocation, `assertAuthProviderConformance`, `FORBIDDEN_KEYS` 확장. → §7.3
169
+ 4. **Worker 격리 authProvider 샌드박스** — `createSandboxedAuthProvider`(인메모리 검증 spawn, JSON-string 와이어, null-proto sanitizer, correlation-id 단일 점유 동시성, 타임아웃/종료, kill-switch), `auth.provider:"plugin"` config 분기 + 라이프사이클 감사, 참조 서명된 플러그인 + §7.4 매트릭스. → §7.4
170
+ 5. **1.0.0 릴리스 컷** — core를 **1.0.0**으로 bump; docs EN/KO (이 범위 문서, 위협 모델 + §6 ID + 목표 버전 bump와 함께 리스크 레지스터 델타, 실제 환경 종료 기준 + 잔여); wiki ingest(`[[plugin-sandbox]]` 페이지 + `[[packaging-and-distribution]]`/`[[identity-and-auth]]`/`release-roadmap` 업데이트); README "Current Scope". Core는 `v*` 태그를 재사용; 첫 번째 안정 `haechi@1.0.0`이 증명(attested)과 함께 발행됨. (PR0이 이미 머지되고 위성이 재발행되어 1.0.0에 대해 설치 가능해야 함.)