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
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 ?? {},
};
}
+180 -9
View File
@@ -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 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 * fd_write: 將 iovec 陣列的資料寫入 fdstdout=1 或 stderr=2
* iovec 結構:{ buf: i32, buf_len: i32 }(各 4 byteslittle-endian * iovec 結構:{ buf: i32, buf_len: i32 }(各 4 byteslittle-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. 未知前綴回傳 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),
};
}
@@ -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
+276
View File
@@ -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
}