Files
Arcrun/cypher-executor/src/actions/auth-dispatcher.ts
T
Leo 20c7610371 refactor: 移除已廢棄的自管加密金鑰機制(credential 全面託管 CF Workers Secrets)
leo 2026-07-20 明令:「已經改用 cf 自己的 secrets,不要再說它了」
「我希望以後再也看不到這個詞再出現」

背景:credential 早已遷移至 CF Workers per-script Secrets + D1 目錄,
舊的自管金鑰(client 端 AES-GCM + KV 密文 + crypto_decrypt)是遷移期遺留。
本次連根移除,含一併作廢的死 SaaS 碼。

移除:
- 舊 KV 密文解密路徑(credential-injector.ts 整檔、dual-read fallback)
  前置驗證:leo21c / youlin 兩帳號 CREDENTIALS_KV 實測 *:cred:* 皆 0 筆
- migrate-to-workers-secrets 搬家端點(回填已完成,無可回填)
- /register 路由與 generateApiKey(HMAC 產 ak_ key 是 SaaS 遺物;
  self-hosted 走 namespace 明碼 D21,已無人使用)
- platform_crypto component(三帳號實測 404 已退役,無 workflow 引用)

保留(附理由):
- crypto_decrypt 保留為永遠回失敗的 stub——現役三個 auth .wasm 仍宣告該
  import,缺項會讓 WASM instantiate 直接失敗。待零件重編後可真正刪除。

順帶修復(原不在範圍,但會實際壞事):
- /auth/callback 有 `if (!key) redirect(server_error)` 閘,未設該 secret 的
  實例會登入直接失敗 → 已移除
- OAuth 兩處把 provider token 寫進舊加密 KV(租戶鍵與實際 api_key 在 rotate
  後必然分歧,已失效)→ 改導向 Workers Secrets,包 try/catch 不影響登入
- acr init Standard 模式呼叫已刪除的 /register → 改引導 OAuth 取 key
- .claude/rules 與 system-dev/docs 是同一規範的兩份鏡像,先前只改 rules
  導致鏡像仍在教舊做法 → 已同步(此類雙檔同步應納入檢查)

新用戶安裝從此零 secret 前置。
測試 187/188(唯一 fail 為 pre-existing,stash 驗證與本次無關);
cypher-executor 與 cli typecheck 全綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:32:48 +08:00

302 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `secret_get` 等)
* - 本檔只做「查 recipe 決定走哪個 primitive Worker」+「HTTP fetch 取回注入結果」
*
* 目前階段接上 `auth_static_key` + `auth_service_account` + `auth_oauth2`,
* Phase 4 剩 `auth_mtls`mTLS handshake 在 Worker runtime 層)。
*
* 執行時機:graph-executor 在節點 runner 執行前呼叫,取回的 ctx 會:
* 1. 本 dispatcher 命中 → return enriched ctx
* 2. 沒命中 → ctx 原樣往下(T10 起舊的 injectCredentials 雙讀 fallback 已移除)
*/
import type { Bindings } from '../types';
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
import { wasmWorkerUrl } from '../lib/component-loader';
import { createArcrunHostFunctions } from '../lib/wasi-shim';
// ── credential-store 遷移 T6/T7(方案 A,D19)────────────────────────────────
//
// 密文值住 cypher-executor 自己的 per-script secretsT5 寫入)。解密發生在獨立的
// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。
// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref]T4)取明文
// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有
// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。
//
// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。
// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function
// 內,解密/注入邏輯仍全在 WASM 零件。
/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */
interface CredentialRefRow {
name: string;
secret_ref: string;
}
/**
* 對一組 credential name,從新家(cypher per-script secrets)取明文。
*
* 流程:查 D1 `credentials`api_key + name)拿 `secret_ref` → 用 `secret_get(ref)`
* host function,實作 = env[ref])取值。
*
* ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、
* 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!),
* 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。
*
* 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。
*
* D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback),
* 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。
*/
export async function resolveSecretsFromNewHome(
env: Bindings,
apiKey: string,
names: string[],
): Promise<Record<string, string>> {
const resolved: Record<string, string> = {};
if (names.length === 0) return resolved;
const db = env.CREDENTIALS_DB;
if (!db) return resolved; // 未綁 D1 → 整組走 fallback
// 1. 查 D1 拿每個 name 的 secret_ref
let rows: CredentialRefRow[];
try {
const placeholders = names.map(() => '?').join(', ');
const result = await db
.prepare(
`SELECT name, secret_ref FROM credentials
WHERE api_key = ? AND name IN (${placeholders})`,
)
.bind(apiKey, ...names)
.all<CredentialRefRow>();
rows = result.results ?? [];
} catch {
// D1 未建表 / query 失敗 → 過渡期整組走 fallback(雙讀),不假綠
return resolved;
}
if (rows.length === 0) return resolved;
// 2. 用 secret_ref 從新家取值(host function secret_get = env[ref]
const secretGet = createArcrunHostFunctions(env, apiKey).secret_get;
if (!secretGet) return resolved; // host function 未就緒 → 走 fallback
const resolvedNames: string[] = [];
for (const row of rows) {
const value = await secretGet(row.secret_ref);
// null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV
if (value === null) continue;
resolved[row.name] = value;
resolvedNames.push(row.name);
}
// 3. 順手更新 last_used_at(只更新真的從新家取到值的 name)
if (resolvedNames.length > 0) {
try {
const now = Math.floor(Date.now() / 1000);
const placeholders = resolvedNames.map(() => '?').join(', ');
await db
.prepare(
`UPDATE credentials SET last_used_at = ?
WHERE api_key = ? AND name IN (${placeholders})`,
)
.bind(now, apiKey, ...resolvedNames)
.run();
} catch {
// last_used 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑)
}
}
return resolved;
}
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']);
/** 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;
}
// 決定 auth service name
// 1. 若 API recipe 宣告了 auth_service(例 recipe:kbdb_get → "kbdb")→ 用它,
// 讓多個 recipe 共用同一把 auth_recipe(不必每個 action 複製 auth recipe)。
// 2. 否則 fallback 到把 componentId 當 service name(向後相容舊行為)。
let service = componentId;
const apiRecipe = await resolveRecipe(componentId, env.RECIPES);
if (apiRecipe?.auth_service) {
service = apiRecipe.auth_service;
}
const recipe = await resolveAuthRecipe(service, env.RECIPES);
if (!recipe) return null;
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
// T6:先從新家(cypher per-script secrets)取 recipe 需要的 credential 明文。
// 拿到的塞進 `resolved_secrets`WASM 優先用;沒拿到的 name 缺席 → WASM fallback 舊 KVT7)。
const secretNames = recipe.required_secrets
.filter(s => !s.optional)
.map(s => s.key);
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, secretNames);
// 走新路徑:HTTP POST 到對應 auth primitive Worker
// 走 workers.dev 避開同 zone 死鎖(P0 #9
const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN);
const res = await fetch(primitiveUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'authenticate',
api_key: apiKey,
service,
// 只在有取到值時帶上(空物件也無妨,WASM 對 nil/空 map 同樣 fallback
resolved_secrets: resolvedSecrets,
}),
});
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>;
auth_path?: 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 ?? {},
_auth_path: result.auth_path ?? {},
};
}
// ── 用戶面 {{credential.NAME}} 注入(design §8)────────────────────────────────
/** 匹配 {{credential.NAME}}NAME 為 word 字元) */
const CREDENTIAL_REF = /\{\{credential\.(\w+)\}\}/g;
/** 遞迴收集任意值(string / 物件 / 陣列)裡所有 {{credential.NAME}} 的 NAME */
function collectCredentialNames(value: unknown, out: Set<string>): void {
if (typeof value === 'string') {
for (const m of value.matchAll(CREDENTIAL_REF)) out.add(m[1]);
} else if (Array.isArray(value)) {
for (const v of value) collectCredentialNames(v, out);
} else if (value && typeof value === 'object') {
for (const v of Object.values(value as Record<string, unknown>)) collectCredentialNames(v, out);
}
}
/** 遞迴把 {{credential.NAME}} 替換成 resolved[NAME](未知 name 原樣保留) */
function replaceCredentialRefs(value: unknown, resolved: Record<string, string>): unknown {
if (typeof value === 'string') {
return value.replace(CREDENTIAL_REF, (orig, name: string) =>
Object.prototype.hasOwnProperty.call(resolved, name) ? resolved[name] : orig,
);
}
if (Array.isArray(value)) return value.map((v) => replaceCredentialRefs(v, resolved));
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = replaceCredentialRefs(v, resolved);
}
return out;
}
return value;
}
/**
* 展開節點 data 裡用戶寫的 `{{credential.NAME}}`design §8)。
*
* 嚴格邊界(rule 02 §2.2):本函式**不解密**。偵測到 {{credential.X}} 後,把 names 交給
* auth_static_key WASM 的 `resolve_credentials` actionWASM 內 kv_get + crypto_decrypt),
* 拿回明文後只做字串回填。本檔不解密、不持有任何金鑰。
*
* - 無 {{credential.}} → 原樣回傳(不打 WASM,零開銷)
* - 解密失敗 / 缺 credential → throw(誠實報錯,不假綠)
*/
export async function resolveCredentialRefs(
data: Record<string, unknown>,
env: Bindings,
apiKey: string,
): Promise<Record<string, unknown>> {
const names = new Set<string>();
collectCredentialNames(data, names);
if (names.size === 0) return data;
const nameList = [...names];
// T6:先從新家取這些 credential 的明文,塞 `resolved_secrets`WASM 優先用它,
// 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KVT7)。
const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList);
const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'resolve_credentials',
api_key: apiKey,
names: nameList,
resolved_secrets: resolvedSecrets,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`credential resolve 回傳 ${res.status}: ${text.slice(0, 200)}`);
}
const result = (await res.json().catch(() => null)) as {
success?: boolean;
error?: string;
credentials?: Record<string, string>;
} | null;
if (!result || result.success === false) {
throw new Error(`credential resolve 失敗: ${result?.error ?? '未知錯誤'}`);
}
return replaceCredentialRefs(data, result.credentials ?? {}) as Record<string, unknown>;
}