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:
@@ -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` action(WASM 內 kv_get + crypto_decrypt),
|
||||
* 拿回明文後只做字串回填。ENCRYPTION_KEY 永不經此處。
|
||||
* 拿回明文後只做字串回填。本檔不解密、不持有任何金鑰。
|
||||
*
|
||||
* - 無 {{credential.}} → 原樣回傳(不打 WASM,零開銷)
|
||||
* - 解密失敗 / 缺 credential → throw(誠實報錯,不假綠)
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Credential Injector
|
||||
*
|
||||
* 執行順序:
|
||||
* 1. 檢查是否有對應的 auth recipe(auth_recipe:{componentId} in RECIPES KV)
|
||||
* → 有:走 auth recipe 路徑(支援 static_key, service_account)
|
||||
* → 無:走舊有 flat injection 路徑(向後相容)
|
||||
*
|
||||
* Auth Recipe 路徑:
|
||||
* - static_key:展開 inject.header/query/body 的 {{secret.KEY}} 模板
|
||||
* - service_account:JWT 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 token:service_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 Key(ak_前綴),作為 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;
|
||||
}
|
||||
@@ -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 解密回填。
|
||||
// 解密在 WASM(rule 02 §2.2),此處只偵測+回填,不碰 ENCRYPTION_KEY。
|
||||
// {{credential.X}} → 交 auth_static_key WASM resolve_credentials 取值回填。
|
||||
// 取值在 WASM(rule 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 到舊 injectCredentials(Phase 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 dispatcher(auth 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/§5):CF 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. 未知前綴回傳 null(WASM 收到 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.3「secret_ref 命名需以 CRED_
|
||||
* 前綴隔離命名空間」):只允許讀 `CRED_` 開頭的 ref。WASM 不該、也不需要讀到 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 runner(component-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('');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 Key(HMAC-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_token,access_token 長效(直到 revoke)
|
||||
// 存 GitHub access_token 供 auth_static_key 零件使用(實際寫入在 apiKey 決定後,見
|
||||
// 下方 pendingCredential)。GitHub 沒有 refresh_token,access_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 = {
|
||||
|
||||
@@ -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/fail(mindset §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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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。',
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,6 @@ export type Bindings = {
|
||||
AI: Ai;
|
||||
// 環境變數
|
||||
ENVIRONMENT: string;
|
||||
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES key(wrangler secret)
|
||||
MULTI_TENANT?: string; // "false" = Self-hosted 單租戶模式,預設 "true"
|
||||
// OAuth Secrets(wrangler secret)
|
||||
GOOGLE_CLIENT_ID?: string;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* credential-store-migration T8(回填端點)+ T9(治理端點)測試。
|
||||
* credential 治理端點測試。
|
||||
*
|
||||
* 範圍限制(誠實記錄,非本檔缺陷):`putWorkerSecret` / `deleteWorkerSecret` 呼叫真實
|
||||
* Cloudflare API(`fetch` 到 api.cloudflare.com)。測試環境(wrangler.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);
|
||||
|
||||
|
||||
@@ -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 支援 JSPI,hostWrap() 因此把
|
||||
// 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` 物件、同樣不可直接呼叫——這是既有架構的環境限制,
|
||||
|
||||
@@ -44,7 +44,6 @@ database_id = "test-credentials-db-id"
|
||||
|
||||
[vars]
|
||||
ENVIRONMENT = "test"
|
||||
ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
# 分流台勾掉 route 測試:KBDB 指到假 host(fetchMock 攔截,絕不外連——尤其不打官方 uncle6 fallback)
|
||||
KBDB_BASE_URL = "https://kbdb.test"
|
||||
CONSOLE_TENANT = "leo"
|
||||
|
||||
@@ -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)。
|
||||
|
||||
Reference in New Issue
Block a user