1d19d46161
- 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>
376 lines
16 KiB
TypeScript
376 lines
16 KiB
TypeScript
/**
|
||
* 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_<NAME 大寫>_<sha256(api_key) 前 8 碼大寫>
|
||
* 加 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<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 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<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_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<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 — 刪除 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,
|
||
});
|
||
});
|