docs(sdd): credential-primitives-wasm 封存進 archive(T10 完成,卷已結案)

leo 2026-07-21 明令封存:卷已完成,留主目錄會讓未來 session 誤以為進行中。
最後一項 T10(廢除自管加密金鑰)已於 20c7610 完成(移除約 2500 行)。

- git mv 整卷 → system-dev/docs/3-specs/archive/credential-primitives-wasm/
  design.md status: closed;superseded_by 留空(據實:非被另一卷取代,是機制整個換掉)
- 卷首補「封存時仍未完成的項目」——逐條查 code 核實,不當作完成:
  真缺口=auth_mtls 從未實作、7.6 self-hosted auth 鏈端到端從未驗;
  另有勾選過期(auth_oauth2 其實已完成)與驗收條件已作廢(與現行 rule 07 牴觸)者
- 修好 14 處引用(原盤點 10 處,實際更多):session-start-load-sdd.sh 內容嚴重過期
  (把已完成 Phase 寫成進行中)→ 改為以 frontmatter 為判準;四份 rules、CLAUDE.md、
  BACKLOG、3-specs/README、deploy.ts、credentials.ts、wrangler.toml 逐一按性質處理
- 額外:system-dev/docs/2-architecture/ 有四個規則檔重複副本(06-08 遷移遺留)
  → 同步修好,否則留一份過期真相(今日第三次撞到「同一資訊兩份副本」的債)
- 02-forbidden.md §2.1 列的三個「待刪違規 TS」其實早已不存在
  → 改標刪除線+禁止重新引入(過期文件同時製造假待辦與假進行中)

驗證:sdd-active-check exit 0(active 仍恰一份=portal-auth);
session-start-load-sdd.sh exit 0;cypher-executor 與 cli typecheck 全綠;
測試 187/188(唯一 fail 為 pre-existing,stash 覆驗相同)。

未動:pre-write-guard.sh 的 KNOWN_SDDS 白名單不含 archive 路徑
(放寬 guardrail 需明確授權,留待決定)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 01:57:34 +08:00
parent df1b953d1f
commit 8d8b01d240
21 changed files with 241 additions and 161 deletions
@@ -0,0 +1,272 @@
---
status: closed
superseded_by: ""
---
# Design Document: Credential Primitives TS → WASM 改寫
> 📦 **本卷已封存(2026-07-20 收卷,2026-07-21 移入 `3-specs/archive/`**
>
> **完成狀態**:主線目標「cypher-executor TS 不再實作 credential/auth 業務邏輯」已達成——
> `credential-injector.ts`、`jwt-signer.ts`、`BUILTIN_API_RECIPES` 全數移除,auth 走
> `auth_static_key` / `auth_service_account` WASM 零件。最後一項 T10(廢除自管加密金鑰)
> 於 2026-07-20 完成(commit `20c7610`)。
>
> **被什麼取代**:不是被另一份 SDD supersede(故 `superseded_by` 留空)。credential 的**現行做法**
> CF Workers per-script Secrets(密文)+ D1 目錄(metadata/secret_ref),arcrun 不自管任何
> 加密金鑰;`crypto_decrypt` host function 已成永遠回失敗的 stub。現行規範見
> `.claude/rules/01-tech-stack.md`「Credential 儲存規範」,遷移過程記錄見本目錄
> `credential-store-migration.md`。
>
> ⚠️ **封存時仍未完成的項目**(未隨主線收尾,見 tasks.md 卷首清單):Phase 4`auth_oauth2`
> / `auth_mtls` 兩個 primitive 從未實作)、Phase 5 封測門檻逐條複查、以及數項端到端測試。
> 若日後要做 OAuth2 / mTLS 認證,**需另立新 SDD**,不是恢復本卷。
>
> ⚠️ **本檔的 credential 儲存/解密段落已過時**`crypto_decrypt` 現為永遠回失敗的 stub
> credential 值改由 CF Workers Secrets 託管、經 `secret_get(ref)` 取用)。遷移已於
> 2026-07-20 完成,見 `credential-store-migration.md`;現行做法見
> `.claude/rules/01-tech-stack.md`「Credential 儲存規範」。以下解密相關敘述僅供考古。
## Overview
`cypher-executor` 中以 TypeScript 實作的 credential 注入邏輯,改寫為 4 個獨立的 WASM 零件。這是 `credential_parts.md` 長期規格的實現,不再是「未來 Phase」。
**動機**:TS 實作無法在地端(workerd)和邊緣端(Wazero)執行。WASM 零件跨 runtime 可攜,符合 u6u 三層部署架構。
**嚴格規範(richblack 2026-04-19 確認)**cypher-executor TS **完全不實作**任何 credential / auth / template / JWT / 解密邏輯。所有業務邏輯必須在 TinyGo WASM 零件內。TS 僅負責 HTTP routing + 呼叫 WASM + host function 提供 runtime primitivecrypto.subtle / KV / fetch)。
---
## 現有 TS 實作(要刪除的)
| 檔案 | 功能 | 對應 WASM Primitive |
|------|------|---------------------|
| `credential-injector.ts``injectFromAuthRecipe()` | static_key template 展開 | `auth_static_key` |
| `credential-injector.ts` — service_account 分支 | JWT signing + token exchange | `auth_service_account` |
| `credential-injector.ts``decryptCredential()` | AES-GCM 解密 | host function(所有 primitive 共用) |
| `credential-injector.ts``interpolateTemplate()` | `{{secret.KEY}}` 替換 | 內建在各 primitive |
| `jwt-signer.ts``exchangeGoogleJwt()` | PEM→PKCS8→RS256→token | `auth_service_account` |
| `component-loader.ts` — BUILTIN_API_RECIPES | gmail/telegram/line/gsheets 寫死邏輯 | 刪除,改用 auth recipe + `http_request` 零件 |
| `credential-injector.ts` — BUILTIN_CREDENTIALS_MAP | 舊路徑 flat injection | 刪除,統一走 auth recipe |
| `arcrun/credentials/` | 重複的 credentials Worker | 刪除,路由已在 cypher-executor |
---
## 4 個 WASM Primitive 設計
### 統一 I/O 介面(stdin/stdout JSON
```
stdinWorker → WASM):
{
"action": "authenticate" | "needs_refresh" | "refresh" | "test",
"api_key": "ak_xxx", // 租戶識別,用來組 KV key
"service": "openai", // 對應 auth_recipe:{service}
"request": { "method": "GET", "url": "/path", "headers": {}, "body": null }
}
WASM 內部流程:
1. recipeJSON = kv_get("auth_recipe:" + service)
2. 依 recipe.required_secrets 逐一 kv_get("{api_key}:cred:{name}") → {encrypted, iv}
3. secrets[name] = crypto_decrypt(encrypted, iv)
4. service_accountcrypto_sign_rs256(jwt, pkcs8) + http_request 換 token
5. 展開 recipe.inject 的 {{secret.X}} / {{runtime.X}} 模板
stdoutWASM → Worker):
{
"success": true,
"auth_headers": { "Authorization": "Bearer xxx" },
"auth_query": {},
"auth_body": {},
"runtime": { ... updated runtime state,供下次 refresh 用 }
}
```
### auth_static_key
**位置**`arcrun/registry/components/auth_static_key/`
**語言**TinyGo 或 AssemblyScript
功能:
1. 讀取 `recipe.inject.header/query/body` 模板
2.`secrets` 展開 `{{secret.KEY}}` 模板
3. 回傳 `auth_headers` / `auth_query` / `auth_body`
涵蓋:~80% 服務(Bearer token, API Key, Basic Auth, custom header
### auth_service_account
**位置**`arcrun/registry/components/auth_service_account/`
**語言**TinyGo 或 AssemblyScript
功能:
1.`secrets.service_account_json` 解析 private key
2. JWT signingRS256PEM→PKCS8→sign
3. POST token exchange endpoint → 取得 access_token
4. 展開 `{{runtime.access_token}}` 模板
**crypto 考量**
- TinyGo 的 `crypto/rsa` + `crypto/x509` 支援有限
- 若 TinyGo 不支援 RS256:使用 host function 讓 Worker 的 `crypto.subtle` 代簽
- 或改用 AssemblyScript(有 as-crypto 套件)
### auth_oauth2(新建)
**位置**`arcrun/registry/components/auth_oauth2/`
功能:
1. `needs_refresh`:檢查 `runtime.expires_at` 是否過期
2. `refresh`:用 `runtime.refresh_token` + `secrets.client_secret` 換新 token
3. `authenticate`:展開 `{{runtime.access_token}}` 到 headers
### auth_mtls(新建)
**位置**`arcrun/registry/components/auth_mtls/`
功能:
1.`secrets` 讀取 client cert + key
2. 回傳 TLS 設定(由 Worker runtime 執行實際 mTLS handshake
---
## cypher-executor 改動
### 保留(TS routing 層)
- `routes/credentials.ts` — HTTP CRUD for credentials(接收加密的 payload
- `routes/recipes.ts` — HTTP CRUD for auth recipes
- `routes/auth.ts` — OAuth flow routing
- `graph-executor.ts` — workflow 執行排程
- `lib/wasi-shim.ts` — WASM runtime + host functions(加解密 / KV / 簽章 / HTTP 實際由 `crypto.subtle` / env binding / fetch 執行,但**呼叫時機由 WASM 決定**)
### 修改
- `actions/credential-injector.ts`**整檔刪除**,改為新檔 `actions/auth-dispatcher.ts`(約 30 行):
1.`resolveAuthRecipe(componentId)` 取得 `primitive` 名稱(static_key / service_account / oauth2 / mtls
2. 呼叫對應的 auth primitive Worker(見下「呼叫方式」)
3.`{ action, api_key, service, request }`**不送 secrets、不送 recipe plaintext**
4. WASM 透過 host function 自行 `kv_get` 讀 recipe + 加密 secret`crypto_decrypt` 解密
5. 讀回傳 → 合併 `_auth_headers` / `_auth_query` / `_auth_body` 進 ctx
**呼叫方式(Phase 72026-06-06 演化)**:原設計走 `fetch(wasmWorkerUrl(auth_{primitive}, WORKER_SUBDOMAIN))`workers.dev 公網 URL)。但壓測階段 11 證實:**self-hosted** cypher`arcrun-cypher-executor.{sub}.workers.dev`)用 `fetch()` 打**同 `{sub}.workers.dev` zone** 的 auth workerCF 回 **1042**(官方 docs:「fetch from another Worker on the **same zone**」)。官方不踩是因官方 cypher 在自訂域 `cypher.arcrun.dev`、打 `*.workers.dev` 屬**跨 zone**(非官方有 flag)。token/解密鏈本身正常,唯一卡點是同 zone fetch。
- **解法 = `global_fetch_strictly_public` compatibility flag**cypher wrangler.toml),auth-dispatcher **維持原本 `fetch(workers.dev)` 不改**。此 flag 讓 `fetch()` 走公網「前門」→ same-zone fetch 也通(官方 docs:「Worker-to-Worker fetch 可用 service binding **或** `global_fetch_strictly_public` flag」)。
- **為何選 flag 不選 service binding**service binding 靜態、加/改要重 deploy cypher(官方 docs);flag 一行解、用戶無感、不動 service binding 禁令。先做了 service bindingA)後 richblack 拍板廢、改 flagB)。
- **flag 安全(查證官方 docs**:唯一副作用「Worker fetch 自己 hostname → self-loop」;cypher 只打外部 API + sibling auth worker(皆非自己 hostname)→ 不 self-loop。
- **官方/self-host 共用同一份 toml**:官方 cypher 本就跨 zonecypher.arcrun.dev → workers.dev),加 flag 行為不變;self-host 同 zone 被修好。`stripOfficialOnlyBindings()` 不碰 `compatibility_flags`self-hosted 部署自動帶上。
- `lib/component-loader.ts`**刪除 `BUILTIN_API_RECIPES`**(含 http_request / gmail / telegram / line_notify / google_sheets 的 TS 實作),全部改走 WASM runner。每個 `.wasm` 零件都已編譯並以獨立 Worker 部署(`{canonical-id-kebab}.arcrun.dev`)。loader 新增的「WASM runner」路徑就是「canonical_id → HTTP URL 查表後 fetch」,**不做** WASM instantiate。
- **R2 動態注入 WASM 路徑作廢**richblack 2026-04-19 確認:CF workerd 無法以 R2 物件臨時 instantiate WASM)。用戶自製零件(Phase 5)同樣走「產生獨立 Worker」流程,不從 R2 讀。
### 刪除
- `lib/jwt-signer.ts` — 整檔刪除,RS256 簽章移入 `auth_service_account` WASM(透過 host function `crypto_sign_rs256`
- `credential-injector.ts` 整檔刪除(見上)
- `component-loader.ts``BUILTIN_API_RECIPES` 整段刪除
- `BUILTIN_CREDENTIALS_MAP` 已在 `credential-injector.ts` 內,隨檔一併刪
---
## Host FunctionsWASM ↔ Worker 的橋接)
auth primitive WASM 需要呼叫外部能力時,透過 host function。全部放 `u6u` namespace。**錯誤回傳非零 uint32;成功 = 0 且把結果寫入 `outPtr` 指向的 buffer**。
| Host Function | TinyGo 簽章 | 用途 |
|---|---|---|
| `http_request` | `(urlPtr/Len, methodPtr/Len, headersPtr/Len, bodyPtr/Len, outPtr, outLenPtr) uint32` | HTTP 請求(已實作) |
| `kv_get` | `(keyPtr, keyLen, outPtr, outLenPtr) uint32` | 讀 KV。Worker 依 key 前綴路由到 `CREDENTIALS_KV` / `RECIPES` |
| `crypto_decrypt` | `(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) uint32` | AES-GCM 解密。encryption key 由 Worker 從 `env.ENCRYPTION_KEY` 內部讀取,**永遠不暴露給 WASM** |
| `crypto_sign_rs256` | `(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) uint32` | Worker 用 `crypto.subtle.sign('RSASSA-PKCS1-v1_5' + SHA-256)`private key 以 PKCS8 bytes 傳入 |
這些 host function 在 `lib/wasi-shim.ts` 中以 WASI import 提供。
### 安全邊界
- `ENCRYPTION_KEY` 只在 `crypto_decrypt` host function 內部使用,**絕不**經 stdin / 回傳值 / 任何路徑傳給 WASM
- `api_key` 經 stdin 傳入 WASM(讓 WASM 自己組 `{api_key}:cred:{name}` KV key
- `kv_get` 在 Worker 側檢查 key 前綴:
- `auth_recipe:*` → 讀 `RECIPES`
- `{api_key}:cred:*` → 讀 `CREDENTIALS_KV`,且 `{api_key}` 必須等於 stdin 傳入的 api_key(防越權)
- 其他前綴 → 回傳錯誤
---
## 關於解密位置
採用**方案 B(唯一方案)**WASM 透過 host function `crypto_decrypt()` 自行解密。
- cypher-executor TS 完全不解密、不知道 plaintext
- `ENCRYPTION_KEY` 永遠留在 Worker host function 內
- WASM 知道要解哪份 ciphertext(經 `kv_get` 讀到的 `{encrypted, iv}`),但拿不到 encryption key
- 這樣 TS 層完全沒有零件業務邏輯,符合 CLAUDE.md §禁止行為 1/6
(歷史註記:曾規劃方案 A「TS 先解密再送 stdin」,已廢棄 — 違反「TS 不得實作零件邏輯」。)
---
## §8 `{{credential.X}}` 用戶面注入語法(2026-06-10richblack 確認 change
### 問題(壓測實證的 401 根因)
Haiku 自主壓測時自然地在 workflow node.data 寫 `{{credential.openai_key}}` 引用已存的 credential
結果打目標 API 回 **401**;只有把 token **明文硬編碼**進 workflow 才 200。
**根因(系統客觀證據,三條 template 展開路徑都不認 `credential.` namespace**
1. `graph-executor.ts` `interpolateString()`node.data 展開)只認「context 內已存在的 key path」
`{{input.X}}` / `{{data.Y}}` / 任意 dot path),**無 `credential` namespace** → `{{credential.X}}`
展不開 → header 帶字面值 `Bearer {{credential.X}}` 或空 → 401。
2. auth recipe `inject` + auth primitive WASM 只認 `{{secret.X}}` / `{{runtime.X}}`,那條是 auth recipe
**自動注入**路徑(recipe 宣告 `auth_service` 才觸發),不是用戶在 node.data 直接引用的入口。
**正解(auth recipe 自動注入)其實存在,但用戶不知道、直覺會寫 `{{credential.X}}`** → 這是
**設計缺口**(缺一個直覺的用戶面 credential 引用入口),不是單純 bug。
### 決策:讓 `{{credential.X}}` 真的能用(richblack 2026-06-10
新增「用戶在 workflow node.data 直接寫 `{{credential.NAME}}` → 自動解密回填」這條入口。
**硬約束(rule 02 §2.2**:解密**絕不能**在 graph-executor TS 做(hook 擋 `crypto.subtle.decrypt`
且違反「TS 不實作 credential 邏輯」)。故 `{{credential.X}}` 的解密**復用既有 auth_static_key WASM**。
### 設計:auth_static_key 新增 `resolve_credentials` action
| 角色 | 職責 |
|------|------|
| graph-executor.ts(薄 routing | 偵測 node.data 內 `{{credential.NAME}}` → 收集 names → 呼叫 auth_static_key WASM `resolve_credentials` → 拿回明文 → 回填 node.data。**不解密、不碰 ENCRYPTION_KEY**。 |
| auth_static_key WASM(新 action | 收 `{action:"resolve_credentials", api_key, names:[...]}` → 對每個 name `kv_get("{api_key}:cred:{name}")` + `crypto_decrypt` → 回 `{success, credentials:{name: plaintext}}`。**不查 recipe**(與 authenticate 分流)。 |
**stdin/stdoutresolve_credentials**
```
stdin: { "action": "resolve_credentials", "api_key": "ak_xxx", "names": ["openai_key", "..."] }
stdout: { "success": true, "credentials": { "openai_key": "sk-...", ... } }
缺某 name → success:false + error 指明缺哪個(誠實,不假綠)
```
**展開時機(graph-executor**:在 node.data interpolate(現有 `interpolateData`**之後**、
node 執行**之前**插一步:掃描展開結果裡殘留的 `{{credential.NAME}}` → 走 WASM resolve → 回填。
放在 auth recipe 自動注入(`tryAuthDispatch`)**之外**,兩條入口並存但不重疊(一個是用戶顯式引用、
一個是 recipe 宣告 `auth_service` 隱式注入)。
**安全邊界(同 authenticate**
- `crypto_decrypt` host function 內部用 `ENCRYPTION_KEY`,**永不暴露給 WASM / 不經 graph-executor**。
- `kv_get` host 側檢查 `{api_key}:cred:*` 的 api_key 必須等於 stdin api_key(防越權,現有機制)。
- graph-executor 只傳 `names`(明文 credential 名)+ api_key**不傳、不見** ciphertext / plaintext key。
### 為何不選「新建 credential_resolve 零件」
auth_static_key 已具備 `kvGet` + `cryptoDecrypt` 全部能力,新建零件 = 功能重疊 + 多一個 Worker
部署成本。加一個 action 最貼近現有架構(richblack 2026-06-10 拍板)。
### 不做
- ❌ 不在 graph-executor 解密(rule 02 §2.2
- ❌ 不改 credential KV key 格式(仍 `{api_key}:cred:{name}`
- ❌ 不碰 auth recipe 自動注入路徑(`tryAuthDispatch` / `{{secret.X}}` 不變)
---
## 不做的事
- ❌ 不改 recipe YAML schema — 沿用現有格式
- ❌ 不改 KV 儲存結構 — `auth_recipe:{service}` / `{api_key}:cred:{name}` 不變
- ❌ 不改 SDK API — SDK 仍是 HTTP thin wrapper
- ❌ 不建新的 Worker — 在 cypher-executor 內完成(§8 復用 auth_static_key,不新建零件)