Files
Arcrun/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md
T
uncle6me-web 5d00e71275 chore: D22 落地——docs/SDD/wiki/CLAUDE.md 進 repo(Gitea private 預設全 push)
頂層 D22 決策(leo 2026-07-03 拍板):推什麼由開發環境歸屬決定,
Gitea private=除機敏值/build 產物/.github 外全 push。
解 T1.5 卡點:雲端工人 clone 拿得到 credential-store-migration.md,可就地改寫 SDD。
機敏掃描兩輪通過(新增 189 檔約 2.1MB,node_modules/dist/wasm 照舊排除)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:13:33 +08:00

14 KiB
Raw Blame History

Credential Store 遷移 SDD — KV → D1(目錄)+ Cloudflare Secrets Store(密文)

建立:2026-06-29 by arcrun CC|對應 issueArcrun#13(優先序 3)|決策:leo 2026-06-29 拍板(D19 範圍宣告:本檔是既有 SDD credential-primitives-wasm/ 的補充設計(rule 02 §4.3 例外:現有 SDD 目錄內新增單檔)。不施工,先 SDD,總管審對齊後放行。 取代關係:本檔的儲存決策取代 credential-store-redesign.md §4.5 的「維持 KV」推薦——leo 看完該推薦後拍板走 D1+Secrets StoreD19),故 Phase B 翻回「搬」。redesign.md 的 Atelegram 一致性,已做)/ C(友善前門)/ Q1(acr parts)不受影響。 詞彙:component=TinyGo WASMrecipe=http_request+固定設定|workflow=多步 YAMLauth-recipe=credential 怎麼注入。


0. leo 的最終決策(D19,一句話)

「擁有目錄,不擁有內容物」arcrun 持有 credential 的目錄(看得到存了哪些、最後何時用),但不持有、也讀不回密文明文——連 owner 自己的界面都讀不到值。

動機(leo 親述):用戶 API key 外泄被盜刷的經濟損失 + 商譽風險(「就算不是我們過失也會怪在我們身上」)。我們不持有明文、不持有金鑰 = 不背這個鍋。

密文值(token / SA JSON / private key  →  Cloudflare Secrets StoreCF 託管金鑰,arcrun 拿不到明文)
credential 目錄(name/service/metadata →  使用者自己的 D1(不含密文,只含指向 Secrets Store 的 secret_ref
舊自管 ENCRYPTION_KEY + 密文存 KV         →  廢掉(回填完成後);KV 降為暫存/熱讀快取,非真相源
界面 acr creds list                      →  讀 D1(顯示清單 + last_used),看得到但讀不回值

n8n credentials UXD19 定案)

  • 界面顯示很多 credentials(名字 / 服務 / metadata / last_used)。
  • 不能 read 既有值、不能 edit 既有值(連 owner 都不行)。只能:
    • 整筆 replace(重貼新值覆蓋舊的)
    • delete
  • read 不回值「不是限制、是設計」——值在 Secrets Storearcrun 拿不到。

1. 現狀(已核實 code2026-06-29

項目 現狀 檔案
密文儲存 CREDENTIALS_KVkey={api_key}:cred:{name},值={encrypted, iv}AES-GCM cypher-executor/src/routes/credentials.ts:48
加密金鑰 自管全域 ENCRYPTION_KEYclient 端加密、WASM crypto_decrypt 解密) rule 01 加解密;wasi-shim.ts
POST /credentials { name, encrypted, iv } credentials.ts:24-53
讀+解密 auth_static_key WASM kv_getcrypto_decrypt registry/components/auth_static_key/main.go:146
列表 GET /credentialsKV.list({prefix}) 列名(不含值) credentials.ts:68-78
D1 全 repo 無 credential D1D1 只有 KBDB graph kbdb/wrangler.toml:10

風險點(leo 要廢的):自管單一 ENCRYPTION_KEY → 漏一把,全部 credential 裸。


2. 目標架構(D19

2.1 三方職責

元件 存什麼 誰拿得到明文
Cloudflare Secrets Store 密文值本體(token/SA JSON/private key 只有 CF runtime 在執行期注入時。arcrun TS/WASM/owner 界面都拿不回
使用者的 D1(與 KBDB 共用那顆 arcrun-kbdb credential 目錄api_key / name / service / sensitivity / created_at / last_used_at / secret_ref無密文 全員可讀目錄(非機密),無人從這拿到值
CREDENTIALS_KV 過渡期:舊自管密文;遷移後:降為可選熱讀快取或廢用 (遷移後不再是真相源)

2.2 D1 schema(明確不含密文欄)

-- migrations/0002_credentials.sqlkbdb 同一顆 D1IF NOT EXISTS 冪等)
CREATE TABLE IF NOT EXISTS credentials (
  api_key      TEXT NOT NULL,           -- 租戶
  name         TEXT NOT NULL,           -- credential 名(= auth-recipe required_secrets[].key,如 telegram_bot_token
  service      TEXT,                    -- 對應 servicetelegram / notion …),可空
  sensitivity  TEXT NOT NULL DEFAULT 'standard',  -- 'standard' | 'high'high = SA JSON/private key
  secret_ref   TEXT NOT NULL,           -- 指向 Secrets Store 的 secret 名(不是密文本體)
  created_at   INTEGER NOT NULL,
  last_used_at INTEGER,                 -- 執行注入時更新(治理面 last_used 顯示用)
  PRIMARY KEY (api_key, name)
);
CREATE INDEX IF NOT EXISTS idx_cred_apikey ON credentials(api_key);

⚠️ 「name 是名字標籤不是密文」leo 防誤解):name/secret_ref 都是字串標籤,密文值永不進 D1

2.3 Secrets Store 整合(密文存 store、D1 存 ref

已核實 CF Secrets Store 形態(wrangler 文件):

  • 管理 CLIwrangler secrets-store store create/list/deletewrangler secrets-store secret put/get/list/delete <STORE_ID> <name>
  • Worker bindingwrangler.jsonc):
    "secrets_store_secrets": [
      { "binding": "CRED_STORE", "store_id": "<STORE_ID>", "secret_name": "<name>" }
    ]
    
  • 執行期:await env.CRED_STORE.get() 回密文值。

⚠️ 設計約束(必須在施工前對齊,本 SDD 標為待決) 上述 binding 形態要求 secret_name 在 wrangler config 部署時靜態宣告——這不適合「每個用戶任意數量的動態 credential」(不可能為每個 token 改 toml 重部署)。三條候選路徑,請 leo/總管拍板選一

  1. Secrets Store Account REST API(動態 by ref,推薦)worker 執行期用 CF APIaccounts/{id}/secrets_store/stores/{store_id}/secrets/{secret_ref}/value,或官方提供的等價端點)依 secret_ref 動態取值。需一把「能讀 Secrets Store 的 CF API token」給 cypher worker(本身是高敏感、走 wrangler secret put 注入該 worker,非進 git)。好處:完全動態、不用為每 cred 改 toml。代價worker 持有一把能讀 store 的 token(但仍比「自管全域 ENCRYPTION_KEY」好——CF 託管、可輪替、範圍限 store)。
  2. 靜態 binding + 預宣告:只適合固定少量 credential,動態場景不可行 → 排除。
  3. 過渡折衷:高敏感(SA JSON/private key)走 Secrets Store;低敏感 token 暫留 KV(AES-GCM) → 但這不滿足 D19「廢自管 ENCRYPTION_KEY、owner 也讀不回」→ 僅作為回填未完成前的中繼。

本 SDD 預設走路徑 1REST by ref,並標「待 leo 確認 self-hosted leo21c CF 帳號的 Secrets Store API 路徑與 token 範圍」。施工前需一次 spike 驗證 leo21c 帳號可建 store + worker 可 by-ref 取值。

self-hostedleo21cSecrets Store 是 CF 原生能力,leo21c 自己的 CF 帳號支援。acr init/update 需新增「ensure Secrets Store store 存在」步驟(類比現有 ensureD1Database / ensureKvNamespace),把 store_id 注入 worker。

註:這與「Anthropic Routine env-var 型 self-hosted 不支援」是不同層(那是 Anthropic 雲端;這是 CF 帳號能力)——別混(leo 提醒)。

2.4 寫入流程(client 加密 → Secrets Store

⚠️ 鐵律對齊:現行是 client 端加密 + 自管 ENCRYPTION_KEY。走 Secrets Store 後,密文交給 CF 託管arcrun 不再自管金鑰。client 端是否仍加密一層 = 待決(見 §6 開放問題 Q-b):

  • 選項甲:client 不再加密,明文值經 TLS 送到 cypher → cypher 用「能寫 store 的 token」寫進 Secrets StoreCF 端加密託管)。arcrun TS 短暫經手明文(記憶體中,不落地)。
  • 選項乙:保留 client 加密當「傳輸期保護」,但這需 arcrun 持有解密金鑰才能寫進 store 明文槽 → 又回到自管金鑰,違 D19。 → 傾向選項甲(符合「不持有金鑰」),但「TS 短暫經手明文」需 leo 接受(mindset §7 誠實標:不是零接觸,是不落地、不持久、不持金鑰)。

2.5 讀取/注入流程(執行期 by ref)

  1. cypher 執行 workflow,遇 recipe auth_service='telegram'
  2. 查 D1 credentials where api_key+name=telegram_bot_token → 取 secret_ref + sensitivity
  3. secret_ref 向 Secrets Store 取密文值(路徑 1REST by ref)。
  4. auth_recipe:telegraminject.path/header 注入(與現行 auth-dispatcher 同型,只是值來源從「KV 密文+WASM 解密」換成「Secrets Store 取值」)。
  5. 更新 D1 last_used_at

⚠️ rule 02 §2.2/§2.3 對齊:解密/注入仍不在 cypher TS 實作業務邏輯。Secrets Store 取值是「host 能力」(類比 kv_get/crypto_decrypt),應落在 wasi-shim host function(新增 secret_get(ref))或等價的 host 邊界,WASM 零件仍是注入邏輯的所在。施工時須守此界線(見 §5)。


3. 治理 / UX 端點(n8n 模式)

端點 行為 對齊 D19
GET /credentials D1,回 [{name, service, sensitivity, created_at, last_used_at}]不含 secret_ref 對外、不含值 顯示清單 + last_used
PUT /credentials/:namereplace 整筆覆寫:寫新值進 Secrets Store(同 ref 覆蓋或新 ref+ 更新 D1 metadata 只能 replace
DELETE /credentials/:name 刪 D1 row + 刪 Secrets Store secret 可 delete
GET /credentials/:name/value 不存在 / 移除任何讀回值的路徑 讀不回值 = 設計

CLI 薄殼(rule 07):acr creds list(讀 D1 顯示)、acr creds replace <name>(覆寫)、acr creds delete <name>移除任何「印出 credential 值」的路徑


4. 遷移:雙讀過渡 + 回填 + 回滾

4.1 雙讀過渡(不停機)

執行期取 credential 值的順序:

  1. 先查 D1 有無 secret_ref → 有 → 走 Secrets Store 取值(新家)。
  2. 找不到 → fallback 舊路徑:CREDENTIALS_KV {api_key}:cred:{name} + WASM crypto_decrypt(舊自管)。 → 新寫入一律走新家;舊資料未回填前仍可讀。

4.2 回填(一次性、冪等、可審)

POST /credentials/migrate-to-secrets-store(類比現有 migrate-cron-index 冪等端點):

  • 對每個 {api_key}:cred:{name} KV rowWASM 解密取明文 → 寫進 Secrets Store(領 secret_ref)→ 在 D1 建 row(含 ref + metadata)。
  • 冪等:D1 已有該 (api_key,name) row 且 ref 可解析 → 跳過。
  • 誠實回報逐筆 ok/fail(不假綠,mindset §7)。
  • 回填完成且驗證通過後,才執行 §4.4 廢 ENCRYPTION_KEY。

4.3 回滾錨點

  • 回填不刪 KV 舊密文(保留為回滾錨點),只新增 D1+Secrets Store。
  • 出問題 → 雙讀 fallback 自動回到 KV 路徑;D1 row 可刪、Secrets Store secret 可刪 → 回到純 KV 狀態。
  • 只有在「全租戶回填驗證綠 + 觀察期無 fallback 命中」後,才進 §4.4。

4.4 廢自管 ENCRYPTION_KEY(最後一步)

  • 移除 crypto_decrypt 對 credential 的依賴路徑(KV 密文路徑停用)。
  • wrangler secret delete ENCRYPTION_KEYcypher / auth_static_key / auth_service_account)。
  • 清理 KV 舊密文(確認 Secrets Store 是唯一真相源後)。
  • ⚠️ 此步不可逆,須 leo 明示放行 + 回填觀察期通過。

5. 與鐵律 / 既有架構對齊(施工硬約束)

  • rule 02 §2.2/§2.3cypher TS 不實作 credential 解密/注入業務邏輯。Secrets Store 取值走 host function 邊界(新增 secret_get(ref) 於 wasi-shim),注入仍在 WASM auth primitive。
  • rule 01 加解密:自管 AES-GCM 路徑在回填後廢除;新家由 CF Secrets Store 託管金鑰(arcrun 不持金鑰)。
  • 不新增 componentmindset §1);不新增 service bindingrule 03 §3.1)。
  • 薄殼(rule 07CLI/MCP 只做「list 顯示 / replace 覆寫 / delete」介面轉換,store 能力在 API。
  • 部署繞開 GitHub ActionsD1 migration + Secrets Store ensure 走 acr init/updatewrangler 直推),不掛 Actions。
  • D1 同源:用使用者既有 arcrun-kbdb D1(不新建第二顆),migration 0002_credentials.sql 走現有 deploy.ts migration 注入路徑(與 0001_base.sql 同模式)。

6. 開放問題(施工前須 leo / 總管拍板)

  • Q-a(核心)Secrets Store 動態 by-ref 取值路徑(§2.3 路徑 1 REST API)—— leo21c 自己的 CF 帳號是否支援、API 路徑與 token 範圍?需一次 spike 驗證。這是整個遷移的硬前置(取值不通則 D19 走不了)。
  • Q-b:寫入時 client 是否仍加密一層(§2.4 甲/乙)。傾向甲(不持金鑰,TS 短暫經手明文不落地)——需 leo 接受此誠實 trade-off。
  • Q-csensitivity 分級的判定(誰標 high/standard)——auth-recipe 可加 sensitivity 欄宣告該 service 的 credential 等級,或一律 high。
  • Q-d:與 redesign.md C(友善前門:.env → 一次填)整合——C 的「值來源 .env」在新架構下是「.env 明文 → cypher 寫進 Secrets Store」,與 §2.4 甲一致。確認 C 依附本 SDDstore 是 Secrets Store 不是 D1)。

7. 任務分解(待放行後施工;本批僅 SDD)

  • T1 spikeleo21c CF 帳號 Secrets Store create store + worker by-ref 取值驗證(解 Q-a)。硬前置。
  • T2 D1 migration 0002_credentials.sql(§2.2+ deploy.ts 注入。
  • T3 acr init/update 新增 ensure Secrets Store store + 注入 store_id(類比 ensureD1Database)。
  • T4 wasi-shim 新增 secret_get(ref) host function(守 rule 02 邊界)。
  • T5 寫入路徑:POST/PUT /credentials 改寫 Secrets Store + D1 ref(§2.4)。
  • T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → Secrets Store 取值(§2.5+ 更新 last_used。
  • T7 雙讀 fallback(§4.1)。
  • T8 回填端點 POST /credentials/migrate-to-secrets-store(§4.2,冪等可審)。
  • T9 治理端點 list/replace/delete + 移除任何 read-value 路徑(§3+ CLI 薄殼。
  • T10 回填驗證 + 觀察期 → 廢 ENCRYPTION_KEY(§4.4leo 明示放行)。

每個 cred 操作跨 TS / WASM / host-function / 兩個 store,必端到端實測(防再假綠,mindset §7)。T1 不通則整案停,先解 Q-a。