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:
@@ -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 ?? {},
|
||||
};
|
||||
}
|
||||
@@ -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 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)
|
||||
@@ -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. 未知前綴回傳 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<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 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),
|
||||
crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64),
|
||||
crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user