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
+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/§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. 未知前綴回傳 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.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 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('');
},
};
}