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:
@@ -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('');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user