feat(credentials): T8 回填端點 + T9 治理端點/CLI (credential-store-migration)

- POST /credentials/migrate-to-workers-secrets:舊 KV credential 逐筆解密回填 D1+Workers
  Secrets,冪等可審,重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點
- GET /credentials 改讀 D1(與既有 /credentials/catalog 共用查詢);DELETE 改為新家優先、
  舊 KV fallback,避免孤兒資料
- acr creds list/replace/delete 三支 CLI 薄殼指令;順手修好過期的 acr creds push(舊
  client 端加密格式已被 T5 取代)
- 新增 cypher-executor/tests/credentials.test.ts + D1 test fixture

T6/T7(讀取/注入路徑、雙讀 fallback)需要重新編譯 registry/components/auth_static_key
的 TinyGo WASM,本環境無 tinygo 且 proxy 擋 github.com 下載,卡在工具鏈缺口,詳細分析
記錄在 credential-store-migration.md。

端到端驗證:部署到 leo21c 帳號真實跑過 GET/POST/DELETE 三分支 + migrate 端點(對真實
既存的兩筆 credential 跑,發現 cypher-executor 自己的 ENCRYPTION_KEY secret 疑似為空,
誠實記錄為待 leo/總管裁決的不可逆風險項,未擅自重設)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-04 23:00:07 +00:00
parent 1db8a13a3a
commit 1d19d46161
9 changed files with 588 additions and 99 deletions
@@ -250,10 +250,92 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla
正確)→ 清理:CF API `DELETE .../secrets/{ref}` + D1 `DELETE FROM credentials`,二次查詢確認
皆已清空。部署完成後**立刻 restore `wrangler.toml` 備份**git 追蹤版本無 leo21c 帳號專屬 id
殘留(已 grep 確認)。
- [ ] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → `env[ref]` 取值(§2.5+ 更新 last_used。
- [ ] T7 雙讀 fallback(§4.1)。
- [ ] T8 回填端點 `POST /credentials/migrate-to-workers-secrets`(§4.2,冪等可審)。
- [ ] T9 治理端點 list/replace/delete + 移除任何 read-value 路徑(§3+ CLI 薄殼。
- [] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → `env[ref]` 取值(§2.5+ 更新 last_used。
2026-07-04 [cloud-worker] 卡在框架級工具缺口,非設計問題〕**T6 需要修改
`registry/components/auth_static_key/main.go`(與 `auth_service_account/main.go`**
這兩個 auth primitive 是獨立部署的 TinyGo WASM Worker,密文值現在住在
**cypher-executor 自己的 per-script secrets**T5 `putWorkerSecret` 寫入的 script 是
`arcrun-cypher-executor`,不是 auth_static_key worker)——`env[secret_ref]` 動態索引
只在「secret 所在的那個 worker 自己的 runtime」內可讀,auth_static_key worker 讀不到
cypher-executor 的 env。故 T6 的正確落地方式是:cypher-executor`auth-dispatcher.ts`
先查 D1 拿 secret_ref、呼叫 `secret_get(ref)`(T4 已備妥,讀的正是 cypher 自己的
env)取得明文,再把已解析好的值放進送給 auth_static_key WASM 的 payload 一個新欄位
(如 `resolved_secrets: {name: value}`),main.go 收到後優先用它、沒有才 fallback 舊
`kv_get`+`crypto_decrypt`(這個 fallback 分支同時就是 T7 雙讀)。**這個 main.go 改動
需要 TinyGo 編譯**——本次雲端環境核實:`tinygo` 未安裝(`which tinygo` 找不到)、
`apt-cache search tinygo` 查無套件、且 proxy 擋 `github.com``curl -sI
https://github.com/tinygo-org/tinygo/releases` → `HTTP/1.1 403 Forbidden`
tinygo 官方無 apt/npm 分發管道,只能從 GitHub Releases 下載),**無法在本環境安裝
TinyGo 工具鏈,因此無法重新編譯/部署 auth_static_key.wasm**。誠實結論:T6/T7 的
TS 側前置(D1 查詢+`secret_get` 呼叫)在架構上可行且不衝突 rule 02(cypher 短暫經手
明文、只轉送給 WASM 做 template 注入,同 §2.4 選項甲的既有精神),但**卡在需要
TinyGo 編譯環境這一步,非本次雲端工人能力範圍**,需總管本機(有 TinyGo)或 leo
決定是否要在雲端環境補裝工具鏈(例如允許 proxy 放行 github.com release 下載,或
改用其他 TinyGo 取得管道)。整案不硬繞(不改用「TS 直接組 header」這種違反 rule 02
的捷徑),留給有 TinyGo 環境的一方接手 main.go 改動 + 重新編譯部署。
- [⛔] T7 雙讀 fallback(§4.1)。〔同上,隨 T6 main.go 改動一併落地——D1 查得到 secret_ref
走新路徑,查不到就是 WASM 原有的 `kv_get`+`crypto_decrypt` 分支,本來就會自然發生,
不需要額外的「fallback 判斷」程式碼,只要 T6 的 `resolved_secrets` 是「有給才用、沒給
就照舊」的設計即可。同樣卡在 T6 的 TinyGo 前置。〕
- [x] T8 回填端點 `POST /credentials/migrate-to-workers-secrets`(§4.2,冪等可審)。
2026-07-04 [cloud-worker] 完成:`cypher-executor/src/routes/credentials.ts` 新增
端點,重用 `createArcrunHostFunctions(env, apiKey).crypto_decrypt`wasi-shim.ts
唯一合法 `crypto.subtle.decrypt` 呼叫點,本檔不重新實作解密,grep 確認全 repo
`crypto.subtle` 呼叫點未增加)解出舊 KV 明文 → `putWorkerSecret` → D1 upsert。
冪等:D1 已有可解析 `secret_ref` 的 row 就跳過(不重打 CF API)。逐筆誠實回報
ok/skipped/fail。**單元測試**`tests/credentials.test.ts`vitest-pool-workers +
D1 mock`tests/setup.ts` 建表):401 檢查、冪等 skip、空結果、以及「假造密文在
測試環境缺 CF token 時誠實回報 fail 不假綠」4 案例全過。`tsc --noEmit` exit 0
`vitest run` 35/361 個 pre-existing 無關失敗,見完成記錄)。
**端到端真實驗證**leo21c `arcrun-cypher-executor`,非測試替身):patch
`wrangler.toml` 真實 id(同 T5 手法)→ `wrangler deploy --dry-run` 核對 binding
表全對 → 真部署 → 打 `POST /credentials/migrate-to-workers-secrets` 對真實租戶
`leo` 名下**兩筆真實既存**的舊 KV credential`google_service_account`、
`notion_token`,非測試殘留,KV 內容確認是合法 `{encrypted, iv}` JSON 結構,
base64 長度正常)→ **端點誠實回報兩筆皆 fail**(`Imported AES key length must be
128, 192, or 256 bits but provided 0`),**未寫入任何部分資料到 D1 或 Workers
Secrets(已查證 D1 `credentials` 表對這兩筆 name 完全無 row,非部分髒寫)**。
**重大誠實發現(非本次程式碼缺陷,是既有基礎設施缺口)**:這個錯誤代表
`env.ENCRYPTION_KEY` 在 **cypher-executor 這個 worker 自己的 runtime** 內讀到空字串
`hexToUint8Array('').length === 0`)——過去 `crypto_decrypt` 只在 auth_static_key/
auth_service_account 這兩個獨立 worker 上被呼叫過(各自有自己設定好的
`ENCRYPTION_KEY` secret),**cypher-executor 自己的 `ENCRYPTION_KEY` secret 從未被
任何程式路徑真正呼叫驗證過**(Phase 6.6 CI 記錄有把同一份 `.env` 值 pipe 給三個
worker,理論上應該一致,但 CF Workers Secrets 管理 API 本質唯寫、無法讀回比對,
無法排除某個環節曾經漏設或設成空值)。T8 是**史上第一個**在 cypher-executor 自己
的 runtime 呼叫 `crypto_decrypt` 的程式路徑,因此第一次揭露這個潛在缺口。
**不可逆風險,本次不處理,等 leo**:重設 cypher-executor 的 `ENCRYPTION_KEY` 這件事
本身不可逆——如果現在的空值就是問題根源,重設一把新 key 沒問題;但如果問題其實
出在別處(例如 `env` binding 讀取路徑有 bug),錯誤地假設「重設 key 就會好」而
去 `wrangler secret put` 可能導致**這兩把真實 credentialGoogle Service Account
+ Notion token)從此永久無法解密**(唯寫 API 讀不回舊值比對,猜錯就回不了頭)。
故本次**刻意不猜、不重設**,只誠實回報現象+兩種可能成因,交給有本機環境能
實際比對三個 worker `ENCRYPTION_KEY` 是否一致的一方(總管/leo)診斷後裁決。
測試資料已清理(探測用 `t89_probe_secret``t89_legacy_probe` 皆已刪除,D1 與 KV
復原至只剩原本兩筆真實資料的乾淨狀態,已 curl 覆核);部署完成後 `wrangler.toml`
已 `git checkout` 復原,grep 確認無 leo21c 帳號專屬 id 殘留。
- [x] T9 治理端點 list/replace/delete + 移除任何 read-value 路徑(§3+ CLI 薄殼。
2026-07-04 [cloud-worker] 完成:`GET /credentials` 改讀 D1(與既有 `/credentials/
catalog` 共用同一份 query`/catalog` 保留給 Console 既有呼叫不受影響);
`DELETE /credentials/:name` 改為「D1 有 secret_ref → 刪 Workers Secret + D1 row
沒有(從未回填過,只存在舊 KV)→ fallback 刪舊 KV key」,避免 GET 改讀 D1 後
「查不到卻刪不掉」的孤兒資料。**全程無任何回傳密文值的路徑**(GET 只回
name/service/sensitivity/created_at/last_used_atDELETE 不讀值只刪)。CLI 薄殼
`cli/src/commands/creds.ts` 加 `acr creds list`(讀 D1 顯示)/`acr creds replace
<name> <value>`PUT 整筆覆寫)/`acr creds delete <name>`;順手修好 `acr creds
push`(原實作對應 T5 之前的 `{name,encrypted,iv}` 格式已過期,改直接 PUT 明文
`{name,value}`,否則會 400——這是既有 CLI 的誠實缺口修正,非新增範圍外功能)。
**驗證**`tsc --noEmit`cli + cypher-executorexit 0`vitest run`
credentials.test.ts 全過(見 T8 記錄,同一檔涵蓋兩個任務);CLI `tsc` build 後
`node dist/index.js creds --help`/`creds replace --help` 手動核對指令走線正確。
**端到端真實驗證**leo21c `arcrun-cypher-executor`):
`POST /credentials`T5 既有路徑)建一筆 → `GET /credentials` 立刻讀到(D1 新讀
路徑生效,與 `/catalog` 回傳一致)→ `DELETE` 新路徑分支:CF API 直接核對
`arcrun-cypher-executor` secrets 清單,刪除前有 `CRED_T89_PROBE_SECRET_*`、刪除後
該 secret 真的消失(只剩 `CF_SECRETS_API_TOKEN`/`ENCRYPTION_KEY`)+ D1 row 真的
清空 → 另外手工在 KV 塞一筆「只存在舊 KV、無 D1 row」的假資料,`DELETE` 走
legacy-kv fallback 分支,CF API 核對 KV key 真的被刪除。兩分支皆對真實
leo21c 帳號驗證通過,非模擬。
- [ ] T10 回填驗證 + 觀察期 → 廢 ENCRYPTION_KEY(§4.4leo 明示放行)。
> **每個 cred 操作跨 TS / WASM / host-function / 兩個 store,必端到端實測**(防再假綠,mindset §7)。原「T1 不通則整案停」已兌現一輪:T1 負結果 → 整案停 → 總管裁決轉向 → T1.5 全過 → **T2-T9 解凍**。備援(若施工再撞死路):codegen binding + 自動重部署(Arcrun#2 總管裁決的方向 1)。