feat(auth): auth_static_key WASM primitive + host functions

- wasi-shim gains kv_get / crypto_decrypt / crypto_sign_rs256 host
  functions with strict boundary (ENCRYPTION_KEY never exits Worker).
- registry/components/auth_static_key: TinyGo impl for API-key /
  Bearer / Basic Auth recipes (80% of supported services).
- .component-builds/auth_static_key: independent Worker at
  auth-static-key.arcrun.dev, imports wasi-shim cross-directory.
- cypher-executor/auth-dispatcher routes static_key recipes to the
  new Worker instead of credential-injector TS.

Replaces TS credential injection per
.agents/specs/arcrun/credential-primitives-wasm Phase 1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 16:54:18 +08:00
parent 6ee6fee8b9
commit 18f04448ce
10 changed files with 2290 additions and 9 deletions
@@ -0,0 +1,94 @@
/**
* Auth Dispatcher
*
* 對需要認證的零件,在執行前 HTTP POST 到對應的 auth primitive Worker,
* 取回 auth_headers / auth_query / auth_body 合併進節點 context。
*
* 嚴格邊界(rule 02 §2.2):
* - 本檔**不做**任何 credential 解密 / template 展開 / JWT 簽章
* - 那些全部在 auth primitive WASM 零件內執行(透過 host function `crypto_decrypt` 等)
* - 本檔只做「查 recipe 決定走哪個 primitive Worker」+「HTTP fetch 取回注入結果」
*
* 目前階段(Phase 2)接上 `auth_static_key` + `auth_service_account`,
* Phase 4(封測後)加 `auth_oauth2` / `auth_mtls`。
*
* 執行時機:graph-executor 在節點 runner 執行前呼叫,取回的 ctx 會:
* 1. 先試本 dispatcher(命中才 return enriched ctx)
* 2. 沒命中 fallback 到 `injectCredentials`(Phase 1.9 才刪除)
*/
import type { Bindings } from '../types';
import { resolveAuthRecipe } from '../routes/recipes';
import { wasmWorkerUrl } from '../lib/component-loader';
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account']);
/** auth primitive 本身的 componentId(避免自引用) */
const AUTH_PRIMITIVE_IDS = new Set([
'auth_static_key',
'auth_service_account',
'auth_oauth2',
'auth_mtls',
]);
/**
* 試著對零件做 auth 注入。
* - 命中(有對應 auth recipe 且 primitive 已支援)→ 回傳注入後的 ctx
* - 未命中 → 回傳 null(呼叫端繼續跑舊路徑)
*/
export async function tryAuthDispatch(
componentId: string,
input: Record<string, unknown>,
env: Bindings,
apiKey: string,
): Promise<Record<string, unknown> | null> {
if (AUTH_PRIMITIVE_IDS.has(componentId)) {
// auth primitive 本身不需要再做 auth
return null;
}
const recipe = await resolveAuthRecipe(componentId, env.RECIPES);
if (!recipe) return null;
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
// 走新路徑:HTTP POST 到對應 auth primitive Worker
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`);
const res = await fetch(primitiveUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'authenticate',
api_key: apiKey,
service: componentId,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`auth primitive "${recipe.primitive}" 回傳 ${res.status}: ${text.slice(0, 200)}`,
);
}
const result = await res.json().catch(() => null) as {
success?: boolean;
error?: string;
auth_headers?: Record<string, string>;
auth_query?: Record<string, string>;
auth_body?: Record<string, string>;
} | null;
if (!result || result.success === false) {
throw new Error(
`auth primitive 失敗: ${result?.error ?? '未知錯誤'}`,
);
}
return {
...input,
_auth_headers: result.auth_headers ?? {},
_auth_query: result.auth_query ?? {},
_auth_body: result.auth_body ?? {},
};
}
+180 -9
View File
@@ -7,6 +7,17 @@
* Requirements: 3.1, 3.3
*/
/**
* createArcrunHostFunctions 所需的最小 env 子集。
* 不直接依賴 cypher-executor 的 Bindings,讓 auth primitive Worker 這類
* 只綁 CREDENTIALS_KV / RECIPES / ENCRYPTION_KEY 的獨立 Worker 也能用。
*/
export interface ArcrunHostEnv {
CREDENTIALS_KV: KVNamespace;
RECIPES: KVNamespace;
ENCRYPTION_KEY: string;
}
const WASI_ESUCCESS = 0;
const WASI_ENOSYS = 76;
@@ -29,10 +40,20 @@ export interface WasiShim {
/**
* Host function 注入介面
* 讓 .wasm 零件能透過 host function 呼叫外部服務,而不需要網路 syscall
*
* 嚴格邊界:
* - encryption key 只在 `crypto_decrypt` host function 內部使用,永遠不傳給 WASM
* - `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<string>;
/** KV 讀取:key 前綴由 Worker 路由到對應 binding,並做越權檢查 */
kv_get?: (key: string) => Promise<string | null>;
/** AES-GCM 解密:encryption key 由 Worker 保管,不暴露給 WASM */
crypto_decrypt?: (encryptedB64: string, ivB64: string) => Promise<string>;
/** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
}
/**
@@ -54,6 +75,18 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
return new DataView(memory.buffer);
}
// 寫入結果到 WASM 的 outPtr bufferhost 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 陣列的資料寫入 fdstdout=1 或 stderr=2
* iovec 結構:{ buf: i32, buf_len: i32 }(各 4 byteslittle-endian
@@ -181,7 +214,7 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
path_link: () => WASI_ENOSYS,
},
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
// .wasm 零件用 //go:wasmimport u6u http_request 宣告
// .wasm 零件用 //go:wasmimport u6u <name> 宣告
u6u: {
http_request: hostFunctions?.http_request
? async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
@@ -196,17 +229,64 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
const body = dec.decode(new Uint8Array(buf, bodyPtr, bodyLen));
try {
const result = await hostFunctions!.http_request!(url, method, headers, body);
const encoded = new TextEncoder().encode(result);
// 寫入結果到 outPtr 指向的 buffer
const view = new DataView(buf);
new Uint8Array(buf, outPtr, encoded.length).set(encoded);
view.setUint32(outLenPtr, encoded.length, true);
return 0; // success
return writeOut(buf, outPtr, outLenPtr, new TextEncoder().encode(result));
} catch {
return 1; // error
return 1;
}
}
: () => 1, // host function 未注入時回傳錯誤
: () => 1,
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
kv_get: hostFunctions?.kv_get
? async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
const buf = memory.buffer;
const key = new TextDecoder().decode(new Uint8Array(buf, keyPtr, keyLen));
try {
const result = await hostFunctions!.kv_get!(key);
if (result === null) return 2;
return writeOut(buf, outPtr, outLenPtr, new TextEncoder().encode(result));
} catch {
return 1;
}
}
: () => 1,
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
// 輸入皆為 base64 字串(WASM 從 KV 讀到什麼就送什麼)
crypto_decrypt: hostFunctions?.crypto_decrypt
? async (encPtr: number, encLen: number, ivPtr: number, ivLen: number,
outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
const buf = memory.buffer;
const dec = new TextDecoder();
const encB64 = dec.decode(new Uint8Array(buf, encPtr, encLen));
const ivB64 = dec.decode(new Uint8Array(buf, ivPtr, ivLen));
try {
const plaintext = await hostFunctions!.crypto_decrypt!(encB64, ivB64);
return writeOut(buf, outPtr, outLenPtr, new TextEncoder().encode(plaintext));
} catch {
return 1;
}
}
: () => 1,
// crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功
crypto_sign_rs256: hostFunctions?.crypto_sign_rs256
? async (dataPtr: number, dataLen: number, pkcs8Ptr: number, pkcs8Len: number,
outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
const buf = memory.buffer;
const data = new Uint8Array(new Uint8Array(buf, dataPtr, dataLen));
const pkcs8 = new Uint8Array(new Uint8Array(buf, pkcs8Ptr, pkcs8Len));
try {
const sig = await hostFunctions!.crypto_sign_rs256!(data, pkcs8);
return writeOut(buf, outPtr, outLenPtr, sig);
} catch {
return 1;
}
}
: () => 1,
},
},
@@ -241,3 +321,94 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
return shim;
}
// ── Worker 端 host function 實作(Phase 0.6)──────────────────────────────────
//
// 唯一合法位置:AES-GCM 解密與 RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。
// 由 component-loader 的 WASM runner 路徑呼叫,注入進 createWasiShim。
//
// 安全邊界:
// 1. `ENCRYPTION_KEY` 只在 `crypto_decrypt` 內部讀 env,絕不經 stdin/回傳值傳給 WASM
// 2. `kv_get` 依 key 前綴路由,且 `{api_key}:cred:*` 必須符合 stdin 傳入的 api_key(越權檢查)
// 3. 未知前綴回傳 nullWASM 收到 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<string | null> {
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;
}
/**
* AES-GCM 解密。encryption key 由 env.ENCRYPTION_KEY 在本 function 內讀取,
* 永不傳給 WASM。輸入為 base64 字串,輸出為 UTF-8 plaintext。
*/
async function aesGcmDecrypt(env: ArcrunHostEnv, encryptedB64: string, ivB64: string): Promise<string> {
const keyBytes = hexToUint8Array(env.ENCRYPTION_KEY);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToUint8Array(ivB64) },
cryptoKey,
base64ToUint8Array(encryptedB64),
);
return new TextDecoder().decode(plaintext);
}
/**
* RSASSA-PKCS1-v1_5 + SHA-256 簽章。private key 以 PKCS8 bytes 傳入(由 WASM 零件解析 PEM 後送進來)。
*/
async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise<Uint8Array> {
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);
}
/**
* 建立 arcrun host function 組合(kv_get / crypto_decrypt / crypto_sign_rs256)。
* 由 WASM runnercomponent-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),
crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64),
crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8),
};
}