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>
This commit is contained in:
Claude
2026-07-04 23:00:07 +00:00
parent 1db8a13a3a
commit 1d19d46161
9 changed files with 588 additions and 99 deletions
+112 -68
View File
@@ -1,66 +1,132 @@
/**
* acr creds push [credentials.yaml]
* acr creds push/list/replace/delete
*
* 讀取 credentials.yaml,以 ENCRYPTION_KEY 加密後 POST 至 cypher.arcrun.dev/credentials。
* Server 以 {api_key}:cred:{name} 為 KV key 存入 CREDENTIALS_KV(多租戶隔離)。
* credential-store-migration T52026-07-03Arcrun#2)已把 server 端寫入路徑從
* 「client AES-GCM 加密 + {name,encrypted,iv}」改為「明文值 + TLS 傳輸」(§2.4 選項甲:
* arcrun 不再自管 ENCRYPTION_KEY,密文改由 CF Workers Secrets 託管)。T9(§3 治理端點)
* 對應把 CLI 薄殼換成新的 list/replace/delete 三支指令:全部只做「讀 argv/yaml → 呼叫
* cypher-executor API → 印結果」,不做任何加解密或業務邏輯(rule 07 薄殼原則)。
*
* 不再需要用戶提供 CF API Token 或 KV Namespace ID
* 移除任何「印出 credential 值」的路徑(D19:擁有目錄,不擁有內容物,連 owner 都讀不回)
*/
import { readFileSync } from 'node:fs';
import yaml from 'js-yaml';
import chalk from 'chalk';
import ora from 'ora';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
async function encryptValue(value: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> {
const keyBytes = hexToUint8Array(encryptionKey);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBytes.buffer as ArrayBuffer,
{ name: 'AES-GCM' },
false,
['encrypt'],
);
const ivBytes = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(value);
const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBytes }, cryptoKey, encoded);
return {
encrypted: Buffer.from(new Uint8Array(cipherBuffer)).toString('base64'),
iv: Buffer.from(ivBytes).toString('base64'),
};
}
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
return bytes;
}
export async function cmdCredsPush(filePath: string): Promise<void> {
const config = loadConfig();
if (config.mode === 'local') {
console.error(chalk.red('Local 模式不支援 acr creds push。'));
console.log(chalk.gray('請先執行 acr init 設定 Standard 模式,取得 API Key。'));
process.exit(1);
}
import { loadConfig, getCypherExecutorUrl, type ArcrunConfig } from '../lib/config.js';
function requireApiKey(config: ArcrunConfig): string {
if (!config.api_key) {
// self-hosted 用「資料分區標籤」(明碼,用戶在 .env 設 NAMESPACE)當 KV 前綴,非平台發的 api_key。
if (config.mode === 'self-hosted') {
console.error(chalk.red('缺少 NAMESPACE(你的資料分區標籤)。'));
console.log(chalk.gray('在專案 .env 設一行(明碼即可,這是分區標籤不是密碼):'));
console.log(chalk.cyan(' NAMESPACE=leo'));
console.log(chalk.gray('(要防外部呼叫請對 webhook 加保護;見 README「讓 AI 連到對的 arcrun」段)'));
} 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);
// 讀取 credentials.yaml
let creds: Record<string, string>;
try {
const raw = readFileSync(filePath, 'utf8');
@@ -76,48 +142,26 @@ export async function cmdCredsPush(filePath: string): Promise<void> {
return;
}
// 加密金鑰:優先從 config 讀(含 .env 的 ENCRYPTION_KEY / ARCRUN_ENCRYPTION_KEY,見 config.ts loadDotEnvOnce),
// 其次環境變數。self-hosted:你自己保管這把(工具不生成、不外傳),須與 worker 的 ENCRYPTION_KEY secret 一致。
const encryptionKey = config.encryption_key ?? process.env.ARCRUN_ENCRYPTION_KEY ?? '';
if (!encryptionKey || encryptionKey.length < 64) {
if (config.mode === 'self-hosted') {
console.error(chalk.red('缺少 encryption_key(或長度不足,需 ≥64 hex chars = 256-bit)。'));
console.log(chalk.gray('在專案 .env 設(你自己保管,忘了就解不開已上傳的 credential):'));
console.log(chalk.cyan(' ENCRYPTION_KEY=<64+ hex> # 產生:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'));
console.log(chalk.gray('同一把也要設進 workerwrangler secret put ENCRYPTION_KEY(見 acr init 提示)'));
} else {
console.error(chalk.red('缺少 encryption_key。請重新執行 acr init 取得設定。'));
}
process.exit(1);
}
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 { encrypted, iv } = await encryptValue(String(value), encryptionKey);
const res = await fetch(`${baseUrl}/credentials`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Arcrun-API-Key': config.api_key!,
},
body: JSON.stringify({ name, encrypted, iv }),
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 已加密儲存。執行 workflow 時會自動注入,無需在 --input 手動帶 token。\n'));
console.log(chalk.gray('\n Credential 已存進 Workers Secretscypher 唯寫,讀不回值)。執行 workflow 時會自動注入。\n'));
}
+19 -4
View File
@@ -13,7 +13,7 @@ import { dirname, join } from 'node:path';
import { cmdInit } from './commands/init.js';
import { cmdConfig } from './commands/config.js';
import { cmdWhoami } from './commands/whoami.js';
import { cmdCredsPush } from './commands/creds.js';
import { cmdCredsPush, cmdCredsList, cmdCredsReplace, cmdCredsDelete } from './commands/creds.js';
import { cmdPush } from './commands/push.js';
import { cmdRun } from './commands/run.js';
import { cmdValidate } from './commands/validate.js';
@@ -79,12 +79,27 @@ program
.option('--json', '結構化輸出(給 AI / 腳本讀取)')
.action((options: { json?: boolean }) => cmdWhoami(options));
// acr creds push [credentials.yaml]
const credsCmd = program.command('creds').description('Credential 管理');
// acr creds push/list/replace/delete — credential-store-migration T9(治理端點 CLI 薄殼)
const credsCmd = program.command('creds').description('Credential 管理D19:只能看目錄/replace/delete,讀不回值)');
credsCmd
.command('push [file]')
.description('加密上傳 credentials.yaml 至你的 CF KV不經過 arcrun.dev')
.description('批次上傳 credentials.yaml(明文經 TLS,值存進你的 Workers Secrets不經過 arcrun.dev')
.action((file: string) => cmdCredsPush(file ?? 'credentials.yaml'));
credsCmd
.command('list')
.description('列出已存的 credential 目錄(name/service/sensitivity/last_used,不含值)')
.action(() => cmdCredsList());
credsCmd
.command('replace <name> <value>')
.description('整筆覆寫一個 credential 的值(只能整筆換,不能局部編輯)')
.option('--service <service>', '對應 service 名(如 telegram')
.option('--sensitivity <level>', 'standard | high')
.action((name: string, value: string, options: { service?: string; sensitivity?: string }) =>
cmdCredsReplace(name, value, options));
credsCmd
.command('delete <name>')
.description('刪除一個 credential(目錄 row + Workers Secret 本體)')
.action((name: string) => cmdCredsDelete(name));
// acr push <workflow.yaml>
program