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:
@@ -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` 物件、同樣不可直接呼叫——這是既有架構的環境限制,
|
||||
|
||||
Reference in New Issue
Block a user