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>
This commit is contained in:
2026-07-21 01:32:16 +08:00
committed by uncle6me-web
parent b9b94d7852
commit 20c7610371
64 changed files with 214 additions and 2197 deletions
@@ -6,15 +6,15 @@
*
* 嚴格邊界(rule 02 §2.2):
* - 本檔**不做**任何 credential 解密 / template 展開 / JWT 簽章
* - 那些全部在 auth primitive WASM 零件內執行(透過 host function `crypto_decrypt` 等)
* - 那些全部在 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. 沒命中 fallback 到 `injectCredentials`(Phase 1.9 才刪除)
* 1. 本 dispatcher 命中 return enriched ctx
* 2. 沒命中 → ctx 原樣往下(T10 起舊的 injectCredentials 雙讀 fallback 已移除)
*/
import type { Bindings } from '../types';
@@ -250,7 +250,7 @@ function replaceCredentialRefs(value: unknown, resolved: Record<string, string>)
*
* 嚴格邊界(rule 02 §2.2):本函式**不解密**。偵測到 {{credential.X}} 後,把 names 交給
* auth_static_key WASM 的 `resolve_credentials` actionWASM 內 kv_get + crypto_decrypt),
* 拿回明文後只做字串回填。ENCRYPTION_KEY 永不經此處
* 拿回明文後只做字串回填。本檔不解密、不持有任何金鑰
*
* - 無 {{credential.}} → 原樣回傳(不打 WASM,零開銷)
* - 解密失敗 / 缺 credential → throw(誠實報錯,不假綠)
@@ -1,235 +0,0 @@
/**
* Credential Injector
*
* 執行順序:
* 1. 檢查是否有對應的 auth recipeauth_recipe:{componentId} in RECIPES KV
* → 有:走 auth recipe 路徑(支援 static_key, service_account
* → 無:走舊有 flat injection 路徑(向後相容)
*
* Auth Recipe 路徑:
* - static_key:展開 inject.header/query/body 的 {{secret.KEY}} 模板
* - service_accountJWT signing → token exchange → 展開 {{runtime.access_token}}
* - 注入結果以 _auth_headers / _auth_query / _auth_body 攜帶,不污染業務欄位
*
* 舊有路徑(向後相容):
* - 從 RECIPES KV 讀取 credentials_required(動態 recipe
* - 或從 BUILTIN_CREDENTIALS_MAP(內建清單)
* - 解密後以 inject_as 欄位名稱直接注入 context
*/
import type { Bindings } from '../types';
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
import type { AuthRecipeDefinition } from '../routes/recipes';
export interface CredentialRequirement {
key: string; // CREDENTIALS_KV 的 credential 名稱(如 gmail_token
inject_as: string; // 注入到 input 的欄位名稱(如 access_token
}
/** 內建 API recipe 的 credentials_required(對應 component-loader 的 BUILTIN_API_RECIPES*/
const BUILTIN_CREDENTIALS_MAP: Record<string, CredentialRequirement[]> = {
gmail: [{ key: 'gmail_token', inject_as: 'access_token' }],
google_sheets: [{ key: 'google_oauth', inject_as: 'access_token' }],
telegram: [{ key: 'telegram_bot_token', inject_as: 'bot_token' }],
line_notify: [{ key: 'line_token', inject_as: 'token' }],
};
// ── AES-GCM 解密 ──────────────────────────────────────────────────────────────
async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise<string> {
const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string };
const keyBytes = hexToUint8Array(encryptionKey);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToUint8Array(iv) },
cryptoKey,
base64ToUint8Array(encrypted),
);
return new TextDecoder().decode(decrypted);
}
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;
}
// ── 解密所有 required_secrets → { key: decryptedValue } ──────────────────────
async function decryptSecrets(
recipe: AuthRecipeDefinition,
apiKey: string,
env: Bindings,
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
for (const req of recipe.required_secrets) {
if (req.optional) continue;
const kvKey = `${apiKey}:cred:${req.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${req.key}${req.label}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 加入 ${req.key}: "your-value"\n` +
` 2. 執行:acr creds push`,
);
}
result[req.key] = await decryptCredential(record, env.ENCRYPTION_KEY);
}
return result;
}
// ── Template 展開:{{secret.KEY}} 和 {{runtime.KEY}} ─────────────────────────
function interpolateTemplate(
template: string,
secrets: Record<string, string>,
runtime: Record<string, string>,
): string {
return template.replace(/\{\{(secret|runtime)\.(\w+)\}\}/g, (_, ns, key) => {
if (ns === 'secret') return secrets[key] ?? '';
if (ns === 'runtime') return runtime[key] ?? '';
return '';
});
}
function interpolateRecord(
record: Record<string, string>,
secrets: Record<string, string>,
runtime: Record<string, string>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(record)) {
result[k] = interpolateTemplate(v, secrets, runtime);
}
return result;
}
// ── Auth Recipe 注入(新路徑)────────────────────────────────────────────────
async function injectFromAuthRecipe(
recipe: AuthRecipeDefinition,
input: Record<string, unknown>,
env: Bindings,
apiKey: string,
): Promise<Record<string, unknown>> {
// 解密所有 required_secrets
const secrets = await decryptSecrets(recipe, apiKey, env);
// runtime tokenservice_account 路徑已改走 auth-dispatcher → auth_service_account WASM;
// 這條 TS fallback 只處理 static_key (runtime 為空即可),service_account 永遠不會走到這裡
const runtime: Record<string, string> = {};
if (recipe.primitive === 'service_account') {
throw new Error(
`service_account primitive 應由 auth-dispatcher → auth_service_account WASM 處理,` +
`不應進到 credential-injector TS fallback (service=${recipe.service})`,
);
}
// 展開 inject 模板
const authHeaders = recipe.inject.header
? interpolateRecord(recipe.inject.header, secrets, runtime)
: {};
const authQuery = recipe.inject.query
? interpolateRecord(recipe.inject.query, secrets, runtime)
: {};
const authBody = recipe.inject.body
? interpolateRecord(recipe.inject.body, secrets, runtime)
: {};
return {
...input,
_auth_headers: authHeaders,
_auth_query: authQuery,
_auth_body: authBody,
};
}
// ── 舊有路徑:flat injection(向後相容)──────────────────────────────────────
async function loadCredentialsRequired(
componentId: string,
env: Bindings,
): Promise<CredentialRequirement[]> {
const recipe = await resolveRecipe(componentId, env.RECIPES);
if (recipe?.credentials_required?.length) {
return recipe.credentials_required;
}
return BUILTIN_CREDENTIALS_MAP[componentId] ?? [];
}
// ── 主入口 ────────────────────────────────────────────────────────────────────
/**
* 執行 credential 注入。
*
* @param componentId - 零件 canonical_id 或 hash
* @param input - 節點的 merged context
* @param env - Cloudflare Worker Bindings
* @param apiKey - 用戶的 API Keyak_前綴),作為 KV namespace
*/
export async function injectCredentials(
componentId: string,
input: Record<string, unknown>,
env: Bindings,
apiKey?: string,
): Promise<Record<string, unknown>> {
// 沒有 api_key → local 模式,略過
if (!apiKey) return input;
// ── 新路徑:auth recipe ──
const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES);
if (authRecipe) {
return injectFromAuthRecipe(authRecipe, input, env, apiKey);
}
// ── 舊路徑:flat injection(向後相容)──
const required = await loadCredentialsRequired(componentId, env);
if (required.length === 0) return input;
const enriched = { ...input };
for (const cred of required) {
const kvKey = `${apiKey}:cred:${cred.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${cred.key}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 中加入 ${cred.key}: "your-token"\n` +
` 2. 執行:acr creds push`,
);
}
try {
const decrypted = await decryptCredential(record, env.ENCRYPTION_KEY);
enriched[cred.inject_as] = decrypted;
} catch (e) {
throw new Error(
`credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` +
`修復步驟:重新執行 acr creds push。`,
);
}
}
return enriched;
}
+9 -16
View File
@@ -1,7 +1,6 @@
// arcrun 圖遍歷引擎 — 支援完整 Cypher 語意關係
import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types';
import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from './types';
import { injectCredentials } from './actions/credential-injector';
import { tryAuthDispatch, resolveCredentialRefs } from './actions/auth-dispatcher';
import { expandPromptRecipe } from './lib/recipe-expander';
import { resolveRecipe } from './routes/recipes';
@@ -246,8 +245,8 @@ export class GraphExecutor {
};
// 用戶面 {{credential.NAME}} 展開(design §8):偵測 node.data 裡用戶寫的
// {{credential.X}} → 交 auth_static_key WASM resolve_credentials 解密回填。
// 解密在 WASMrule 02 §2.2),此處只偵測+回填,不碰 ENCRYPTION_KEY
// {{credential.X}} → 交 auth_static_key WASM resolve_credentials 取值回填。
// 取值在 WASMrule 02 §2.2),此處只偵測+回填,不碰任何秘密值
if (this.env && this.apiKey) {
mergedContext = await resolveCredentialRefs(mergedContext, this.env, this.apiKey);
}
@@ -283,19 +282,13 @@ export class GraphExecutor {
}
}
// Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token
if (this.env) {
// 先試 auth dispatcher(新路徑,走 auth primitive WASM Worker via HTTP
// 命中才 return;否則 fallback 到舊 injectCredentialsPhase 1.9 會刪除)
if (this.apiKey) {
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey);
if (dispatched) {
mergedContext = dispatched;
} else {
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
}
} else {
mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey);
// Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token
// 走 auth dispatcherauth primitive WASM Worker via HTTP)——值住 CF Workers
// Secrets,由 WASM 內 secret_get 取用。
if (this.env && this.apiKey) {
const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey);
if (dispatched) {
mergedContext = dispatched;
}
}
-2
View File
@@ -12,7 +12,6 @@ import { docsRouter } from './routes/docs';
import { webhooksRouter } from './routes/webhooks';
import { webhooksCrudRouter } from './routes/webhooks-crud';
import { webhooksListRouter } from './routes/webhooks-list';
import { registerRouter } from './routes/register';
import { recipesRouter } from './routes/recipes';
import { credentialsRouter } from './routes/credentials';
import { webhooksNamedRouter } from './routes/webhooks-named';
@@ -48,7 +47,6 @@ app.route('/', webhooksRouter);
app.route('/', webhooksNamedRouter); // 必須在 webhooksCrudRouter 前(避免 /webhooks/:token 攔截 /webhooks/named
app.route('/', webhooksCrudRouter);
app.route('/', webhooksListRouter);
app.route('/', registerRouter);
app.route('/', recipesRouter);
app.route('/', credentialsRouter);
app.route('/', authRouter);
+1 -1
View File
@@ -319,7 +319,7 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
// ── Auth Recipe Runner ────────────────────────────────────────────────────────
//
// credential-injector 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。
// auth-dispatcher 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。
// 這裡只需要讀取這些欄位,合併進 fetch,再清除 _auth_* 不傳給下游。
function makeAuthRecipeRunner(recipe: AuthRecipeDefinition): ComponentRunner {
@@ -308,7 +308,7 @@ export function extractCompletedDays(text: string): string[] {
}
/** 截斷/切冒號後可能留下未閉合的全形括號 → 從最後一個未配對「(」剪掉。
* 整串都在括號裡(如「(T10 廢 ENCRYPTION_KEY:…」註記行)會剪成空字串——
* 整串都在括號裡(如「(備註:…」這種註記行)會剪成空字串——
* caller 視空標題為「不是任務」跳過,恰好把括號註記行濾掉。 */
function trimUnbalancedParen(s: string): string {
let depth = 0;
+14 -128
View File
@@ -10,17 +10,16 @@
/**
* createArcrunHostFunctions env
* cypher-executor Bindings, auth primitive Worker
* CREDENTIALS_KV / RECIPES / ENCRYPTION_KEY Worker
* CREDENTIALS_KV / RECIPES Worker
*/
export interface ArcrunHostEnv {
CREDENTIALS_KV: KVNamespace;
RECIPES: KVNamespace;
ENCRYPTION_KEY: string;
/**
* credential-store-migration T4§2.5/§5CF Workers per-script Secrets env var
* worker`env[ref]`T1.5 spike
* index signature `secret_get` host function secret_ref
* ENCRYPTION_KEY
*
*/
[secretRef: string]: unknown;
}
@@ -55,7 +54,7 @@ export interface WasiShim {
* .wasm host function syscall
*
*
* - encryption key `crypto_decrypt` host function 使 WASM
* - `secret_get` `CRED_` WASM worker
* - `kv_get` Worker key auth-dispatcher.ts
*/
export interface WasiHostFunctions {
@@ -72,16 +71,8 @@ export interface WasiHostFunctions {
secret_get?: (ref: string) => Promise<string | null>;
/** KV 寫入:用於快取 access_token 等短效值,ttlSeconds=0 表示不設 TTL */
kv_put?: (key: string, value: string, ttlSeconds: number) => Promise<void>;
/** 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>;
/** HMAC-SHA256(data, ENCRYPTION_KEY) → raw bytes */
crypto_hmac_sha256?: (data: Uint8Array) => Promise<Uint8Array>;
/** AES-GCM 加密(plaintext, ENCRYPTION_KEY) → {encryptedB64, ivB64} */
crypto_aes_encrypt?: (plaintext: Uint8Array) => Promise<{ encryptedB64: string; ivB64: string }>;
/** crypto random bytes → hex string */
crypto_random_bytes?: (numBytes: number) => string;
}
/**
@@ -422,23 +413,13 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
})
: () => 1,
// crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功
// 輸入皆為 base64 字串(WASM 從 KV 讀到什麼就送什麼)
crypto_decrypt: hostFunctions?.crypto_decrypt
? hostWrap(async (encPtr: number, encLen: number, ivPtr: number, ivLen: number,
outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
const dec = new TextDecoder();
const encB64 = dec.decode(new Uint8Array(memory.buffer, encPtr, encLen));
const ivB64 = dec.decode(new Uint8Array(memory.buffer, ivPtr, ivLen));
try {
const plaintext = await hostFunctions!.crypto_decrypt!(encB64, ivB64);
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(plaintext));
} catch {
return 1;
}
})
: () => 1,
// crypto_decrypt — 已停用,永遠回 1(失敗)。
//
// ⚠️ 不能整條移除:現役 auth_static_key / auth_service_account / auth_oauth2 的
// .wasm 仍宣告 `//go:wasmimport u6u crypto_decrypt`import 缺項會讓 WASM
// **instantiate 直接失敗**(不是呼叫才失敗)→ 所有認證零件全掛。故保留成 stub,
// 讓連結成立。待三個零件的 Go 原始碼移除該 wasmimport 並重編 wasm 後,才可刪掉這條。
crypto_decrypt: () => 1,
// crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功
crypto_sign_rs256: hostFunctions?.crypto_sign_rs256
@@ -457,52 +438,6 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
})
: () => 1,
// crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功,output = raw bytes
crypto_hmac_sha256: hostFunctions?.crypto_hmac_sha256
? hostWrap(async (dataPtr: number, dataLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen));
try {
const sig = await hostFunctions!.crypto_hmac_sha256!(data);
return writeOut(memory.buffer, outPtr, outLenPtr, sig);
} catch {
return 1;
}
})
: () => 1,
// crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功
crypto_aes_encrypt: hostFunctions?.crypto_aes_encrypt
? hostWrap(async (plaintextPtr: number, plaintextLen: number,
outEncPtr: number, outEncLenPtr: number,
outIvPtr: number, outIvLenPtr: number): Promise<number> => {
if (!memory) return 1;
const plaintext = new Uint8Array(new Uint8Array(memory.buffer, plaintextPtr, plaintextLen));
try {
const { encryptedB64, ivB64 } = await hostFunctions!.crypto_aes_encrypt!(plaintext);
const encBytes = new TextEncoder().encode(encryptedB64);
const ivBytes = new TextEncoder().encode(ivB64);
const s1 = writeOut(memory.buffer, outEncPtr, outEncLenPtr, encBytes);
const s2 = writeOut(memory.buffer, outIvPtr, outIvLenPtr, ivBytes);
return s1 !== 0 ? s1 : s2;
} catch {
return 1;
}
})
: () => 1,
// crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功,output = hex string
crypto_random_bytes: hostFunctions?.crypto_random_bytes
? (numBytes: number, outPtr: number, outLenPtr: number): number => {
if (!memory) return 1;
try {
const hexStr = hostFunctions!.crypto_random_bytes!(numBytes);
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(hexStr));
} catch {
return 1;
}
}
: () => 1,
},
},
@@ -636,11 +571,11 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
// ── Worker 端 host function 實作(Phase 0.6)──────────────────────────────────
//
// 唯一合法位置:AES-GCM 解密與 RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。
// 唯一合法位置:RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。
// 由 component-loader 的 WASM runner 路徑呼叫,注入進 createWasiShim。
//
// 安全邊界:
// 1. `ENCRYPTION_KEY` 只在 `crypto_decrypt` 內部讀 env,絕不經 stdin/回傳值傳給 WASM
// 1. `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他 env 機密
// 2. `kv_get` 依 key 前綴路由,且 `{api_key}:cred:*` 必須符合 stdin 傳入的 api_key(越權檢查)
// 3. 未知前綴回傳 nullWASM 收到 kv_get 回傳 2 = 找不到)
@@ -692,23 +627,6 @@ async function routedKvPut(env: ArcrunHostEnv, apiKey: string, key: string, valu
// 其他 key 前綴拒絕寫入(安全邊界)
}
/**
* 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
*/
@@ -730,7 +648,7 @@ async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise<
*
* `routedKvGet` design.md §2.3secret_ref CRED_
* `CRED_` refWASM worker
* `ENCRYPTION_KEY` / `CF_SECRETS_API_TOKEN` credential env var
* `CF_SECRETS_API_TOKEN` credential env var
* null `kv_get` /
*/
function secretGet(env: ArcrunHostEnv, ref: string): string | null {
@@ -740,7 +658,7 @@ function secretGet(env: ArcrunHostEnv, ref: string): string | null {
}
/**
* arcrun host function kv_get / crypto_decrypt / crypto_sign_rs256 / secret_get
* arcrun host function kv_get / kv_put / crypto_sign_rs256 / secret_get
* WASM runnercomponent-loader WASM api_key
*
* http_request factory auth primitive WASM API WASM
@@ -750,39 +668,7 @@ export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): W
return {
kv_get: (key: string) => routedKvGet(env, apiKey, key),
kv_put: (key: string, value: string, ttlSeconds: number) => routedKvPut(env, apiKey, key, value, ttlSeconds),
crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64),
crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8),
secret_get: async (ref: string) => secretGet(env, ref),
};
}
/**
* platform_crypto host functions
* apiKey KV routing
* ENCRYPTION_KEY closure WASM
*/
export function createPlatformCryptoHostFunctions(encryptionKey: string): WasiHostFunctions {
const toB64 = (buf: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buf)));
return {
crypto_hmac_sha256: async (data: Uint8Array): Promise<Uint8Array> => {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, data);
return new Uint8Array(sig);
},
crypto_aes_encrypt: async (plaintext: Uint8Array): Promise<{ encryptedB64: string; ivB64: string }> => {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, plaintext);
return { encryptedB64: toB64(enc), ivB64: toB64(iv.buffer) };
},
crypto_random_bytes: (numBytes: number): string => {
const arr = crypto.getRandomValues(new Uint8Array(numBytes));
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
},
};
}
+29 -37
View File
@@ -9,6 +9,7 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { storeCredential } from './credentials';
export const authRouter = new Hono<{ Bindings: Bindings }>();
@@ -48,26 +49,13 @@ function getLandingOrigin(c: { req: { raw: Request } }): string {
return 'https://arcrun.dev';
}
/** 產生 API KeyHMAC-SHA256 of email,與 /register 相同邏輯) */
async function generateApiKey(email: string, encryptionKey: string): Promise<string> {
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
const msgData = new TextEncoder().encode(email);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
return 'ak_' + hex.slice(0, 32);
}
/** AES-GCM 加密,回傳 {encrypted, iv}base64),與 SDK 格式相同 */
async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32));
const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext));
const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf)));
return { encrypted: toB64(enc), iv: toB64(iv) };
/**
* API Key /me/api-key/rotate
*
* key email USERS_KV api_key
*/
function generateApiKey(): string {
return 'ak_' + randomToken(24);
}
/** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */
@@ -198,16 +186,14 @@ authRouter.get('/auth/callback', async (c) => {
}
await c.env.SESSIONS_KV.delete(`state:${state}`);
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey) {
return Response.redirect(`${landingOrigin}/login?error=server_error`, 302);
}
try {
let email: string;
let displayName: string;
let avatarUrl: string | undefined;
let providerId: string;
// provider token 要存進 credential 新家,但必須用最終的 api_key 當租戶鍵,
// 而 api_key 要等下方 USERS_KV upsert 才決定 → 先暫存,稍後再寫。
let pendingCredential: { name: string; value: string; service: string } | null = null;
const provider = stateRecord.provider;
const redirectUri = 'https://cypher.arcrun.dev/auth/callback';
@@ -240,12 +226,10 @@ authRouter.get('/auth/callback', async (c) => {
avatarUrl = userInfo.picture;
providerId = userInfo.sub;
// 存 Google refresh_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined
// 存 Google refresh_token 供 auth_oauth2 零件使用(實際寫入在 apiKey 決定後,見下方
// pendingCredential)。Google 只在首次授權時回傳 refresh_token,後續登入為 undefined
if (tokenData.refresh_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`;
const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
pendingCredential = { name: 'google_refresh_token', value: tokenData.refresh_token, service: 'google_user' };
// 種 auth_recipe:google_user(用戶自己的 Google OAuth2
void upsertAuthRecipe(c.env.RECIPES, {
@@ -319,12 +303,10 @@ authRouter.get('/auth/callback', async (c) => {
avatarUrl = userInfo.avatar_url;
providerId = String(userInfo.id);
// 存 GitHub access_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用
// GitHub 沒有 refresh_tokenaccess_token 長效(直到 revoke
// 存 GitHub access_token 供 auth_static_key 零件使用(實際寫入在 apiKey 決定後,見
// 下方 pendingCredential)。GitHub 沒有 refresh_tokenaccess_token 長效(直到 revoke
if (tokenData.access_token) {
const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`;
const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey);
await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted));
pendingCredential = { name: 'github_access_token', value: tokenData.access_token, service: 'github_user' };
// GitHub access_token 長效無 refresh 概念,用 static_key primitive
void upsertAuthRecipe(c.env.RECIPES, {
@@ -352,8 +334,8 @@ authRouter.get('/auth/callback', async (c) => {
const updated: UserRecord = { ...existing, display_name: displayName, avatar_url: avatarUrl };
await c.env.USERS_KV.put(userKey, JSON.stringify(updated));
} else {
// New user — generate api key (same HMAC logic as /register)
apiKey = await generateApiKey(email, encryptionKey);
// New user — generate a random api key
apiKey = generateApiKey();
const newUser: UserRecord = {
email, display_name: displayName, avatar_url: avatarUrl,
api_key: apiKey, provider, provider_id: providerId,
@@ -364,6 +346,16 @@ authRouter.get('/auth/callback', async (c) => {
await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey);
}
// provider token → credential 新家(Workers Secrets + D1)。登入本身不該因為存
// credential 失敗而失敗(例如 self-hosted 未設 CF_SECRETS_API_TOKEN)→ 吞錯誤只記 log。
if (pendingCredential) {
try {
await storeCredential(c.env, apiKey, pendingCredential.name, pendingCredential.value, pendingCredential.service);
} catch (e) {
console.error('存 provider token 失敗(不影響登入):', e instanceof Error ? e.message : String(e));
}
}
// Create session (TTL 7 days)
const sessionId = randomToken(32);
const session: SessionRecord = {
+20 -71
View File
@@ -17,11 +17,7 @@
* 2026-07-03 T1.5 spike SDD §6 Q-b leo
* trade-off leo
*
* credential-store-migration T8§4.2 + T9§3
* - `POST /credentials/migrate-to-workers-secrets`X-Arcrun-API-Key
* `{api_key}:cred:{name}` KV row wasi-shim crypto_decrypt
* PUT Workers Secrets D1 upsert D1
* secret_ref row ok/skipped/failmindset §7
* credential-store-migration T9§3
* - `GET /credentials` D1 `/credentials/catalog` query
* `/catalog` Console
* - `DELETE /credentials/:name` D1 secret_ref Workers Secret + D1 row
@@ -31,7 +27,6 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { sha256Prefix } from '../lib/hash';
import { createArcrunHostFunctions } from '../lib/wasi-shim';
export const credentialsRouter = new Hono<{ Bindings: Bindings }>();
@@ -52,6 +47,25 @@ async function deriveSecretRef(apiKey: string, name: string): Promise<string> {
return `CRED_${name.toUpperCase()}_${hash8.toUpperCase()}`;
}
/**
* credential CF Workers Secrets + D1
*
* OAuth callback /credentials provider token
* auth primitive `POST /credentials`
*
*/
export async function storeCredential(
env: Bindings,
apiKey: string,
name: string,
value: string,
service: string | null,
): Promise<void> {
const secretRef = await deriveSecretRef(apiKey, name);
await putWorkerSecret(env, secretRef, value);
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service, 'standard', secretRef);
}
function validateName(name: unknown): name is string {
return typeof name === 'string' && /^\w+$/.test(name);
}
@@ -308,68 +322,3 @@ credentialsRouter.get('/credentials', async (c) => {
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
}
});
interface MigrateResult {
name: string;
ok: boolean;
skipped?: boolean;
error?: string;
}
// POST /credentials/migrate-to-workers-secrets — 回填(T8,§4.2):一次性、冪等、可審。
// 把呼叫者名下舊 `{api_key}:cred:{name}` KV row{encrypted, iv} AES-GCM 密文)逐一解密
// →(重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點,本檔不重新實作解密)→ PUT 進 Workers
// Secrets → D1 upsert 目錄。冪等:D1 已有該 (api_key,name) row 且 secret_ref 非空 → 跳過。
// 不刪 KV 舊密文(§4.3 回滾錨點——雙讀 fallback、廢除 ENCRYPTION_KEY 前的安全網)。
credentialsRouter.post('/credentials/migrate-to-workers-secrets', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
const cryptoDecrypt = createArcrunHostFunctions(c.env, apiKey).crypto_decrypt;
if (!cryptoDecrypt) {
return c.json({ success: false, error: 'crypto_decrypt host function 未就緒' }, 500);
}
const prefix = `${apiKey}:cred:`;
const list = await c.env.CREDENTIALS_KV.list({ prefix });
const results: MigrateResult[] = [];
for (const key of list.keys) {
const name = key.name.slice(prefix.length);
try {
const existingRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name);
if (existingRef) {
results.push({ name, ok: true, skipped: true });
continue;
}
const raw = await c.env.CREDENTIALS_KV.get(key.name);
if (!raw) {
results.push({ name, ok: false, error: 'KV row 讀不到值(可能已被刪除)' });
continue;
}
const { encrypted, iv } = JSON.parse(raw) as { encrypted: string; iv: string };
const plaintext = await cryptoDecrypt(encrypted, iv);
const secretRef = await deriveSecretRef(apiKey, name);
await putWorkerSecret(c.env, secretRef, plaintext);
await upsertCredentialRow(c.env.CREDENTIALS_DB, apiKey, name, null, 'standard', secretRef);
results.push({ name, ok: true });
} catch (e) {
// 誠實回報逐筆 fail,不假綠(mindset §7
results.push({ name, ok: false, error: e instanceof Error ? e.message : String(e) });
}
}
const failed = results.filter(r => !r.ok);
return c.json({
success: failed.length === 0,
total: results.length,
migrated: results.filter(r => r.ok && !r.skipped).length,
skipped: results.filter(r => r.skipped).length,
failed: failed.length,
results,
});
});
+1 -1
View File
@@ -401,7 +401,7 @@ recipesRouter.delete('/recipes/:id', async (c) => {
* 2. rec_xxxxxxxx idx:{hash} canonical_id canonical
* 3. canonical_id idx:installed:{canonical_id} recipe:{uuid}
* fallback key recipe:{canonical_id} / migration
* component-loader/auth-dispatcher/credential-injector
* component-loader/auth-dispatcher
*/
export async function resolveRecipe(
id: string,
-46
View File
@@ -1,46 +0,0 @@
// POST /register — API Key 發放
// email → HMAC-SHA256(email, ENCRYPTION_KEY) → api_key (ak_ 前綴)
// 同一個 email 永遠得到相同的 Key,無需資料庫
import { Hono } from 'hono';
import type { Bindings } from '../types';
export const registerRouter = new Hono<{ Bindings: Bindings }>();
registerRouter.post('/register', async (c) => {
let email: string;
try {
const body = await c.req.json() as { email?: string };
email = (body.email ?? '').trim().toLowerCase();
} catch {
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
}
if (!email || !email.includes('@')) {
return c.json({ success: false, error: 'email 格式不正確' }, 400);
}
const encryptionKey = c.env.ENCRYPTION_KEY;
if (!encryptionKey || encryptionKey.length < 32) {
return c.json({ success: false, error: 'server configuration error' }, 500);
}
// HMAC-SHA256(email, ENCRYPTION_KEY) → hex → 取前 32 字元 → ak_ 前綴
const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32));
const msgData = new TextEncoder().encode(email);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData);
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
const apiKey = 'ak_' + hex.slice(0, 32);
return c.json({
success: true,
api_key: apiKey,
encryption_key: encryptionKey, // 用戶需要此 key 才能加密上傳 credential
email,
message: 'API Key 已發放,請妥善保存。相同 email 永遠得到相同的 Key。',
});
});
-1
View File
@@ -46,7 +46,6 @@ export type Bindings = {
AI: Ai;
// 環境變數
ENVIRONMENT: string;
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES keywrangler secret
MULTI_TENANT?: string; // "false" = Self-hosted 單租戶模式,預設 "true"
// OAuth Secretswrangler secret
GOOGLE_CLIENT_ID?: string;
+1 -72
View File
@@ -1,12 +1,10 @@
/**
* credential-store-migration T8+ T9
* credential
*
* `putWorkerSecret` / `deleteWorkerSecret`
* Cloudflare API`fetch` api.cloudflare.comwrangler.test.toml
* CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID CF API
* - D1-only GET /credentials/credentials/catalog
* - migrate skip D1 row putWorkerSecret
* - migrate CF token row fail
* - DELETE D1 row fallback KV deleteWorkerSecret
* CF Workers Secrets API / leo21c
* curl credential-store-migration.md T8/T9
@@ -90,75 +88,6 @@ describe('GET /credentials (D1, T9)', () => {
});
});
describe('POST /credentials/migrate-to-workers-secrets (T8)', () => {
beforeEach(async () => {
await clearTenantRows();
await env.CREDENTIALS_KV.list({ prefix: `${API_KEY}:cred:` }).then(async (list) => {
for (const k of list.keys) await env.CREDENTIALS_KV.delete(k.name);
});
});
it('缺 X-Arcrun-API-Key → 401', async () => {
const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { method: 'POST' });
expect(res.status).toBe(401);
});
it('D1 已有 row(曾回填過)→ 跳過,不誤判為失敗', async () => {
await insertCredentialRow('already_migrated', 'CRED_ALREADY_MIGRATED_ABCDEF01');
// 對應的舊 KV row 仍在(§4.3 回滾錨點:回填後不刪 KV),驗證「有 D1 row 就跳過」而非重打 CF API
await env.CREDENTIALS_KV.put(
`${API_KEY}:cred:already_migrated`,
JSON.stringify({ encrypted: 'irrelevant', iv: 'irrelevant' }),
);
const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', {
method: 'POST',
headers: { 'X-Arcrun-API-Key': API_KEY },
});
const body = await res.json() as {
success: boolean; total: number; migrated: number; skipped: number; failed: number;
results: Array<{ name: string; ok: boolean; skipped?: boolean }>;
};
expect(body.success).toBe(true);
expect(body.total).toBe(1);
expect(body.skipped).toBe(1);
expect(body.migrated).toBe(0);
expect(body.failed).toBe(0);
expect(body.results[0]).toMatchObject({ name: 'already_migrated', ok: true, skipped: true });
});
it('無任何舊 KV row → 空結果,success:true(沒東西可回填不是失敗)', async () => {
const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', {
method: 'POST',
headers: { 'X-Arcrun-API-Key': API_KEY },
});
const body = await res.json() as { success: boolean; total: number };
expect(body.success).toBe(true);
expect(body.total).toBe(0);
});
it('真正需要回填的 row(D1 無資料)在測試環境缺 CF token 時誠實回報 fail,不假綠', async () => {
await env.CREDENTIALS_KV.put(
`${API_KEY}:cred:needs_migration`,
JSON.stringify({ encrypted: 'ZmFrZQ==', iv: 'ZmFrZQ==' }),
);
const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', {
method: 'POST',
headers: { 'X-Arcrun-API-Key': API_KEY },
});
const body = await res.json() as {
success: boolean; failed: number; results: Array<{ name: string; ok: boolean; error?: string }>;
};
// 解密本身可能因假造的 base64 密文而失敗,或走到 putWorkerSecret 因缺 CF_SECRETS_API_TOKEN 失敗——
// 兩者都應該落在「誠實回報 fail」而非靜默假裝成功
expect(body.success).toBe(false);
expect(body.failed).toBe(1);
const row = body.results.find(r => r.name === 'needs_migration');
expect(row?.ok).toBe(false);
expect(row?.error).toBeTruthy();
});
});
describe('DELETE /credentials/:name (T9)', () => {
beforeEach(clearTenantRows);
+4 -5
View File
@@ -231,7 +231,7 @@ function makeFakeEnv(overrides: Record<string, unknown> = {}): ArcrunHostEnv {
return {
CREDENTIALS_KV: fakeKv,
RECIPES: fakeKv,
ENCRYPTION_KEY: 'deadbeef'.repeat(8),
CF_SECRETS_API_TOKEN: 'fake-cf-token',
...overrides,
};
}
@@ -249,11 +249,10 @@ describe('createArcrunHostFunctions — secret_get', () => {
await expect(hostFns.secret_get!('CRED_NOT_SET')).resolves.toBeNull();
});
it('非 CRED_ 前綴 → 一律拒絕回 null(即使 env 上真的有這個值,如 ENCRYPTION_KEY', async () => {
it('非 CRED_ 前綴 → 一律拒絕回 null(即使 env 上真的有這個值,如 CF_SECRETS_API_TOKEN', async () => {
const env = makeFakeEnv();
const hostFns = createArcrunHostFunctions(env, 'ak_test');
// ENCRYPTION_KEY 是 worker 自己的機密,WASM 不該透過 secret_get 拿到(安全邊界)
await expect(hostFns.secret_get!('ENCRYPTION_KEY')).resolves.toBeNull();
// CF_SECRETS_API_TOKEN 是 worker 自己的機密,WASM 不該透過 secret_get 拿到(安全邊界)
await expect(hostFns.secret_get!('CF_SECRETS_API_TOKEN')).resolves.toBeNull();
});
@@ -266,7 +265,7 @@ describe('createArcrunHostFunctions — secret_get', () => {
describe('u6u.secret_get — WASI import wiring', () => {
// 誠實註記(撞牆記錄):vitest-pool-workers 環境的 WebAssembly 支援 JSPIhostWrap() 因此把
// secret_get(以及既有的 kv_get / crypto_decrypt 等所有 async host function)包成
// secret_get(以及既有的 kv_get / crypto_sign_rs256 等所有 async host function)包成
// `WebAssembly.Suspending` 物件而非一般函式——這類物件設計上只能當 WASM import 綁定使用,
// 不能在 JS 端直接 `fn(...)` 呼叫(會拋 "is not a function")。用 probe 測試證實
// kv_get 的 import 同樣是 `Suspending` 物件、同樣不可直接呼叫——這是既有架構的環境限制,
-1
View File
@@ -44,7 +44,6 @@ database_id = "test-credentials-db-id"
[vars]
ENVIRONMENT = "test"
ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
# 分流台勾掉 route 測試:KBDB 指到假 hostfetchMock 攔截,絕不外連——尤其不打官方 uncle6 fallback
KBDB_BASE_URL = "https://kbdb.test"
CONSOLE_TENANT = "leo"
-1
View File
@@ -114,7 +114,6 @@ service = "arcrun-validate-json"
[vars]
ENVIRONMENT = "production"
# MULTI_TENANT = "true"
# ENCRYPTION_KEY 透過 wrangler secret set 設定
# credential-store-migration T3(§2.3 寫入路徑需要的 token+account id):
# CF_SECRETS_API_TOKEN 是機密,透過 `wrangler secret put CF_SECRETS_API_TOKEN` 設定(不進 toml)。