/** * WASI preview1 輕量 shim * 只實作 stdin/stdout/stderr 所需的最小 syscall 集合。 * 其餘 syscall 一律回傳 ENOSYS(76),確保零件無法呼叫網路或檔案系統。 * * 不依賴任何外部套件(不使用 @cloudflare/workers-wasi)。 * Requirements: 3.1, 3.3 */ /** * createArcrunHostFunctions 所需的最小 env 子集。 * 不直接依賴 cypher-executor 的 Bindings,讓 auth primitive Worker 這類 * 只綁 CREDENTIALS_KV / RECIPES 的獨立 Worker 也能用。 */ export interface ArcrunHostEnv { CREDENTIALS_KV: KVNamespace; RECIPES: KVNamespace; /** * 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 字串取值, * 不需要逐一宣告固定屬性名。 */ [secretRef: string]: unknown; } const WASI_ESUCCESS = 0; const WASI_ENOSYS = 76; // fd 常數 const FD_STDIN = 0; const FD_STDOUT = 1; const FD_STDERR = 2; export interface WasiShim { /** WebAssembly.Imports 物件,傳入 WebAssembly.instantiate */ imports: WebAssembly.Imports; /** 取得 stdout 的完整輸出(合併所有 chunks) */ getStdout(): string; /** 取得 stderr 的完整輸出 */ getStderr(): string; /** 注入 WebAssembly.Memory(instantiate 後呼叫) */ setMemory(memory: WebAssembly.Memory): void; /** * 執行 WASM _start,自動使用 WebAssembly.promising(JSPI)讓 async host * function 能正確 suspend/resume。若 JSPI 不可用則 fallback 同步執行。 * 必須在 setMemory() 之後呼叫。 */ run(instance: WebAssembly.Instance): Promise; } /** * Host function 注入介面 * 讓 .wasm 零件能透過 host function 呼叫外部服務,而不需要網路 syscall * * 嚴格邊界: * - `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他機密 * - `kv_get` 必須在 Worker 側檢查 key 前綴以防越權(見 auth-dispatcher.ts) */ export interface WasiHostFunctions { /** HTTP 請求 host function:.wasm 呼叫此函數發出 HTTP 請求 */ 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; /** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */ crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise; } /** * 建立 WASI shim 實例 * @param stdinData - 要寫入 stdin 的 UTF-8 字串(通常是 JSON.stringify(input)) * @param hostFunctions - 可選的 host function 注入(讓 .wasm 呼叫外部服務) */ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFunctions): WasiShim { const stdinBytes = new TextEncoder().encode(stdinData); let stdinOffset = 0; const stdoutChunks: Uint8Array[] = []; const stderrChunks: Uint8Array[] = []; let memory: WebAssembly.Memory | null = null; function getMemoryView(): DataView { if (!memory) throw new Error('WASI memory not set — call setMemory() after instantiate'); return new DataView(memory.buffer); } // 寫入結果到 WASM 的 outPtr buffer(host function 共用) // 回傳 0 = 成功,1 = memory 不可用 function writeOut(buf: ArrayBuffer, outPtr: number, outLenPtr: number, data: Uint8Array): number { try { new Uint8Array(buf, outPtr, data.length).set(data); new DataView(buf).setUint32(outLenPtr, data.length, true); return 0; } catch { return 1; } } /** * fd_write: 將 iovec 陣列的資料寫入 fd(stdout=1 或 stderr=2) * iovec 結構:{ buf: i32, buf_len: i32 }(各 4 bytes,little-endian) */ function fd_write(fd: number, iovs: number, iovs_len: number, nwritten_ptr: number): number { if (fd !== FD_STDOUT && fd !== FD_STDERR) return WASI_ENOSYS; const view = getMemoryView(); const buf = memory!.buffer; let totalWritten = 0; for (let i = 0; i < iovs_len; i++) { const iov_base = view.getUint32(iovs + i * 8, true); const iov_len = view.getUint32(iovs + i * 8 + 4, true); if (iov_len === 0) continue; const chunk = new Uint8Array(buf, iov_base, iov_len); const copy = new Uint8Array(iov_len); copy.set(chunk); if (fd === FD_STDOUT) stdoutChunks.push(copy); else stderrChunks.push(copy); totalWritten += iov_len; } view.setUint32(nwritten_ptr, totalWritten, true); return WASI_ESUCCESS; } /** * fd_read: 從 stdin 讀取資料到 iovec 陣列 */ function fd_read(fd: number, iovs: number, iovs_len: number, nread_ptr: number): number { if (fd !== FD_STDIN) return WASI_ENOSYS; const view = getMemoryView(); const buf = memory!.buffer; let totalRead = 0; for (let i = 0; i < iovs_len; i++) { const iov_base = view.getUint32(iovs + i * 8, true); const iov_len = view.getUint32(iovs + i * 8 + 4, true); if (iov_len === 0) continue; const remaining = stdinBytes.length - stdinOffset; if (remaining <= 0) break; const toCopy = Math.min(iov_len, remaining); const dest = new Uint8Array(buf, iov_base, toCopy); dest.set(stdinBytes.subarray(stdinOffset, stdinOffset + toCopy)); stdinOffset += toCopy; totalRead += toCopy; } view.setUint32(nread_ptr, totalRead, true); return WASI_ESUCCESS; } /** * proc_exit: 零件呼叫 exit(),拋出 Error 中止執行 */ function proc_exit(code: number): never { throw new Error(`wasm exit: ${code}`); } /** * random_get: 填充隨機 bytes(使用 Web Crypto API) */ function random_get(buf_ptr: number, buf_len: number): number { const view = new Uint8Array(memory!.buffer, buf_ptr, buf_len); crypto.getRandomValues(view); return WASI_ESUCCESS; } // ── Asyncify protocol ────────────────────────────────────────────────────── // TinyGo WASI target 永遠使用 asyncify scheduler。Asyncify 讓 WASM 能在呼叫 host // function 時「unwind」(保存 call stack),待 async 工作完成後再「rewind」(恢復)。 // // 協議流程(每次 async host function 呼叫): // 1. WASM 呼叫 host import(例如 http_request) // 2. Host 檢查 asyncify_get_state(): // - state=1(Unwinding): 正在展開,host 應直接回傳 0(佔位值) // - state=2(Rewinding): 正在恢復,host 應回傳上一次 async 結果(已存在 asyncifyResult) // - state=0(Normal): 正常執行,host 啟動 async 工作並呼叫 asyncify_start_unwind // 3. WASM 的 _start 控制流回到 run()(asyncify 讓 _start 提前返回) // 4. run() await async 工作,呼叫 asyncify_start_rewind,再次呼叫 _start // 5. WASM 從 host import 返回點繼續執行,host 回傳儲存的結果 // // 注意:每次 _start 呼叫只能處理一個 async 中斷點。若 WASM 有多個連續的 async host call, // run() 會在 while 迴圈裡重複 rewind 直到 asyncify_get_state() == 0(Normal)。 // Asyncify 資料緩衝區設定(TinyGo asyncify 用於保存 call stack) // 位址在 run() 中設定(WASM memory 末尾分配 1MB) let asyncifyDataPtr = 0; const ASYNCIFY_BUF_SIZE = 1024 * 1024; // 1MB stack buffer // 儲存 async host function 的結果和 Promise let asyncifyPendingPromise: Promise | null = null; let asyncifyResult: number = 0; // asyncify exports(run() 設定後才可用) let asyncifyExports: { get_state: () => number; start_unwind: (ptr: number) => void; stop_unwind: () => void; start_rewind: (ptr: number) => void; stop_rewind: () => void; } | null = null; // JSPI helper:若環境支援 WebAssembly.Suspending,用它包裝 async import function // 用於 scheduler=none 編譯的 WASM(無 asyncify exports) // eslint-disable-next-line @typescript-eslint/no-explicit-any function jspiSuspending Promise>(fn: T): T { const SuspendingCtor = (WebAssembly as unknown as Record)['Suspending'] as (new (fn: T) => T) | undefined; return SuspendingCtor ? new SuspendingCtor(fn) : fn; } // 建立一個 asyncify-aware 的 host function wrapper // 協議:Normal 時啟動 async 工作並呼叫 start_unwind;Rewinding 時回傳已存的結果 // 用於 scheduler=asyncify 編譯的 WASM(有 asyncify exports) // eslint-disable-next-line @typescript-eslint/no-explicit-any function asyncifyWrap(fn: (...args: any[]) => Promise): (...args: any[]) => number { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (...args: any[]): number => { if (!memory) return 1; const ax = asyncifyExports; if (!ax) return 0; // asyncify 尚未初始化(sync fallback) const state = ax.get_state(); if (state === 2) { // Rewinding:回傳上次 async 的真實結果 return asyncifyResult; } if (state === 1) { // Unwinding 中:直接回傳 0(WASM 在 unwind,不使用此值) return 0; } // Normal(state=0):啟動 async 工作,觸發 asyncify unwind asyncifyPendingPromise = fn(...args); // asyncify_start_unwind 設定 WASM 內部 unwind flag; // host function 返回後 WASM 開始保存 call stack,最終 _start() 返回 ax.start_unwind(asyncifyDataPtr); return 0; // WASM 忽略此值(正在 unwind) }; } // 根據 WASM 是否有 asyncify exports 決定使用哪種包裝方式 // JSPI mode: scheduler=none WASM + WebAssembly.Suspending // asyncify mode: scheduler=asyncify WASM + asyncify protocol // 初始化時先用 asyncifyWrap,run() 後若沒有 asyncify exports 就切換到 jspiSuspending // 但因為 imports 在 instantiate 前就需要確定,這裡統一先用 asyncifyWrap // run() 時若發現沒有 asyncify exports 且有 JSPI,則使用 JSPI 模式 // eslint-disable-next-line @typescript-eslint/no-explicit-any function hostWrap(fn: (...args: any[]) => Promise): (...args: any[]) => number | Promise { // 嘗試使用 JSPI Suspending(若環境支援) const SuspendingCtor = (WebAssembly as unknown as Record)['Suspending'] as // eslint-disable-next-line @typescript-eslint/no-explicit-any (new (fn: any) => any) | undefined; if (SuspendingCtor) { // JSPI 可用:包裝為 Suspending,讓 WASM 能 suspend 等待 async 結果 // 這適用於 scheduler=none 的 WASM(無 asyncify 干擾) return new SuspendingCtor(fn); } // fallback:asyncify 協議(scheduler=asyncify WASM) return asyncifyWrap(fn); } const shim: WasiShim = { imports: { wasi_snapshot_preview1: { fd_write, fd_read, proc_exit, random_get, // 其餘 syscall 回傳 ENOSYS(不允許網路/檔案系統操作) fd_seek: () => WASI_ENOSYS, fd_close: () => WASI_ESUCCESS, fd_fdstat_get: () => WASI_ENOSYS, fd_prestat_get: () => WASI_ENOSYS, fd_prestat_dir_name: () => WASI_ENOSYS, environ_get: () => WASI_ESUCCESS, environ_sizes_get: (count_ptr: number, size_ptr: number) => { if (memory) { const view = getMemoryView(); view.setUint32(count_ptr, 0, true); view.setUint32(size_ptr, 0, true); } return WASI_ESUCCESS; }, args_get: () => WASI_ESUCCESS, args_sizes_get: (argc_ptr: number, argv_buf_size_ptr: number) => { if (memory) { const view = getMemoryView(); view.setUint32(argc_ptr, 0, true); view.setUint32(argv_buf_size_ptr, 0, true); } return WASI_ESUCCESS; }, clock_time_get: (id: number, precision: bigint, time_ptr: number) => { if (memory) { const view = getMemoryView(); const now = BigInt(Date.now()) * 1_000_000n; view.setBigUint64(time_ptr, now, true); } return WASI_ESUCCESS; }, clock_res_get: () => WASI_ENOSYS, poll_oneoff: () => WASI_ENOSYS, sched_yield: () => WASI_ESUCCESS, proc_raise: () => WASI_ENOSYS, sock_accept: () => WASI_ENOSYS, sock_recv: () => WASI_ENOSYS, sock_send: () => WASI_ENOSYS, sock_shutdown: () => WASI_ENOSYS, path_open: () => WASI_ENOSYS, path_create_directory: () => WASI_ENOSYS, path_remove_directory: () => WASI_ENOSYS, path_rename: () => WASI_ENOSYS, path_unlink_file: () => WASI_ENOSYS, path_filestat_get: () => WASI_ENOSYS, path_readlink: () => WASI_ENOSYS, path_symlink: () => WASI_ENOSYS, path_link: () => WASI_ENOSYS, }, // u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務 // .wasm 零件用 //go:wasmimport u6u 宣告 // 所有 async host function 透過 asyncifyWrap 包裝,實作 asyncify 協議 u6u: { http_request: hostFunctions?.http_request ? hostWrap(async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number, headersPtr: number, headersLen: number, bodyPtr: number, bodyLen: number, outPtr: number, outLenPtr: number): Promise => { if (!memory) return 1; // 在 await 前讀完所有輸入(memory.buffer 在 await 後可能因 grow 而失效) const snapBuf = memory.buffer; const dec = new TextDecoder(); const url = dec.decode(new Uint8Array(snapBuf, urlPtr, urlLen)); const method = dec.decode(new Uint8Array(snapBuf, methodPtr, methodLen)); const headers = dec.decode(new Uint8Array(snapBuf, headersPtr, headersLen)); const body = dec.decode(new Uint8Array(snapBuf, bodyPtr, bodyLen)); try { const result = await hostFunctions!.http_request!(url, method, headers, body); // await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer) return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result)); } catch (e) { // t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情); // 取代只 return 1(WASM 寫無資訊的 "HTTP request failed")。 // writeOut 失敗(memory 壞)才 fallback return 1。 const errDetail = e instanceof Error ? e.message : String(e); const errEnv = new TextEncoder().encode( JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' }) ); return writeOut(memory.buffer, outPtr, outLenPtr, errEnv); } }) : () => 1, // kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key kv_get: hostFunctions?.kv_get ? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise => { if (!memory) { console.error('[kv_get] memory null'); return 1; } const key = new TextDecoder().decode(new Uint8Array(memory.buffer, keyPtr, keyLen)); console.error(`[kv_get] key="${key}" keyPtr=${keyPtr} keyLen=${keyLen} outPtr=${outPtr} outLenPtr=${outLenPtr}`); try { const result = await hostFunctions!.kv_get!(key); console.error(`[kv_get] result=${result === null ? 'null' : result.slice(0, 80)}`); if (result === null) return 2; const encoded = new TextEncoder().encode(result); const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded); console.error(`[kv_get] writeOut status=${status} encodedLen=${encoded.length} memBufLen=${memory.buffer.byteLength}`); return status; } catch (e) { console.error(`[kv_get] error: ${e}`); return 1; } }) : () => 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 => { if (!memory) return 1; const dec = new TextDecoder(); const key = dec.decode(new Uint8Array(memory.buffer, keyPtr, keyLen)); const value = dec.decode(new Uint8Array(memory.buffer, valPtr, valLen)); try { await hostFunctions!.kv_put!(key, value, ttlSeconds); return 0; } catch { return 1; } }) : () => 1, // crypto_decrypt — 已停用,永遠回 1(失敗)。 // // ⚠️ 不能整條移除:現役 auth_static_key / auth_service_account / auth_oauth2 的 // .wasm 仍宣告 `//go:wasmimport u6u crypto_decrypt`,import 缺項會讓 WASM // **instantiate 直接失敗**(不是呼叫才失敗)→ 所有認證零件全掛。故保留成 stub, // 讓連結成立。待三個零件的 Go 原始碼移除該 wasmimport 並重編 wasm 後,才可刪掉這條。 crypto_decrypt: () => 1, // crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功 crypto_sign_rs256: hostFunctions?.crypto_sign_rs256 ? hostWrap(async (dataPtr: number, dataLen: number, pkcs8Ptr: number, pkcs8Len: number, outPtr: number, outLenPtr: number): Promise => { if (!memory) return 1; // await 前複製 typed array(避免 memory grow 後 buffer 失效) const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen)); const pkcs8 = new Uint8Array(new Uint8Array(memory.buffer, pkcs8Ptr, pkcs8Len)); try { const sig = await hostFunctions!.crypto_sign_rs256!(data, pkcs8); return writeOut(memory.buffer, outPtr, outLenPtr, sig); } catch { return 1; } }) : () => 1, }, }, setMemory(mem: WebAssembly.Memory) { memory = mem; }, async run(instance: WebAssembly.Instance): Promise { const exp = instance.exports as Record; const startFn = (exp._start ?? exp.main) as (() => void) | undefined; if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export'); // eslint-disable-next-line @typescript-eslint/no-explicit-any const promisingFn = (WebAssembly as unknown as Record)['promising'] as ((fn: () => void) => () => Promise) | undefined; // 若環境支援 JSPI(Cloudflare Workers 2025+),優先使用 WebAssembly.promising // hostWrap() 已將 imports 包裝為 WebAssembly.Suspending,不需要 asyncify 協議 if (promisingFn) { try { await promisingFn(startFn)(); } catch (e) { if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; } return; } // JSPI 不可用:使用 asyncify 協議(需要 WASM 有 asyncify exports) const asyncifyGetState = exp.asyncify_get_state as (() => number) | undefined; const asyncifyStartUnwind = exp.asyncify_start_unwind as ((ptr: number) => void) | undefined; const asyncifyStopUnwind = exp.asyncify_stop_unwind as (() => void) | undefined; const asyncifyStartRewind = exp.asyncify_start_rewind as ((ptr: number) => void) | undefined; const asyncifyStopRewind = exp.asyncify_stop_rewind as (() => void) | undefined; if (asyncifyGetState && asyncifyStartUnwind && asyncifyStopUnwind && asyncifyStartRewind && asyncifyStopRewind) { asyncifyExports = { get_state: asyncifyGetState, start_unwind: asyncifyStartUnwind, stop_unwind: asyncifyStopUnwind, start_rewind: asyncifyStartRewind, stop_rewind: asyncifyStopRewind, }; const mallocFn = exp.malloc as ((size: number) => number) | undefined; if (mallocFn && memory) { const totalSize = ASYNCIFY_BUF_SIZE; asyncifyDataPtr = mallocFn(totalSize); const view = new DataView(memory.buffer); view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true); view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + totalSize, true); } else if (memory) { const memBytes = memory.buffer.byteLength; asyncifyDataPtr = memBytes - ASYNCIFY_BUF_SIZE; if (asyncifyDataPtr > 8) { const view = new DataView(memory.buffer); view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true); view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + ASYNCIFY_BUF_SIZE, true); } } } // JSPI 不可用且無 asyncify exports:同步執行(host function 不能 async) if (!asyncifyExports) { try { startFn(); } catch (e) { if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e; } return; } // 主執行迴圈:每次呼叫 _start,若 asyncify 捕捉到 pending promise 就 await 再 rewind let rewinding = false; while (true) { asyncifyPendingPromise = null; try { if (rewinding) { asyncifyExports.start_rewind(asyncifyDataPtr); startFn(); asyncifyExports.stop_rewind(); } else { startFn(); } } catch (e) { if (e instanceof Error && e.message === 'wasm exit: 0') break; throw e; } // 若 asyncifyWrap 觸發了 unwind,_start 會因 unwind 返回(沒有 exit) // asyncifyWrap 已呼叫 start_unwind,這裡只需 stop_unwind 並 await promise if (asyncifyPendingPromise !== null) { asyncifyExports.stop_unwind(); asyncifyResult = await asyncifyPendingPromise; asyncifyPendingPromise = null; rewinding = true; continue; } // 沒有 pending promise 且沒有 exit → 正常完成 break; } }, getStdout(): string { if (stdoutChunks.length === 0) return ''; const total = stdoutChunks.reduce((n, c) => n + c.length, 0); const merged = new Uint8Array(total); let offset = 0; for (const chunk of stdoutChunks) { merged.set(chunk, offset); offset += chunk.length; } return new TextDecoder().decode(merged); }, getStderr(): string { if (stderrChunks.length === 0) return ''; const total = stderrChunks.reduce((n, c) => n + c.length, 0); const merged = new Uint8Array(total); let offset = 0; for (const chunk of stderrChunks) { merged.set(chunk, offset); offset += chunk.length; } return new TextDecoder().decode(merged); }, }; return shim; } // ── Worker 端 host function 實作(Phase 0.6)────────────────────────────────── // // 唯一合法位置:RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。 // 由 component-loader 的 WASM runner 路徑呼叫,注入進 createWasiShim。 // // 安全邊界: // 1. `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他 env 機密 // 2. `kv_get` 依 key 前綴路由,且 `{api_key}:cred:*` 必須符合 stdin 傳入的 api_key(越權檢查) // 3. 未知前綴回傳 null(WASM 收到 kv_get 回傳 2 = 找不到) function hexToUint8Array(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); return bytes; } function base64ToUint8Array(b64: string): Uint8Array { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } /** * 依 key 前綴路由到對應 KV binding,並做越權檢查。 * - `auth_recipe:{service}` → env.RECIPES * - `{apiKey}:cred:{name}` → env.CREDENTIALS_KV(前綴必須等於 caller 的 apiKey) * - 其他前綴 → null(拒絕) */ async function routedKvGet(env: ArcrunHostEnv, apiKey: string, key: string): Promise { if (key.startsWith('auth_recipe:')) { return env.RECIPES.get(key); } const credMatch = key.match(/^([^:]+):cred:.+$/); if (credMatch) { if (credMatch[1] !== apiKey) { // 越權:WASM 嘗試讀其他租戶的 credential return null; } return env.CREDENTIALS_KV.get(key); } return null; } /** * 依 key 前綴路由寫入 KV。只允許寫 oauth2 cache key(短效 access_token)。 * - `{apiKey}:oauth2:{service}:*` → env.CREDENTIALS_KV(越權檢查) */ async function routedKvPut(env: ArcrunHostEnv, apiKey: string, key: string, value: string, ttlSeconds: number): Promise { const oauth2Match = key.match(/^([^:]+):oauth2:.+$/); if (oauth2Match && oauth2Match[1] === apiKey) { const opts = ttlSeconds > 0 ? { expirationTtl: ttlSeconds } : undefined; await env.CREDENTIALS_KV.put(key, value, opts); return; } // 其他 key 前綴拒絕寫入(安全邊界) } /** * RSASSA-PKCS1-v1_5 + SHA-256 簽章。private key 以 PKCS8 bytes 傳入(由 WASM 零件解析 PEM 後送進來)。 */ async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise { const cryptoKey = await crypto.subtle.importKey( 'pkcs8', pkcs8, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'], ); const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', cryptoKey, data); return new Uint8Array(sig); } /** * 讀取 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 本身的 * 其他機密(`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 / kv_put / crypto_sign_rs256 / secret_get)。 * 由 WASM runner(component-loader 的 WASM 路徑)呼叫,與 api_key 綁定以做越權檢查。 * * http_request 不由本 factory 提供 — auth primitive WASM 與 API WASM 零件若需要 * 發 HTTP,由呼叫者(component-loader)另外注入,以便個別限制可達主機。 */ export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): WasiHostFunctions { return { kv_get: (key: string) => routedKvGet(env, apiKey, key), kv_put: (key: string, value: string, ttlSeconds: number) => routedKvPut(env, apiKey, key, value, ttlSeconds), crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8), secret_get: async (ref: string) => secretGet(env, ref), }; }