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>
16 KiB
status, superseded_by
| status | superseded_by |
|---|---|
| closed |
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_accountWASM 零件。最後一項 T10(廢除自管加密金鑰) 於 2026-07-20 完成(commit20c7610)。被什麼取代:不是被另一份 SDD supersede(故
superseded_by留空)。credential 的現行做法 = CF Workers per-script Secrets(密文)+ D1 目錄(metadata/secret_ref),arcrun 不自管任何 加密金鑰;crypto_decrypthost 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 primitive(crypto.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)
stdin(Worker → 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_account)crypto_sign_rs256(jwt, pkcs8) + http_request 換 token
5. 展開 recipe.inject 的 {{secret.X}} / {{runtime.X}} 模板
stdout(WASM → 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
功能:
- 讀取
recipe.inject.header/query/body模板 - 用
secrets展開{{secret.KEY}}模板 - 回傳
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
功能:
- 從
secrets.service_account_json解析 private key - JWT signing(RS256:PEM→PKCS8→sign)
- POST token exchange endpoint → 取得 access_token
- 展開
{{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/
功能:
needs_refresh:檢查runtime.expires_at是否過期refresh:用runtime.refresh_token+secrets.client_secret換新 tokenauthenticate:展開{{runtime.access_token}}到 headers
auth_mtls(新建)
位置:arcrun/registry/components/auth_mtls/
功能:
- 從
secrets讀取 client cert + key - 回傳 TLS 設定(由 Worker runtime 執行實際 mTLS handshake)
cypher-executor 改動
保留(TS routing 層)
routes/credentials.ts— HTTP CRUD for credentials(接收加密的 payload)routes/recipes.ts— HTTP CRUD for auth recipesroutes/auth.ts— OAuth flow routinggraph-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 行):- 查
resolveAuthRecipe(componentId)取得primitive名稱(static_key / service_account / oauth2 / mtls) - 呼叫對應的 auth primitive Worker(見下「呼叫方式」)
- 送
{ action, api_key, service, request }(不送 secrets、不送 recipe plaintext) - WASM 透過 host function 自行
kv_get讀 recipe + 加密 secret,crypto_decrypt解密 - 讀回傳 → 合併
_auth_headers/_auth_query/_auth_body進 ctx
呼叫方式(Phase 7,2026-06-06 演化):原設計走
fetch(wasmWorkerUrl(auth_{primitive}, WORKER_SUBDOMAIN))(workers.dev 公網 URL)。但壓測階段 11 證實:self-hosted cypher(arcrun-cypher-executor.{sub}.workers.dev)用fetch()打同{sub}.workers.devzone 的 auth worker,CF 回 1042(官方 docs:「fetch from another Worker on the same zone」)。官方不踩是因官方 cypher 在自訂域cypher.arcrun.dev、打*.workers.dev屬跨 zone(非官方有 flag)。token/解密鏈本身正常,唯一卡點是同 zone fetch。- 解法 =
global_fetch_strictly_publiccompatibility flag(cypher wrangler.toml),auth-dispatcher 維持原本fetch(workers.dev)不改。此 flag 讓fetch()走公網「前門」→ same-zone fetch 也通(官方 docs:「Worker-to-Worker fetch 可用 service binding 或global_fetch_strictly_publicflag」)。 - 為何選 flag 不選 service binding:service binding 靜態、加/改要重 deploy cypher(官方 docs);flag 一行解、用戶無感、不動 service binding 禁令。先做了 service binding(A)後 richblack 拍板廢、改 flag(B)。
- flag 安全(查證官方 docs):唯一副作用「Worker fetch 自己 hostname → self-loop」;cypher 只打外部 API + sibling auth worker(皆非自己 hostname)→ 不 self-loop。
- 官方/self-host 共用同一份 toml:官方 cypher 本就跨 zone(cypher.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_accountWASM(透過 host functioncrypto_sign_rs256)credential-injector.ts整檔刪除(見上)component-loader.ts的BUILTIN_API_RECIPES整段刪除BUILTIN_CREDENTIALS_MAP已在credential-injector.ts內,隨檔一併刪
Host Functions(WASM ↔ 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_decrypthost function 內部使用,絕不經 stdin / 回傳值 / 任何路徑傳給 WASMapi_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-10,richblack 確認 change)
問題(壓測實證的 401 根因)
Haiku 自主壓測時自然地在 workflow node.data 寫 {{credential.openai_key}} 引用已存的 credential,
結果打目標 API 回 401;只有把 token 明文硬編碼進 workflow 才 200。
根因(系統客觀證據,三條 template 展開路徑都不認 credential. namespace):
graph-executor.tsinterpolateString()(node.data 展開)只認「context 內已存在的 key path」 ({{input.X}}/{{data.Y}}/ 任意 dot path),無credentialnamespace →{{credential.X}}展不開 → header 帶字面值Bearer {{credential.X}}或空 → 401。- 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/stdout(resolve_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_decrypthost function 內部用ENCRYPTION_KEY,永不暴露給 WASM / 不經 graph-executor。kv_gethost 側檢查{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,不新建零件)