/** * Credentials API — 多租戶 credential 管理 * * credential-store-migration T5(D19「擁有目錄,不擁有內容物」, * system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md §2.3-2.4/§3): * * 新寫入(POST 建立 / PUT 覆寫)一律走新家: * 1. 密文值 PUT 進 CF Workers per-script Secrets(掛在本 worker 上,管理 API 唯寫, * arcrun 自己也讀不回值——D19「不持有內容物」)。 * 2. D1 `credentials` 表只寫「目錄」(api_key/name/service/sensitivity/secret_ref/ * created_at),**不含密文**。 * 不再寫 KV / 不再寫明文密文到 D1。 * * §2.4 選項甲(傾向):client 不再 AES-GCM 加密,明文值經 TLS 送到 cypher,cypher 短暫在記憶體 * 經手明文(不落地、不持久、不持金鑰)後直接 PUT 進 Workers Secrets。這與舊版 * `.claude/rules/01-tech-stack.md` 記載的「傳輸格式 {name, encrypted, iv}」不同——是本 SDD * (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 不假綠)。 * - `GET /credentials`:改讀 D1(與 `/credentials/catalog` 共用同一份 query,同時保留 * `/catalog` 別名,Console 既有呼叫不受影響)。 * - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row; * 沒有(credential 從未回填過,只存在舊 KV)→ fallback 刪舊 KV key,避免刪不掉的孤兒資料。 */ 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 }>(); /** 本 worker 的 script name(wrangler.toml `name`),官方與 self-hosted 都用同一個名字, * 只有帳號(CF_ACCOUNT_ID)不同——CF Workers Scripts secrets API 是 accountId+scriptName 定位。*/ const CYPHER_SCRIPT_NAME = 'arcrun-cypher-executor'; /** * secret_ref 命名規則(credential-store-migration §2.3「以 CRED_ 前綴隔離命名空間」): * CRED__ * 加 api_key 的 hash 是為了避免跨租戶同名 credential(如兩個用戶都存 telegram_bot_token) * 撞名覆蓋彼此的 Workers Secret(secret 是掛在同一個 worker 上、全域命名空間,沒有租戶 * 隔離機制,必須自己用命名衍生隔離)。name 先前已被 validateName() 限制為 \w+, * 大寫後仍是合法的 env var 名(CF secret name 只接受 [A-Za-z0-9_])。 */ async function deriveSecretRef(apiKey: string, name: string): Promise { const hash8 = await sha256Prefix(apiKey); return `CRED_${name.toUpperCase()}_${hash8.toUpperCase()}`; } function validateName(name: unknown): name is string { return typeof name === 'string' && /^\w+$/.test(name); } function validSensitivity(s: unknown): s is 'standard' | 'high' { return s === 'standard' || s === 'high'; } /** * 呼叫 CF Workers Scripts secrets 管理 API,把明文值存進本 worker 的 per-script secret。 * 唯寫:這支 API 不回傳任何既有 secret 的值,只能 create/update/delete/list 名字(D19 對齊)。 */ async function putWorkerSecret(env: Bindings, secretRef: string, value: string): Promise { if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) { throw new Error( '此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,寫入路徑未就緒(見 ' + 'credential-store-migration.md T3:acr init/update 應確保這兩項就緒)', ); } const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets`; const res = await fetch(url, { method: 'PUT', headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: secretRef, text: value, type: 'secret_text' }), }); const body = (await res.json().catch(() => null)) as | { success?: boolean; errors?: Array<{ message?: string }> } | null; if (!res.ok || !body?.success) { const detail = body?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`; throw new Error(`CF Workers Secrets 寫入失敗:${detail}`); } } /** * 呼叫 CF Workers Scripts secrets 管理 API 刪除一個 per-script secret(T9 治理端點用)。 * 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。 */ async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise { if (!env.CF_SECRETS_API_TOKEN || !env.CF_ACCOUNT_ID) { throw new Error('此 worker 缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID 設定,刪除路徑未就緒'); } const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/workers/scripts/${CYPHER_SCRIPT_NAME}/secrets/${secretRef}`; const res = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${env.CF_SECRETS_API_TOKEN}` }, }); if (res.status === 404) return; const body = (await res.json().catch(() => null)) as | { success?: boolean; errors?: Array<{ message?: string }> } | null; if (!res.ok || !body?.success) { const detail = body?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`; throw new Error(`CF Workers Secrets 刪除失敗:${detail}`); } } /** * D1 upsert credential 目錄 row(不含密文)。 * created_at 只在首次建立時寫入;覆寫(PUT/重複 POST)保留原 created_at,只更新 * service/sensitivity/secret_ref(secret_ref 是純函式衍生自 api_key+name,理論上覆寫時 * 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。 */ async function upsertCredentialRow( db: D1Database, apiKey: string, name: string, service: string | null, sensitivity: 'standard' | 'high', secretRef: string, ): Promise { const now = Math.floor(Date.now() / 1000); await db .prepare( `INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL) ON CONFLICT(api_key, name) DO UPDATE SET service = excluded.service, sensitivity = excluded.sensitivity, secret_ref = excluded.secret_ref`, ) .bind(apiKey, name, service, sensitivity, secretRef, now) .run(); } interface CredentialRow { name: string; service: string | null; sensitivity: string; created_at: number; last_used_at: number | null; } /** D1 目錄 list(不含 secret_ref、不含值)——`GET /credentials` 與 `/credentials/catalog` 共用。 */ async function listCredentialRows(db: D1Database, apiKey: string): Promise { const rows = await db .prepare( `SELECT name, service, sensitivity, created_at, last_used_at FROM credentials WHERE api_key = ? ORDER BY created_at DESC`, ) .bind(apiKey) .all(); return rows.results ?? []; } /** 查單一 credential 的 secret_ref(治理端點刪除用;不對外回傳 secret_ref 本身,只內部使用)。 */ async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise { const row = await db .prepare(`SELECT secret_ref FROM credentials WHERE api_key = ? AND name = ?`) .bind(apiKey, name) .first<{ secret_ref: string }>(); return row?.secret_ref ?? null; } interface CredentialWriteBody { name?: string; value?: string; service?: string; sensitivity?: string; } /** POST 建立 / PUT 覆寫共用的寫入邏輯。回傳 { secretRef } 供 route handler 組回應。 */ async function writeCredential( env: Bindings, apiKey: string, name: string, value: string, service: string | undefined, sensitivityRaw: string | undefined, ): Promise<{ secretRef: string; sensitivity: 'standard' | 'high' }> { const sensitivity = validSensitivity(sensitivityRaw) ? sensitivityRaw : 'standard'; const secretRef = await deriveSecretRef(apiKey, name); // 1. 密文值進 Workers Secrets(唯寫,arcrun 自己也讀不回) await putWorkerSecret(env, secretRef, value); // 2. D1 目錄(不含密文) await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service ?? null, sensitivity, secretRef); return { secretRef, sensitivity }; } // POST /credentials — 建立/覆寫 credential(新家:Workers Secrets + D1 目錄) credentialsRouter.post('/credentials', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } const body = (await c.req.json().catch(() => null)) as CredentialWriteBody | null; if (!validateName(body?.name)) { return c.json({ error: 'name 必填,只能包含英文字母、數字和底線' }, 400); } if (!body?.value || typeof body.value !== 'string') { return c.json({ error: 'value 必填(credential 明文值,經 TLS 傳輸)' }, 400); } try { const { secretRef, sensitivity } = await writeCredential( c.env, apiKey, body.name, body.value, body.service, body.sensitivity, ); return c.json({ success: true, name: body.name, service: body.service ?? null, sensitivity, secret_ref: secretRef }); } catch (e) { // 誠實回報:寫入失敗(缺 token 設定 / CF API 錯誤)不假綠(mindset §7) return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); } }); // PUT /credentials/:name — 整筆覆寫(credential-store-migration §3:只能 replace,不能 edit 局部) credentialsRouter.put('/credentials/:name', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } const name = c.req.param('name'); if (!validateName(name)) { return c.json({ error: 'name 只能包含英文字母、數字和底線' }, 400); } const body = (await c.req.json().catch(() => null)) as CredentialWriteBody | null; if (!body?.value || typeof body.value !== 'string') { return c.json({ error: 'value 必填(credential 明文值,經 TLS 傳輸)' }, 400); } try { const { secretRef, sensitivity } = await writeCredential( c.env, apiKey, name, body.value, body.service, body.sensitivity, ); return c.json({ success: true, name, service: body.service ?? null, sensitivity, secret_ref: secretRef }); } catch (e) { return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); } }); // DELETE /credentials/:name — 刪除 credential(T9:新家優先,舊 KV 為回退) // D19 對齊:能刪的只有「目錄 row + Workers Secret 這個密文本體」,本端點從頭到尾不讀值。 credentialsRouter.delete('/credentials/:name', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } const name = c.req.param('name'); try { const secretRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name); if (secretRef) { await deleteWorkerSecret(c.env, secretRef); await c.env.CREDENTIALS_DB .prepare(`DELETE FROM credentials WHERE api_key = ? AND name = ?`) .bind(apiKey, name) .run(); return c.json({ success: true, name, source: 'workers-secrets' }); } // D1 沒有 row:這個 credential 可能從未回填過(只存在舊 KV),fallback 刪舊路徑, // 避免「GET 改讀 D1 看不到、DELETE 卻刪不掉」的孤兒資料。 await c.env.CREDENTIALS_KV.delete(`${apiKey}:cred:${name}`); return c.json({ success: true, name, source: 'legacy-kv' }); } catch (e) { return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); } }); // GET /credentials/catalog — D1 目錄唯讀 list(Mira Console 完整版,Arcrun#3 console 系)。 // 與 GET /credentials(下方,T9 起改讀同一份 D1 查詢)是同一份資料的兩個路徑; // /catalog 保留給既有 Console 呼叫,避免破壞既有前端整合。 credentialsRouter.get('/credentials/catalog', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } try { const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey); return c.json({ success: true, credentials: rows, total: rows.length }); } catch (e) { // 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事) return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); } }); // GET /credentials — 列出 credential 目錄(T9:改讀 D1,只回 metadata,絕不含值/secret_ref) credentialsRouter.get('/credentials', async (c) => { const apiKey = c.req.header('X-Arcrun-API-Key'); if (!apiKey) { return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); } try { const rows = await listCredentialRows(c.env.CREDENTIALS_DB, apiKey); return c.json({ success: true, credentials: rows, total: rows.length }); } catch (e) { 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, }); });