20c7610371
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>
325 lines
14 KiB
TypeScript
325 lines
14 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 T9(§3 治理端點):
|
||
* - `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';
|
||
|
||
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()}`;
|
||
}
|
||
|
||
/**
|
||
* 存一筆 credential 進「新家」(CF Workers Secrets 明文 + D1 目錄)。
|
||
*
|
||
* 給 OAuth callback 這類非 /credentials 端點的內部呼叫者用(存 provider token 供
|
||
* auth primitive 零件取用)。與 `POST /credentials` 共用同一條寫入路徑,
|
||
* 確保只有一套儲存。
|
||
*/
|
||
export async function storeCredential(
|
||
env: Bindings,
|
||
apiKey: string,
|
||
name: string,
|
||
value: string,
|
||
service: string | null,
|
||
): Promise<void> {
|
||
const secretRef = await deriveSecretRef(apiKey, name);
|
||
await putWorkerSecret(env, secretRef, value);
|
||
await upsertCredentialRow(env.CREDENTIALS_DB, apiKey, name, service, 'standard', secretRef);
|
||
}
|
||
|
||
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);
|
||
}
|
||
});
|