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:
+112
-68
@@ -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 T5(2026-07-03,Arcrun#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('同一把也要設進 worker:wrangler 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 Secrets(cypher 唯寫,讀不回值)。執行 workflow 時會自動注入。\n'));
|
||||
}
|
||||
|
||||
+19
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user