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>
This commit is contained in:
@@ -18,7 +18,7 @@ const SOURCE_LABEL: Record<ConfigSource, string> = {
|
||||
};
|
||||
|
||||
/** 敏感欄位只印前綴,避免把 token 完整印到終端 / log。*/
|
||||
const SENSITIVE = new Set(['api_key', 'encryption_key', 'cf_api_token']);
|
||||
const SENSITIVE = new Set(['api_key', 'cf_api_token']);
|
||||
|
||||
function mask(field: string, value: string): string {
|
||||
if (SENSITIVE.has(field) && value.length > 8) return `${value.slice(0, 8)}…`;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* acr creds push/list/replace/delete
|
||||
*
|
||||
* 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 薄殼原則)。
|
||||
* 寫入路徑=「明文值 + TLS 傳輸」,值交由 CF Workers Secrets 託管。CLI 是薄殼:
|
||||
* list/replace/delete 三支指令全部只做「讀 argv/yaml → 呼叫 cypher-executor API →
|
||||
* 印結果」,不做任何加解密或業務邏輯(rule 07 薄殼原則)。
|
||||
*
|
||||
* 移除任何「印出 credential 值」的路徑(D19:擁有目錄,不擁有內容物,連 owner 都讀不回)。
|
||||
*/
|
||||
|
||||
+15
-41
@@ -11,7 +11,6 @@ import { saveConfig, type ArcrunConfig } from '../lib/config.js';
|
||||
import { CfAccountClient } from '../lib/cf-api.js';
|
||||
import {
|
||||
REQUIRED_KV_NAMESPACES,
|
||||
SECRET_TARGET_WORKERS,
|
||||
downloadAndDeploy,
|
||||
type DeployContext,
|
||||
} from '../lib/deploy.js';
|
||||
@@ -19,7 +18,7 @@ import { cmdInstallHarness } from './install-harness.js';
|
||||
import { cmdMcpSetup } from './mcp-setup.js';
|
||||
import { detectEnvironment, printPreflight, verifyInstall } from '../lib/preflight.js';
|
||||
|
||||
const ARCRUN_REGISTER_URL = 'https://cypher.arcrun.dev/register';
|
||||
const ARCRUN_LOGIN_URL = 'https://arcrun.dev/login';
|
||||
|
||||
async function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
const answer = await rl.question(chalk.cyan(`? ${question}: `));
|
||||
@@ -103,40 +102,23 @@ async function initLocal(): Promise<void> {
|
||||
}
|
||||
|
||||
async function initStandard(rl: ReturnType<typeof createInterface>): Promise<void> {
|
||||
console.log(chalk.gray(' Standard 模式:只需要 email,不需要 Cloudflare 帳號\n'));
|
||||
console.log(chalk.gray(' Standard 模式:用 arcrun.dev 帳號登入取得 API Key\n'));
|
||||
|
||||
const email = await prompt(rl, 'Email(用來取得 API Key)');
|
||||
// API Key 發放走網站 OAuth 登入(/auth/google/start、/auth/github/start)。
|
||||
// CLI 是薄殼,不自己發 key(rule 07),只引導用戶去拿再貼回來。
|
||||
console.log(' 1. 開啟 ' + chalk.cyan(ARCRUN_LOGIN_URL) + ' 用 Google / GitHub 登入');
|
||||
console.log(' 2. 在 Dashboard 複製你的 API Key(ak_ 開頭)\n');
|
||||
|
||||
process.stdout.write(chalk.gray('\n → 向 arcrun.dev 取得 API Key...'));
|
||||
const apiKey = (await prompt(rl, 'API Key(ak_...)')).trim();
|
||||
|
||||
let apiKey = '';
|
||||
let encryptionKey = '';
|
||||
try {
|
||||
const res = await fetch(ARCRUN_REGISTER_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`取得失敗(${res.status}):${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json() as { api_key: string; encryption_key: string };
|
||||
apiKey = data.api_key;
|
||||
encryptionKey = data.encryption_key;
|
||||
console.log(chalk.green(' ✓'));
|
||||
} catch (e) {
|
||||
console.log(chalk.yellow(` ✗ ${e instanceof Error ? e.message : e}`));
|
||||
console.log(chalk.yellow(' 請確認網路連線後重新執行 acr init\n'));
|
||||
if (!apiKey.startsWith('ak_')) {
|
||||
console.log(chalk.yellow('\n ✗ API Key 應以 ak_ 開頭,請重新執行 acr init\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config: ArcrunConfig = {
|
||||
mode: 'standard',
|
||||
api_key: apiKey,
|
||||
encryption_key: encryptionKey,
|
||||
};
|
||||
|
||||
saveConfig(config);
|
||||
@@ -322,28 +304,20 @@ async function initSelfHosted(
|
||||
console.log(chalk.green(' ✓ 設定寫入 ~/.arcrun/config.yaml'));
|
||||
console.log(chalk.green(' ✓ 建立 credentials.yaml'));
|
||||
|
||||
// 下一步:身份設定(self-hosted 單租戶——namespace 明碼用戶自填、encryption_key 用戶自保管)。
|
||||
// 下一步:身份設定(self-hosted 單租戶——namespace 明碼用戶自填)。
|
||||
// 工具不生成、不 hash、不外傳任何 key(守 rule 05 精神:secret 不進自動化,由用戶持有)。
|
||||
console.log(chalk.bold('\n 下一步 ①:在這個專案建 .env(你自己填,工具不碰):'));
|
||||
console.log(chalk.cyan(' NAMESPACE=leo # 你的資料分區標籤(明碼即可,不是密碼)'));
|
||||
console.log(chalk.cyan(' ENCRYPTION_KEY=<64+ hex> # credential 加密金鑰,你自己保管'));
|
||||
console.log(chalk.gray(' 生成 key:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'));
|
||||
console.log(chalk.gray(' (NAMESPACE 是分區標籤非密碼;要防外部呼叫請對 webhook 加保護。'));
|
||||
console.log(chalk.gray(' ENCRYPTION_KEY 忘了 = 解不開已上傳的 credential。.env 已被 gitignore。)'));
|
||||
|
||||
console.log(chalk.bold('\n 下一步 ②:把同一把 ENCRYPTION_KEY 設進你的 worker(runtime 解密要用):'));
|
||||
for (const w of SECRET_TARGET_WORKERS) {
|
||||
console.log(chalk.cyan(` wrangler secret put ENCRYPTION_KEY --name ${w}`));
|
||||
}
|
||||
console.log(chalk.gray(` ${SECRET_TARGET_WORKERS.length} 個 Worker 共用同一把(與 .env 的 ENCRYPTION_KEY 一致)。`));
|
||||
console.log(chalk.gray(' 不想自己跑?跑 acr init 時授權(明示同意)我可代設——但預設由你自己 put(你持有 key)。\n'));
|
||||
console.log(chalk.gray(' .env 已被 gitignore。)'));
|
||||
console.log(chalk.gray(' credential 不需要自管加密金鑰:明文由 CF Workers Secrets 託管。\n'));
|
||||
|
||||
// credential-store-migration T3(§2.3):cypher worker 要有一把「能打 CF Workers Scripts
|
||||
// secrets 管理 API 的 token」才能讓 POST/PUT /credentials 把密文寫進 Workers Secrets。
|
||||
// 比照 ENCRYPTION_KEY 的既有模式(印手動指令,不是工具自動 put)——CF_ACCOUNT_ID 非機密,
|
||||
// 已由 downloadAndDeploy/injectWranglerConfig 自動注入(同 WORKER_SUBDOMAIN 模式),
|
||||
// 印手動指令而非工具自動 put——CF_ACCOUNT_ID 非機密,已由 downloadAndDeploy/
|
||||
// injectWranglerConfig 自動注入(同 WORKER_SUBDOMAIN 模式),
|
||||
// 只有 CF_SECRETS_API_TOKEN(機密)需要用戶手動 put。
|
||||
console.log(chalk.bold(' 下一步 ③:把能打 Workers Scripts secrets API 的 CF token 設進 cypher worker:'));
|
||||
console.log(chalk.bold(' 下一步 ②:把能打 Workers Scripts secrets API 的 CF token 設進 cypher worker:'));
|
||||
console.log(chalk.cyan(` wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor`));
|
||||
console.log(chalk.gray(' 貼你剛才用來部署的同一個 CF API Token(需含 Workers Scripts:Edit 權限)。'));
|
||||
console.log(chalk.gray(' 用途:POST/PUT /credentials 把密文寫進 Workers per-script Secrets(credential-store-migration T5)。'));
|
||||
|
||||
@@ -164,43 +164,3 @@ export class CfAccountClient {
|
||||
return result.uuid;
|
||||
}
|
||||
}
|
||||
|
||||
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
|
||||
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
|
||||
if (!encryptionKey || encryptionKey.length < 64) {
|
||||
throw new Error(
|
||||
'ARCRUN_ENCRYPTION_KEY 未設定或長度不足(需要 256-bit hex,即 64 個十六進位字元)\n' +
|
||||
'生成指令:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
|
||||
);
|
||||
}
|
||||
|
||||
const keyBytes = hexToUint8Array(encryptionKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt'],
|
||||
);
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(value);
|
||||
const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, encoded);
|
||||
|
||||
return JSON.stringify({
|
||||
encrypted: uint8ArrayToBase64(new Uint8Array(cipherBuffer)),
|
||||
iv: uint8ArrayToBase64(iv),
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64(arr: Uint8Array): string {
|
||||
return Buffer.from(arr).toString('base64');
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface ArcrunConfig {
|
||||
mode: 'local' | 'standard' | 'self-hosted';
|
||||
// Standard 模式
|
||||
api_key?: string; // arcrun.dev API Key(ak_前綴)
|
||||
encryption_key?: string; // AES-GCM key,與 cypher-executor ENCRYPTION_KEY secret 一致
|
||||
// Self-hosted 模式
|
||||
cloudflare_account_id?: string;
|
||||
user_kv_namespace_id?: string;
|
||||
@@ -58,8 +57,6 @@ const ENV_MAP: Record<string, keyof ArcrunConfig> = {
|
||||
NAMESPACE: 'api_key',
|
||||
ARCRUN_NAMESPACE: 'api_key',
|
||||
ARCRUN_API_KEY: 'api_key',
|
||||
ARCRUN_ENCRYPTION_KEY: 'encryption_key',
|
||||
ENCRYPTION_KEY: 'encryption_key',
|
||||
ARCRUN_CYPHER_EXECUTOR_URL: 'cypher_executor_url',
|
||||
ARCRUN_MCP_URL: 'mcp_url',
|
||||
CLOUDFLARE_ACCOUNT_ID: 'cloudflare_account_id',
|
||||
@@ -117,7 +114,7 @@ function readProjectConfig(): Partial<ArcrunConfig> | undefined {
|
||||
|
||||
/**
|
||||
* 載入 .env(就近往上找,同 .arcrun.yaml)到 process.env,讓用戶照 Node/Python 慣例
|
||||
* 在 .env 設 NAMESPACE / ENCRYPTION_KEY 等即生效。不覆蓋「已存在於 shell」的 env(shell > .env)。
|
||||
* 在 .env 設 NAMESPACE / CLOUDFLARE_* 等即生效。不覆蓋「已存在於 shell」的 env(shell > .env)。
|
||||
* 自己解析(不引入 dotenv 依賴)。只認單純 KEY=VALUE,忽略空行/註解/引號。
|
||||
*/
|
||||
let _envFileLoaded = false;
|
||||
@@ -193,7 +190,7 @@ export function resolveConfigSources(): Array<{ field: keyof ArcrunConfig; value
|
||||
const project = readProjectConfig() ?? {};
|
||||
const env = readEnvOverrides();
|
||||
const fields: (keyof ArcrunConfig)[] = [
|
||||
'mode', 'api_key', 'encryption_key', 'cloudflare_account_id',
|
||||
'mode', 'api_key', 'cloudflare_account_id',
|
||||
'cf_api_token', 'cypher_executor_url', 'mcp_url',
|
||||
];
|
||||
const rows: Array<{ field: keyof ArcrunConfig; value: string; source: ConfigSource }> = [];
|
||||
|
||||
@@ -129,13 +129,6 @@ export const REQUIRED_KV_NAMESPACES = [
|
||||
'OAUTH_KV',
|
||||
] as const;
|
||||
|
||||
/** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/
|
||||
export const SECRET_TARGET_WORKERS = [
|
||||
'arcrun-cypher-executor',
|
||||
'arcrun-auth-static-key',
|
||||
'arcrun-auth-service-account',
|
||||
] as const;
|
||||
|
||||
/** 共享部署依賴(downloadAndDeploy 2.5:tarball root 裝一次,各 worker 往上 resolve)。
|
||||
* 含全部 worker 的 runtime deps:tier1 component 只要 hono;tier2 cypher/registry/mcp/kbdb
|
||||
* 另需 zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml;
|
||||
|
||||
Reference in New Issue
Block a user