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:
+1539
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "arcrun-auth-static-key",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250408.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* arcrun auth_static_key Worker
|
||||||
|
*
|
||||||
|
* POST / → JSON input {action, api_key, service} → WASM (WASI preview1 stdin/stdout) → JSON output
|
||||||
|
*
|
||||||
|
* 方案 A:直接 import cypher-executor/src/lib/wasi-shim.ts 的 shim + host function factory,
|
||||||
|
* 確保 AES-GCM 解密邏輯只存在於一個檔案(rule 02 §2.2)。
|
||||||
|
*
|
||||||
|
* 安全邊界:
|
||||||
|
* - api_key 經 stdin 傳進 WASM,同時綁到 host function 的 kv_get 做越權檢查
|
||||||
|
* - ENCRYPTION_KEY 只存在於 host function 的 closure 中,不會進入 WASM 記憶體
|
||||||
|
*/
|
||||||
|
|
||||||
|
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import {
|
||||||
|
createWasiShim,
|
||||||
|
createArcrunHostFunctions,
|
||||||
|
type ArcrunHostEnv,
|
||||||
|
} from '../../../cypher-executor/src/lib/wasi-shim';
|
||||||
|
|
||||||
|
type Env = ArcrunHostEnv;
|
||||||
|
|
||||||
|
const app = new Hono<{ Bindings: Env }>();
|
||||||
|
app.use('*', cors());
|
||||||
|
|
||||||
|
app.get('/', (c) => c.json({ ok: true, component: 'auth_static_key' }));
|
||||||
|
|
||||||
|
app.post('/', async (c) => {
|
||||||
|
let input: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
input = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ success: false, error: 'request body must be JSON' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = typeof input.api_key === 'string' ? input.api_key : '';
|
||||||
|
if (!apiKey) {
|
||||||
|
return c.json({ success: false, error: 'api_key 必填' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWasm(c.env, apiKey, input);
|
||||||
|
return c.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return c.json(
|
||||||
|
{ success: false, error: e instanceof Error ? e.message : String(e) },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
|
|
||||||
|
// ── WASM runner ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runWasm(env: Env, apiKey: string, input: unknown): Promise<unknown> {
|
||||||
|
const stdinData = JSON.stringify(input);
|
||||||
|
const hostFunctions = createArcrunHostFunctions(env, apiKey);
|
||||||
|
const shim = createWasiShim(stdinData, hostFunctions);
|
||||||
|
|
||||||
|
const instance = await WebAssembly.instantiate(
|
||||||
|
componentWasm as WebAssembly.Module,
|
||||||
|
shim.imports,
|
||||||
|
);
|
||||||
|
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
|
||||||
|
|
||||||
|
const start = (instance.exports._start ?? instance.exports.main) as () => void;
|
||||||
|
if (typeof start !== 'function') {
|
||||||
|
throw new Error('WASM missing _start or main export');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
start();
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = shim.getStdout().trim();
|
||||||
|
if (!stdout) throw new Error('WASM component produced no output');
|
||||||
|
return JSON.parse(stdout);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name = "arcrun-auth-static-key"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-02-19"
|
||||||
|
compatibility_flags = ["nodejs_compat"]
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
COMPONENT_ID = "auth_static_key"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
pattern = "auth-static-key.arcrun.dev/*"
|
||||||
|
zone_name = "arcrun.dev"
|
||||||
|
|
||||||
|
# 與 cypher-executor/wrangler.toml 同一組 KV namespace
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "CREDENTIALS_KV"
|
||||||
|
id = "e7f4320f88d343f187e35e3543dd74c9"
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "RECIPES"
|
||||||
|
id = "9cf9db905c6241f78503199e58b2ffe0"
|
||||||
|
|
||||||
|
# ENCRYPTION_KEY 透過 wrangler secret set 設定
|
||||||
|
# wrangler secret put ENCRYPTION_KEY
|
||||||
@@ -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
|
* 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_ESUCCESS = 0;
|
||||||
const WASI_ENOSYS = 76;
|
const WASI_ENOSYS = 76;
|
||||||
|
|
||||||
@@ -29,10 +40,20 @@ export interface WasiShim {
|
|||||||
/**
|
/**
|
||||||
* Host function 注入介面
|
* Host function 注入介面
|
||||||
* 讓 .wasm 零件能透過 host function 呼叫外部服務,而不需要網路 syscall
|
* 讓 .wasm 零件能透過 host function 呼叫外部服務,而不需要網路 syscall
|
||||||
|
*
|
||||||
|
* 嚴格邊界:
|
||||||
|
* - encryption key 只在 `crypto_decrypt` host function 內部使用,永遠不傳給 WASM
|
||||||
|
* - `kv_get` 必須在 Worker 側檢查 key 前綴以防越權(見 auth-dispatcher.ts)
|
||||||
*/
|
*/
|
||||||
export interface WasiHostFunctions {
|
export interface WasiHostFunctions {
|
||||||
/** HTTP 請求 host function:.wasm 呼叫此函數發出 HTTP 請求 */
|
/** HTTP 請求 host function:.wasm 呼叫此函數發出 HTTP 請求 */
|
||||||
http_request?: (url: string, method: string, headers: string, body: string) => Promise<string>;
|
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);
|
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)
|
* fd_write: 將 iovec 陣列的資料寫入 fd(stdout=1 或 stderr=2)
|
||||||
* iovec 結構:{ buf: i32, buf_len: i32 }(各 4 bytes,little-endian)
|
* 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,
|
path_link: () => WASI_ENOSYS,
|
||||||
},
|
},
|
||||||
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
|
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
|
||||||
// .wasm 零件用 //go:wasmimport u6u http_request 宣告
|
// .wasm 零件用 //go:wasmimport u6u <name> 宣告
|
||||||
u6u: {
|
u6u: {
|
||||||
http_request: hostFunctions?.http_request
|
http_request: hostFunctions?.http_request
|
||||||
? async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
|
? 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));
|
const body = dec.decode(new Uint8Array(buf, bodyPtr, bodyLen));
|
||||||
try {
|
try {
|
||||||
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
||||||
const encoded = new TextEncoder().encode(result);
|
return writeOut(buf, outPtr, outLenPtr, 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
|
|
||||||
} catch {
|
} 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;
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
canonical_id: "auth_static_key"
|
||||||
|
display_name: "Auth Primitive — Static Key"
|
||||||
|
category: "auth"
|
||||||
|
version: "v1"
|
||||||
|
wasi_target: "preview1"
|
||||||
|
stability: "floating"
|
||||||
|
runtime_compat:
|
||||||
|
- "cf-workers"
|
||||||
|
- "workerd"
|
||||||
|
- "wazero"
|
||||||
|
constraints:
|
||||||
|
max_size_kb: 2048
|
||||||
|
max_cold_start_ms: 50
|
||||||
|
no_network_syscall: true
|
||||||
|
no_filesystem_syscall: true
|
||||||
|
io_model: "stdin_stdout_json"
|
||||||
|
input_schema:
|
||||||
|
type: object
|
||||||
|
required: [action, api_key, service]
|
||||||
|
properties:
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [authenticate]
|
||||||
|
description: 目前僅支援 authenticate;static_key 無 refresh 概念
|
||||||
|
api_key:
|
||||||
|
type: string
|
||||||
|
description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key
|
||||||
|
service:
|
||||||
|
type: string
|
||||||
|
description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄
|
||||||
|
request:
|
||||||
|
type: object
|
||||||
|
description: (保留)下游零件的 HTTP request 上下文;static_key 當前不使用
|
||||||
|
output_schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
auth_headers:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
auth_query:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
auth_body:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
runtime:
|
||||||
|
type: object
|
||||||
|
description: Static key 不使用;欄位保留以對齊其他 auth primitive
|
||||||
|
gherkin_tests:
|
||||||
|
- scenario: "缺少 api_key"
|
||||||
|
given: '{"action":"authenticate","service":"openai"}'
|
||||||
|
then_contains: '{"success":false'
|
||||||
|
- scenario: "找不到 auth recipe"
|
||||||
|
given: '{"action":"authenticate","api_key":"ak_nonexistent","service":"nonexistent"}'
|
||||||
|
then_contains: '{"success":false'
|
||||||
|
tags: [auth, credential, primitive, static_key]
|
||||||
|
description: "Static key auth primitive。讀取 auth_recipe + 解密 required_secrets + 展開 {{secret.X}} 模板,回傳 auth_headers / auth_query / auth_body。涵蓋 Bearer token / API key / Basic auth / 自訂 header 等 80% 服務。透過 host function kv_get + crypto_decrypt,plaintext 永不離開 WASM。"
|
||||||
|
config_example: |
|
||||||
|
auth_step:
|
||||||
|
component: "auth_static_key"
|
||||||
|
action: "authenticate"
|
||||||
|
service: "openai" # 對應 auth_recipe:openai 的 KV 記錄
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module component
|
||||||
|
|
||||||
|
go 1.21
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
// auth_static_key — static key auth primitive
|
||||||
|
//
|
||||||
|
// 讀取 auth_recipe:{service} + 解密 required_secrets + 展開 {{secret.X}} 模板,
|
||||||
|
// 回傳 auth_headers / auth_query / auth_body。
|
||||||
|
//
|
||||||
|
// 所有外部 I/O 都透過 host function:
|
||||||
|
// - u6u.kv_get — 依 key 前綴路由到 RECIPES / CREDENTIALS_KV (host 做越權檢查)
|
||||||
|
// - u6u.crypto_decrypt — AES-GCM 解密 (encryption key 由 host 持有,不暴露給 WASM)
|
||||||
|
//
|
||||||
|
//go:build tinygo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功 / 1 錯誤 / 2 找不到
|
||||||
|
//
|
||||||
|
//go:wasmimport u6u kv_get
|
||||||
|
func hostKvGet(
|
||||||
|
keyPtr uintptr, keyLen uint32,
|
||||||
|
outPtr uintptr, outLenPtr uintptr,
|
||||||
|
) uint32
|
||||||
|
|
||||||
|
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
|
||||||
|
// enc/iv 為 base64 字串(即 KV 中儲存的格式)
|
||||||
|
//
|
||||||
|
//go:wasmimport u6u crypto_decrypt
|
||||||
|
func hostCryptoDecrypt(
|
||||||
|
encPtr uintptr, encLen uint32,
|
||||||
|
ivPtr uintptr, ivLen uint32,
|
||||||
|
outPtr uintptr, outLenPtr uintptr,
|
||||||
|
) uint32
|
||||||
|
|
||||||
|
// ── 型別 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Input struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Request json.RawMessage `json:"request,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SecretRequirement struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Optional bool `json:"optional,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthInjectSpec struct {
|
||||||
|
Header map[string]string `json:"header,omitempty"`
|
||||||
|
Query map[string]string `json:"query,omitempty"`
|
||||||
|
Body map[string]string `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthRecipe struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
Primitive string `json:"primitive"`
|
||||||
|
RequiredSecrets []SecretRequirement `json:"required_secrets"`
|
||||||
|
Inject AuthInjectSpec `json:"inject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EncryptedRecord struct {
|
||||||
|
Encrypted string `json:"encrypted"`
|
||||||
|
IV string `json:"iv"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
raw, err := io.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
writeError("failed to read stdin: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var input Input
|
||||||
|
if err := json.Unmarshal(raw, &input); err != nil {
|
||||||
|
writeError("invalid input JSON: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.APIKey == "" {
|
||||||
|
writeError("api_key 必填")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Service == "" {
|
||||||
|
writeError("service 必填")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Action != "" && input.Action != "authenticate" {
|
||||||
|
writeError("auth_static_key 僅支援 action=authenticate")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 讀 auth recipe
|
||||||
|
recipeJSON, status := kvGet("auth_recipe:" + input.Service)
|
||||||
|
if status == 2 {
|
||||||
|
writeError("找不到 auth recipe: " + input.Service)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status != 0 {
|
||||||
|
writeError("kv_get 失敗(auth_recipe)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var recipe AuthRecipe
|
||||||
|
if err := json.Unmarshal([]byte(recipeJSON), &recipe); err != nil {
|
||||||
|
writeError("auth recipe JSON 解析失敗: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if recipe.Primitive != "static_key" {
|
||||||
|
writeError("auth recipe " + input.Service + " 的 primitive 不是 static_key (是 " + recipe.Primitive + ")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 解密所有 non-optional required_secrets
|
||||||
|
secrets := make(map[string]string)
|
||||||
|
for _, req := range recipe.RequiredSecrets {
|
||||||
|
if req.Optional {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kvKey := input.APIKey + ":cred:" + req.Key
|
||||||
|
encJSON, s := kvGet(kvKey)
|
||||||
|
if s == 2 {
|
||||||
|
writeError("缺少 credential: " + req.Key + " (" + req.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s != 0 {
|
||||||
|
writeError("kv_get 失敗(credential " + req.Key + ")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rec EncryptedRecord
|
||||||
|
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
|
||||||
|
writeError("credential " + req.Key + " 格式錯誤: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
|
||||||
|
if !ok {
|
||||||
|
writeError("credential " + req.Key + " 解密失敗")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secrets[req.Key] = plaintext
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 展開模板 (static_key 沒有 runtime,傳空 map)
|
||||||
|
runtime := map[string]string{}
|
||||||
|
authHeaders := interpolateRecord(recipe.Inject.Header, secrets, runtime)
|
||||||
|
authQuery := interpolateRecord(recipe.Inject.Query, secrets, runtime)
|
||||||
|
authBody := interpolateRecord(recipe.Inject.Body, secrets, runtime)
|
||||||
|
|
||||||
|
// 4. 輸出
|
||||||
|
out, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"auth_headers": authHeaders,
|
||||||
|
"auth_query": authQuery,
|
||||||
|
"auth_body": authBody,
|
||||||
|
"runtime": runtime,
|
||||||
|
})
|
||||||
|
os.Stdout.Write(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func writeError(msg string) {
|
||||||
|
out, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": msg,
|
||||||
|
"auth_headers": map[string]string{},
|
||||||
|
"auth_query": map[string]string{},
|
||||||
|
"auth_body": map[string]string{},
|
||||||
|
})
|
||||||
|
os.Stdout.Write(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
|
||||||
|
func kvGet(key string) (string, uint32) {
|
||||||
|
keyBytes := []byte(key)
|
||||||
|
outBuf := make([]byte, 65536)
|
||||||
|
var outLen uint32
|
||||||
|
|
||||||
|
status := hostKvGet(
|
||||||
|
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||||
|
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||||
|
)
|
||||||
|
if status != 0 {
|
||||||
|
return "", status
|
||||||
|
}
|
||||||
|
return string(outBuf[:outLen]), 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// cryptoDecrypt 呼叫 host function 做 AES-GCM 解密
|
||||||
|
// enc/iv 均為 base64 字串;回傳 UTF-8 plaintext
|
||||||
|
func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||||
|
encBytes := []byte(encB64)
|
||||||
|
ivBytes := []byte(ivB64)
|
||||||
|
outBuf := make([]byte, 65536)
|
||||||
|
var outLen uint32
|
||||||
|
|
||||||
|
// 處理空字串的防呆(TinyGo 取 &[]byte{}[0] 會 panic)
|
||||||
|
if len(encBytes) == 0 || len(ivBytes) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
status := hostCryptoDecrypt(
|
||||||
|
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
|
||||||
|
uintptr(unsafe.Pointer(&ivBytes[0])), uint32(len(ivBytes)),
|
||||||
|
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||||
|
)
|
||||||
|
if status != 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(outBuf[:outLen]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// interpolateTemplate 展開 {{secret.X}} 與 {{runtime.X}}。未知 key 展開為空字串(與 TS 版 parity)。
|
||||||
|
// 其他 namespace 的 {{...}} 原樣保留(static_key 不解析)。
|
||||||
|
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(template))
|
||||||
|
i := 0
|
||||||
|
for i < len(template) {
|
||||||
|
start := strings.Index(template[i:], "{{")
|
||||||
|
if start < 0 {
|
||||||
|
b.WriteString(template[i:])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b.WriteString(template[i : i+start])
|
||||||
|
openIdx := i + start
|
||||||
|
closeRel := strings.Index(template[openIdx+2:], "}}")
|
||||||
|
if closeRel < 0 {
|
||||||
|
b.WriteString(template[openIdx:])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
inner := template[openIdx+2 : openIdx+2+closeRel]
|
||||||
|
advance := openIdx + 2 + closeRel + 2
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(inner, "secret."):
|
||||||
|
key := inner[len("secret."):]
|
||||||
|
b.WriteString(secrets[key])
|
||||||
|
case strings.HasPrefix(inner, "runtime."):
|
||||||
|
key := inner[len("runtime."):]
|
||||||
|
b.WriteString(runtime[key])
|
||||||
|
default:
|
||||||
|
// 非本 primitive 負責的 namespace,原樣寫回
|
||||||
|
b.WriteString(template[openIdx:advance])
|
||||||
|
}
|
||||||
|
i = advance
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func interpolateRecord(
|
||||||
|
record map[string]string,
|
||||||
|
secrets, runtime map[string]string,
|
||||||
|
) map[string]string {
|
||||||
|
if record == nil {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
result := make(map[string]string, len(record))
|
||||||
|
for k, v := range record {
|
||||||
|
result[k] = interpolateTemplate(v, secrets, runtime)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user