diff --git a/cypher-executor/src/lib/wasi-shim.ts b/cypher-executor/src/lib/wasi-shim.ts index 88132d9..0ea20d3 100644 --- a/cypher-executor/src/lib/wasi-shim.ts +++ b/cypher-executor/src/lib/wasi-shim.ts @@ -16,6 +16,13 @@ export interface ArcrunHostEnv { CREDENTIALS_KV: KVNamespace; RECIPES: KVNamespace; ENCRYPTION_KEY: string; + /** + * credential-store-migration T4(§2.5/§5):CF Workers per-script Secrets 以 env var 形式 + * 注入 worker,值只能靠字串動態索引取得(`env[ref]`,T1.5 spike ② 已證可行)。 + * 用 index signature 讓 `secret_get` host function 能對任意 secret_ref 字串取值, + * 不需要像 ENCRYPTION_KEY 那樣逐一宣告固定屬性名。 + */ + [secretRef: string]: unknown; } const WASI_ESUCCESS = 0; @@ -56,6 +63,13 @@ export interface WasiHostFunctions { http_request?: (url: string, method: string, headers: string, body: string) => Promise; /** KV 讀取:key 前綴由 Worker 路由到對應 binding,並做越權檢查 */ kv_get?: (key: string) => Promise; + /** + * credential-store-migration T4(§2.5/§5):讀取 CF Workers per-script Secrets 的值。 + * ref = D1 credentials 表的 secret_ref(env var 名,如 `CRED_TELEGRAM_BOT_TOKEN_A1B2C3D4`)。 + * host 端實作=`env[ref]` 動態字串索引(零網路呼叫,值已由 CF runtime 以 env var 注入 worker, + * T1.5 spike ② 已證可行)。找不到該 ref → 回傳 null(與 kv_get 的「找不到」語意一致)。 + */ + secret_get?: (ref: string) => Promise; /** KV 寫入:用於快取 access_token 等短效值,ttlSeconds=0 表示不設 TTL */ kv_put?: (key: string, value: string, ttlSeconds: number) => Promise; /** AES-GCM 解密:encryption key 由 Worker 保管,不暴露給 WASM */ @@ -375,6 +389,23 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti }) : () => 1, + // secret_get(refPtr, refLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 ref + // 與 kv_get 同款 pointer/memory-write 機制;差別只在 host 端實作來源(env[ref] 而非 KV.get)。 + secret_get: hostFunctions?.secret_get + ? hostWrap(async (refPtr: number, refLen: number, outPtr: number, outLenPtr: number): Promise => { + if (!memory) return 1; + const ref = new TextDecoder().decode(new Uint8Array(memory.buffer, refPtr, refLen)); + try { + const result = await hostFunctions!.secret_get!(ref); + if (result === null) return 2; + const encoded = new TextEncoder().encode(result); + return writeOut(memory.buffer, outPtr, outLenPtr, encoded); + } catch { + return 1; + } + }) + : () => 1, + // kv_put(keyPtr, keyLen, valPtr, valLen, ttlSeconds) → 0 成功;1 錯誤 kv_put: hostFunctions?.kv_put ? hostWrap(async (keyPtr: number, keyLen: number, valPtr: number, valLen: number, ttlSeconds: number): Promise => { @@ -694,7 +725,22 @@ async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise< } /** - * 建立 arcrun host function 組合(kv_get / crypto_decrypt / crypto_sign_rs256)。 + * 讀取 CF Workers per-script Secrets 的值:`env[ref]` 動態字串索引 + * (credential-store-migration T4,§2.5/§5;T1.5 spike ② 已證此場景成立,零網路呼叫)。 + * + * 安全邊界(比照 `routedKvGet` 的前綴檢查精神;design.md §2.3「secret_ref 命名需以 CRED_ + * 前綴隔離命名空間」):只允許讀 `CRED_` 開頭的 ref。WASM 不該、也不需要讀到 worker 本身的 + * 其他機密(`ENCRYPTION_KEY` / `CF_SECRETS_API_TOKEN` 等非 credential 用途的 env var)。 + * 不符前綴或值非字串 → 回傳 null(與 `kv_get` 的「拒絕/找不到」語意一致)。 + */ +function secretGet(env: ArcrunHostEnv, ref: string): string | null { + if (!/^CRED_/.test(ref)) return null; + const value = env[ref]; + return typeof value === 'string' ? value : null; +} + +/** + * 建立 arcrun host function 組合(kv_get / crypto_decrypt / crypto_sign_rs256 / secret_get)。 * 由 WASM runner(component-loader 的 WASM 路徑)呼叫,與 api_key 綁定以做越權檢查。 * * http_request 不由本 factory 提供 — auth primitive WASM 與 API WASM 零件若需要 @@ -706,6 +752,7 @@ export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): W kv_put: (key: string, value: string, ttlSeconds: number) => routedKvPut(env, apiKey, key, value, ttlSeconds), crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64), crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8), + secret_get: async (ref: string) => secretGet(env, ref), }; } diff --git a/cypher-executor/tests/wasi-shim.test.ts b/cypher-executor/tests/wasi-shim.test.ts index 03dd904..f8b2cb9 100644 --- a/cypher-executor/tests/wasi-shim.test.ts +++ b/cypher-executor/tests/wasi-shim.test.ts @@ -4,7 +4,7 @@ */ import { describe, it, expect } from 'vitest'; -import { createWasiShim } from '../src/lib/wasi-shim'; +import { createWasiShim, createArcrunHostFunctions, type ArcrunHostEnv } from '../src/lib/wasi-shim'; // 建立一個最小的 fake WebAssembly.Memory(用 ArrayBuffer 模擬) function makeFakeMemory(size = 65536): WebAssembly.Memory { @@ -213,3 +213,69 @@ describe('createWasiShim', () => { }); }); }); + +// ── credential-store-migration T4:secret_get host function ──────────────────── +// +// 讀取 CF Workers per-script Secrets 的值:env[ref] 動態字串索引(§2.5/§5,T1.5 spike ②)。 +// 兩層測試: +// 1. createArcrunHostFunctions 對 fake env 物件的行為(CRED_ 前綴放行、其他前綴拒絕、找不到回 null) +// 2. u6u.secret_get 的 WASI import wiring(pointer/memory-write 機制,比照 kv_get 同款測法) + +function makeFakeEnv(overrides: Record = {}): ArcrunHostEnv { + const fakeKv = { + get: async () => null, + put: async () => {}, + delete: async () => {}, + list: async () => ({ keys: [], list_complete: true, cacheStatus: null }), + } as unknown as KVNamespace; + return { + CREDENTIALS_KV: fakeKv, + RECIPES: fakeKv, + ENCRYPTION_KEY: 'deadbeef'.repeat(8), + ...overrides, + }; +} + +describe('createArcrunHostFunctions — secret_get', () => { + it('CRED_ 前綴的 ref 存在 → 回傳值', async () => { + const env = makeFakeEnv({ CRED_TELEGRAM_BOT_TOKEN_A1B2C3D4: 'sk-fake-secret-value' }); + const hostFns = createArcrunHostFunctions(env, 'ak_test'); + await expect(hostFns.secret_get!('CRED_TELEGRAM_BOT_TOKEN_A1B2C3D4')).resolves.toBe('sk-fake-secret-value'); + }); + + it('CRED_ 前綴但 ref 不存在(env 沒有這個 key)→ null', async () => { + const env = makeFakeEnv(); + const hostFns = createArcrunHostFunctions(env, 'ak_test'); + await expect(hostFns.secret_get!('CRED_NOT_SET')).resolves.toBeNull(); + }); + + it('非 CRED_ 前綴 → 一律拒絕回 null(即使 env 上真的有這個值,如 ENCRYPTION_KEY)', async () => { + const env = makeFakeEnv(); + const hostFns = createArcrunHostFunctions(env, 'ak_test'); + // ENCRYPTION_KEY 是 worker 自己的機密,WASM 不該透過 secret_get 拿到(安全邊界) + await expect(hostFns.secret_get!('ENCRYPTION_KEY')).resolves.toBeNull(); + await expect(hostFns.secret_get!('CF_SECRETS_API_TOKEN')).resolves.toBeNull(); + }); + + it('CRED_ 前綴但值非字串(型別不符)→ null', async () => { + const env = makeFakeEnv({ CRED_WEIRD: 12345 }); + const hostFns = createArcrunHostFunctions(env, 'ak_test'); + await expect(hostFns.secret_get!('CRED_WEIRD')).resolves.toBeNull(); + }); +}); + +describe('u6u.secret_get — WASI import wiring', () => { + // 誠實註記(撞牆記錄):vitest-pool-workers 環境的 WebAssembly 支援 JSPI,hostWrap() 因此把 + // secret_get(以及既有的 kv_get / crypto_decrypt 等所有 async host function)包成 + // `WebAssembly.Suspending` 物件而非一般函式——這類物件設計上只能當 WASM import 綁定使用, + // 不能在 JS 端直接 `fn(...)` 呼叫(會拋 "is not a function")。用 probe 測試證實 + // kv_get 的 import 同樣是 `Suspending` 物件、同樣不可直接呼叫——這是既有架構的環境限制, + // 不是 secret_get 本身的缺陷;pointer/memory-write 機制無法在不起真實 WASM instance 的 + // 單元測試裡直接驗證(真實路徑靠 T5 的 wrangler 部署端到端驗證涵蓋)。 + // 這裡改測 hostWrap 不介入的同步路徑:沒注入 secret_get 時的 fallback。 + it('沒有注入 secret_get host function → 回傳 1(未實作,同步 fallback 路徑,非 hostWrap 包裝)', () => { + const shim = createWasiShim('', {}); // 沒給 secret_get + const secretGetImport = (shim.imports.u6u as Record).secret_get; + expect(secretGetImport()).toBe(1); + }); +}); diff --git a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md index 2ef0c01..29edf19 100644 --- a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md +++ b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md @@ -179,7 +179,24 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla type='table' AND name='credentials'` → 回 `{"name":"credentials"}`;schema dump 確認欄位與 `idx_cred_apikey` index 完全對齊 §2.2;重跑同一份 migration 第二次 → `success:true`(冪等驗證)。 - [ ] T3 `acr init/update` 確保 secrets 寫入設定就緒:cypher worker 持有「能打 Workers Scripts secrets API 的 CF API token」+ account id(§2.3 寫入路徑;per-script secrets **無 store 資源需 ensure**,原「ensure store + 注入 store_id」整步取消)+ 於非關鍵 worker 跑一次 `acr update` 全流程驗證 secrets 存活(補 spike ④ 的誠實缺口)。〔依 T1.5 spike 2026-07-03 改寫,證據 Arcrun#2〕 -- [ ] T4 wasi-shim 新增 `secret_get(ref)` host function(守 rule 02 邊界;host 端實作=`env[ref]` 動態索引,spike ② 已證可行,零網路呼叫)。〔依 T1.5 spike 2026-07-03 改寫,證據 Arcrun#2〕 +- [x] T4 wasi-shim 新增 `secret_get(ref)` host function(守 rule 02 邊界;host 端實作=`env[ref]` 動態索引,spike ② 已證可行,零網路呼叫)。〔依 T1.5 spike 2026-07-03 改寫,證據 Arcrun#2〕2026-07-03 完成: + `cypher-executor/src/lib/wasi-shim.ts` — `ArcrunHostEnv` 加 index signature(`[secretRef: string]: + unknown`)讓 host function 能對任意 secret_ref 動態取值;`WasiHostFunctions` 加 + `secret_get?: (ref: string) => Promise`;`u6u` WASI imports 加 `secret_get` + wiring(與 `kv_get` 同款 pointer/memory-write 機制:0 成功/1 錯誤/2 找不到);host 端實作 + `secretGet(env, ref)`=`env[ref]` 動態索引,**只允許 `CRED_` 前綴**(比照 `routedKvGet` 的 + 前綴檢查精神,拒絕讀 `ENCRYPTION_KEY`/`CF_SECRETS_API_TOKEN` 等非 credential 機密,我方判斷的 + 安全邊界,SDD 未明文但符合 §2.3「CRED_ 前綴隔離命名空間」精神);`createArcrunHostFunctions` + 掛上 `secret_get`。驗證:`tsc --noEmit` exit 0(無輸出);`vitest run tests/wasi-shim.test.ts` + 18/18 通過(新增 `createArcrunHostFunctions — secret_get` 4 案例對 fake env 物件測 CRED_ 前綴放行 + /不存在回 null/非 CRED_ 前綴拒絕/型別不符回 null;`u6u.secret_get` wiring 1 案例測未注入時 + 同步 fallback)。**誠實撞牆記錄**:原規劃另外 2 個測 pointer/memory-write 機制的案例 + (直接呼叫 `.imports.u6u.secret_get(...)`)失敗,查證後發現 vitest-pool-workers 環境的 + WebAssembly 支援 JSPI,`hostWrap()` 把所有 async host function(含既有 `kv_get`)包成 + `WebAssembly.Suspending` 物件,此物件設計上只能當 WASM import 綁定用、不能在 JS 端直接 + `fn(...)` 呼叫——用 probe 測試證實 `kv_get` 的 import 同樣不可直接呼叫,確認是既有架構的 + 環境限制非 secret_get 缺陷,已移除該 2 案例並在測試檔內註記;pointer 機制的真實驗證改由 + T5 wrangler 部署端到端涵蓋。 - [ ] T5 寫入路徑:`POST/PUT /credentials` 改寫 Workers Secrets(API `PUT`)+ D1 ref(§2.4)。 - [ ] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → `env[ref]` 取值(§2.5)+ 更新 last_used。 - [ ] T7 雙讀 fallback(§4.1)。