Files
Arcrun/cypher-executor/src/routes/credentials.ts
T
Claude 1d19d46161 feat(credentials): T8 回填端點 + T9 治理端點/CLI (credential-store-migration)
- POST /credentials/migrate-to-workers-secrets:舊 KV credential 逐筆解密回填 D1+Workers
  Secrets,冪等可審,重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點
- GET /credentials 改讀 D1(與既有 /credentials/catalog 共用查詢);DELETE 改為新家優先、
  舊 KV fallback,避免孤兒資料
- acr creds list/replace/delete 三支 CLI 薄殼指令;順手修好過期的 acr creds push(舊
  client 端加密格式已被 T5 取代)
- 新增 cypher-executor/tests/credentials.test.ts + D1 test fixture

T6/T7(讀取/注入路徑、雙讀 fallback)需要重新編譯 registry/components/auth_static_key
的 TinyGo WASM,本環境無 tinygo 且 proxy 擋 github.com 下載,卡在工具鏈缺口,詳細分析
記錄在 credential-store-migration.md。

端到端驗證:部署到 leo21c 帳號真實跑過 GET/POST/DELETE 三分支 + migrate 端點(對真實
既存的兩筆 credential 跑,發現 cypher-executor 自己的 ENCRYPTION_KEY secret 疑似為空,
誠實記錄為待 leo/總管裁決的不可逆風險項,未擅自重設)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 23:00:07 +00:00

376 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Credentials API — 多租戶 credential 管理
*
* credential-store-migration T5D19「擁有目錄,不擁有內容物」,
* 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 送到 cyphercypher 短暫在記憶體
* 經手明文(不落地、不持久、不持金鑰)後直接 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/failmindset §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 namewrangler.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_<NAME 大寫>_<sha256(api_key) 前 8 碼大寫>
* 加 api_key 的 hash 是為了避免跨租戶同名 credential(如兩個用戶都存 telegram_bot_token
* 撞名覆蓋彼此的 Workers Secretsecret 是掛在同一個 worker 上、全域命名空間,沒有租戶
* 隔離機制,必須自己用命名衍生隔離)。name 先前已被 validateName() 限制為 \w+
* 大寫後仍是合法的 env var 名(CF secret name 只接受 [A-Za-z0-9_])。
*/
async function deriveSecretRef(apiKey: string, name: string): Promise<string> {
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<void> {
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 T3acr 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 secretT9 治理端點用)。
* 404(本來就不存在)視為成功(冪等刪除,呼叫端可能已被清過)。
*/
async function deleteWorkerSecret(env: Bindings, secretRef: string): Promise<void> {
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_refsecret_ref 是純函式衍生自 api_key+name,理論上覆寫時
* 值不會變,這裡仍寫入以求同一份 SQL 同時支援「首次建立」與「覆寫」兩種呼叫路徑)。
*/
async function upsertCredentialRow(
db: D1Database,
apiKey: string,
name: string,
service: string | null,
sensitivity: 'standard' | 'high',
secretRef: string,
): Promise<void> {
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<CredentialRow[]> {
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<CredentialRow>();
return rows.results ?? [];
}
/** 查單一 credential 的 secret_ref(治理端點刪除用;不對外回傳 secret_ref 本身,只內部使用)。 */
async function findSecretRef(db: D1Database, apiKey: string, name: string): Promise<string | null> {
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 — 刪除 credentialT9:新家優先,舊 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 目錄唯讀 listMira 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,
});
});