Files
Arcrun/cli/src/commands/creds.ts
Leo 20c7610371 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>
2026-07-21 01:32:48 +08:00

166 lines
6.3 KiB
TypeScript
Raw Permalink 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.
/**
* acr creds push/list/replace/delete
*
* 寫入路徑=「明文值 + TLS 傳輸」,值交由 CF Workers Secrets 託管。CLI 是薄殼:
* list/replace/delete 三支指令全部只做「讀 argv/yaml → 呼叫 cypher-executor API →
* 印結果」,不做任何加解密或業務邏輯(rule 07 薄殼原則)。
*
* 移除任何「印出 credential 值」的路徑(D19:擁有目錄,不擁有內容物,連 owner 都讀不回)。
*/
import { readFileSync } from 'node:fs';
import yaml from 'js-yaml';
import chalk from 'chalk';
import ora from 'ora';
import { loadConfig, getCypherExecutorUrl, type ArcrunConfig } from '../lib/config.js';
function requireApiKey(config: ArcrunConfig): string {
if (!config.api_key) {
if (config.mode === 'self-hosted') {
console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。'));
console.log(chalk.gray('在專案 .env 設一行(明碼即可,這是分區標籤不是密碼):'));
console.log(chalk.cyan(' NAMESPACE=leo'));
} else {
console.error(chalk.red('缺少 api_key,請重新執行 acr init。'));
}
process.exit(1);
}
return config.api_key;
}
interface CredentialRow {
name: string;
service: string | null;
sensitivity: string;
created_at: number;
last_used_at: number | null;
}
/** acr creds list — 讀 D1 目錄顯示(不含值,D19:讀不回內容物)。 */
export async function cmdCredsList(): Promise<void> {
const config = loadConfig();
const apiKey = requireApiKey(config);
const baseUrl = getCypherExecutorUrl(config);
const res = await fetch(`${baseUrl}/credentials`, {
headers: { 'X-Arcrun-API-Key': apiKey },
});
const body = (await res.json().catch(() => null)) as
| { success?: boolean; credentials?: CredentialRow[]; error?: string }
| null;
if (!res.ok || !body?.success) {
console.error(chalk.red(`讀取失敗:${body?.error ?? `HTTP ${res.status}`}`));
process.exit(1);
}
const rows = body.credentials ?? [];
if (rows.length === 0) {
console.log(chalk.gray('(尚無 credential'));
return;
}
console.log(chalk.bold(`\n ${rows.length} 個 credential(只顯示目錄,值不可讀回)\n`));
for (const r of rows) {
const lastUsed = r.last_used_at ? new Date(r.last_used_at * 1000).toISOString() : '(從未使用)';
console.log(` ${chalk.cyan(r.name)} service=${r.service ?? '-'} sensitivity=${r.sensitivity} last_used=${lastUsed}`);
}
console.log();
}
/** acr creds replace <name> <value> — 整筆覆寫(只能 replace,不能 edit 局部,D19/§3)。 */
export async function cmdCredsReplace(
name: string,
value: string,
options: { service?: string; sensitivity?: string },
): Promise<void> {
const config = loadConfig();
const apiKey = requireApiKey(config);
const baseUrl = getCypherExecutorUrl(config);
const spinner = ora(` 覆寫 ${name}`).start();
try {
const res = await fetch(`${baseUrl}/credentials/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey },
body: JSON.stringify({ value, service: options.service, sensitivity: options.sensitivity }),
});
const body = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null;
if (!res.ok || !body?.success) {
throw new Error(body?.error ?? `HTTP ${res.status}`);
}
spinner.succeed(chalk.green(` ✓ ${name} 已覆寫`));
} catch (e) {
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
/** acr creds delete <name> — 刪除 credential(目錄 row + Workers Secret 本體)。 */
export async function cmdCredsDelete(name: string): Promise<void> {
const config = loadConfig();
const apiKey = requireApiKey(config);
const baseUrl = getCypherExecutorUrl(config);
const spinner = ora(` 刪除 ${name}`).start();
try {
const res = await fetch(`${baseUrl}/credentials/${encodeURIComponent(name)}`, {
method: 'DELETE',
headers: { 'X-Arcrun-API-Key': apiKey },
});
const body = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null;
if (!res.ok || !body?.success) {
throw new Error(body?.error ?? `HTTP ${res.status}`);
}
spinner.succeed(chalk.green(` ✓ ${name} 已刪除`));
} catch (e) {
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
process.exit(1);
}
}
/**
* acr creds push [credentials.yaml] —(保留 bulk 匯入的既有慣用法,內部改走新的明文 PUT
* 路徑,不再 client 端 AES-GCM 加密——舊實作對應的 server 格式已被 T5 取代,直接沿用會 400)。
*/
export async function cmdCredsPush(filePath: string): Promise<void> {
const config = loadConfig();
const apiKey = requireApiKey(config);
let creds: Record<string, string>;
try {
const raw = readFileSync(filePath, 'utf8');
creds = yaml.load(raw) as Record<string, string>;
} catch (e) {
console.error(chalk.red(`無法讀取 ${filePath}${e instanceof Error ? e.message : e}`));
process.exit(1);
}
const entries = Object.entries(creds).filter(([, v]) => typeof v === 'string' && v.length > 0);
if (entries.length === 0) {
console.log(chalk.yellow('credentials.yaml 中沒有有效的 credential(請取消注解並填入值)'));
return;
}
const baseUrl = getCypherExecutorUrl(config);
console.log(chalk.bold(`\n 上傳 ${entries.length} 個 credentials 至 ${baseUrl}\n`));
for (const [name, value] of entries) {
const spinner = ora(` ${name}`).start();
try {
const res = await fetch(`${baseUrl}/credentials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': apiKey },
body: JSON.stringify({ name, value: String(value) }),
});
if (!res.ok) {
const errBody = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${errBody.slice(0, 200)}`);
}
spinner.succeed(chalk.green(` ✓ ${name}`));
} catch (e) {
spinner.fail(chalk.red(` ✗ ${name} 失敗:${e instanceof Error ? e.message : e}`));
}
}
console.log(chalk.gray('\n Credential 已存進 Workers Secretscypher 唯寫,讀不回值)。執行 workflow 時會自動注入。\n'));
}