Files
Arcrun/system-dev/docs/3-specs/archive/credential-primitives-wasm/design.md
T
Leo 8d8b01d240 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>
2026-07-21 01:57:34 +08:00

16 KiB
Raw Blame History

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.tsjwt-signer.tsBUILTIN_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 4auth_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.tsinjectFromAuthRecipe() static_key template 展開 auth_static_key
credential-injector.ts — service_account 分支 JWT signing + token exchange auth_service_account
credential-injector.tsdecryptCredential() AES-GCM 解密 host function(所有 primitive 共用)
credential-injector.tsinterpolateTemplate() {{secret.KEY}} 替換 內建在各 primitive
jwt-signer.tsexchangeGoogleJwt() 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 + 加密 secretcrypto_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 cypherarcrun-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 flagcypher 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 bindingservice 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_flagsself-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.tsBUILTIN_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,不新建零件)