diff --git a/.claude/rules/01-tech-stack.md b/.claude/rules/01-tech-stack.md index a12cb8d..1270437 100644 --- a/.claude/rules/01-tech-stack.md +++ b/.claude/rules/01-tech-stack.md @@ -30,7 +30,8 @@ |---|---| | `u6u.http_request` | 發 HTTP 請求 | | `u6u.kv_get` | 讀 Cloudflare KV(Worker 側依 key 前綴路由到正確 KV) | -| `u6u.crypto_decrypt` | AES-GCM 解密(encryption key 永不暴露給 WASM) | +| `u6u.secret_get` | 讀 CF Workers Secrets(只放行 `CRED_` 前綴) | +| `u6u.crypto_decrypt` | ⚠️ 已廢除,保留成永遠回失敗的 stub(現役 wasm 仍宣告此 import) | | `u6u.crypto_sign_rs256` | RSA-SHA256 簽章(PKCS8 bytes 傳入) | **所有 host function 在 `cypher-executor/src/lib/wasi-shim.ts` 實作**。零件透過 WASI import 使用。 @@ -50,16 +51,22 @@ **警告:R2 不存平台內建零件的 WASM**。平台零件已 bundle 進各自的 Worker binary(`[[wasm_modules]]` 或 `import ... assert { type: 'webassembly' }`)。 -## 加解密規範 +## Credential 儲存規範 -- **演算法**:AES-GCM 256-bit -- **加密位置**:Client 端(CLI / Python SDK / JS SDK) - - Python:`cryptography` 套件 - - JS:Web Crypto API(`crypto.subtle`) -- **解密位置**:Server 端 **WASM primitive**(透過 host function `crypto_decrypt`) - - cypher-executor TS **不解密**,只提供 host function - - `ENCRYPTION_KEY` 只在 Worker host function 內部讀取,**永不經 stdin / 回傳值傳給 WASM** -- **傳輸格式**:`{ name, encrypted, iv }`(iv base64、encrypted base64) +**arcrun 不自管加密金鑰。** credential 明文由 **Cloudflare Workers Secrets** 託管 +(per-script secret,掛在用戶自己的 cypher worker 上): + +- secret 名稱 = `CRED_{NAME}_{sha256(api_key)[:8]}`(跨租戶命名隔離) +- D1 `credentials` 表只存**目錄**(api_key / name / service / secret_ref),不存值 +- D19:擁有目錄,不擁有內容物——連 owner 都讀不回,只能覆寫/刪除 +- 需要 worker 設 `CF_SECRETS_API_TOKEN`(機密,用戶手動 put)+ `CF_ACCOUNT_ID`(自動注入) + +> 註:`crypto_decrypt` host function 是**永遠回失敗的 stub**,因為現役三個 `auth_*` +> `.wasm` 仍宣告該 import(缺項會讓 WASM instantiate 失敗)。三個零件重編後即可刪除。 +- **取用位置**:Server 端 **WASM primitive**(透過 host function `secret_get(ref)`) + - cypher-executor TS 不碰業務邏輯,只提供 host function + - `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他機密 +- **傳輸格式**:明文值走 TLS(`POST /credentials`),不做 client 端加密 ## 網路部署 diff --git a/.claude/rules/05-deploy-convention.md b/.claude/rules/05-deploy-convention.md index e48b4ad..0ee9fe3 100644 --- a/.claude/rules/05-deploy-convention.md +++ b/.claude/rules/05-deploy-convention.md @@ -62,10 +62,13 @@ find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler. **CI 只提供 Cloudflare 驗證,不碰 runtime secret**。 - GH Actions secrets:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`(一次性設好) -- Runtime secret(例:`ENCRYPTION_KEY`、`OPENAI_KEY`、`GOOGLE_API_KEY`): +- Runtime secret(例:`CF_SECRETS_API_TOKEN`、`OPENAI_KEY`、`GOOGLE_API_KEY`): - **由 richblack 一次性手動** `wrangler secret put ` 設進各 Worker - 不進 CI,不進 `wrangler.toml` `[vars]` - - 需要的 Worker:`auth_static_key`、`auth_service_account`(兩個都要 `ENCRYPTION_KEY`) + - 需要的 Worker:`arcrun-cypher-executor`(`CF_SECRETS_API_TOKEN`——寫 credential 進 + Workers Secrets 用;`CF_ACCOUNT_ID` 非機密由 `acr init` 自動注入) + +> `auth_static_key` / `auth_service_account` 不需要任何 secret。 --- diff --git a/.component-builds/auth_oauth2/wrangler.toml b/.component-builds/auth_oauth2/wrangler.toml index e0184ab..a139115 100644 --- a/.component-builds/auth_oauth2/wrangler.toml +++ b/.component-builds/auth_oauth2/wrangler.toml @@ -19,4 +19,3 @@ id = "e7f4320f88d343f187e35e3543dd74c9" binding = "RECIPES" id = "9cf9db905c6241f78503199e58b2ffe0" -# ENCRYPTION_KEY 透過 wrangler secret put 設定 diff --git a/.component-builds/auth_service_account/src/index.ts b/.component-builds/auth_service_account/src/index.ts index 21bca8f..d2a08cc 100644 --- a/.component-builds/auth_service_account/src/index.ts +++ b/.component-builds/auth_service_account/src/index.ts @@ -11,7 +11,6 @@ * * 安全邊界: * - api_key 經 stdin 傳進 WASM,同時綁到 host function 的 kv_get 做越權檢查 - * - ENCRYPTION_KEY 只存在於 host function 的 closure 中,不會進入 WASM 記憶體 * - private key 只以 PKCS8 bytes 傳給 crypto_sign_rs256 host function,decrypt 後 plaintext 不離開 WASM */ diff --git a/.component-builds/auth_service_account/wrangler.toml b/.component-builds/auth_service_account/wrangler.toml index 86f3a08..d9ddbd0 100644 --- a/.component-builds/auth_service_account/wrangler.toml +++ b/.component-builds/auth_service_account/wrangler.toml @@ -20,5 +20,3 @@ id = "e7f4320f88d343f187e35e3543dd74c9" binding = "RECIPES" id = "9cf9db905c6241f78503199e58b2ffe0" -# ENCRYPTION_KEY 透過 wrangler secret set 設定 -# wrangler secret put ENCRYPTION_KEY diff --git a/.component-builds/auth_static_key/src/index.ts b/.component-builds/auth_static_key/src/index.ts index b74765a..9136af1 100644 --- a/.component-builds/auth_static_key/src/index.ts +++ b/.component-builds/auth_static_key/src/index.ts @@ -8,7 +8,6 @@ * * 安全邊界: * - api_key 經 stdin 傳進 WASM,同時綁到 host function 的 kv_get 做越權檢查 - * - ENCRYPTION_KEY 只存在於 host function 的 closure 中,不會進入 WASM 記憶體 */ import componentWasm from '../component.wasm' assert { type: 'webassembly' }; diff --git a/.component-builds/auth_static_key/wrangler.toml b/.component-builds/auth_static_key/wrangler.toml index 42826fd..f77feae 100644 --- a/.component-builds/auth_static_key/wrangler.toml +++ b/.component-builds/auth_static_key/wrangler.toml @@ -20,5 +20,3 @@ id = "e7f4320f88d343f187e35e3543dd74c9" binding = "RECIPES" id = "9cf9db905c6241f78503199e58b2ffe0" -# ENCRYPTION_KEY 透過 wrangler secret set 設定 -# wrangler secret put ENCRYPTION_KEY diff --git a/.component-builds/platform_crypto/component.wasm b/.component-builds/platform_crypto/component.wasm deleted file mode 100644 index 3c01408..0000000 Binary files a/.component-builds/platform_crypto/component.wasm and /dev/null differ diff --git a/.component-builds/platform_crypto/package.json b/.component-builds/platform_crypto/package.json deleted file mode 100644 index c84f3de..0000000 --- a/.component-builds/platform_crypto/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "arcrun-platform-crypto", - "version": "1.0.0", - "private": true, - "type": "module", - "dependencies": { - "hono": "^4.7.0" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20250408.0", - "typescript": "^5.4.0", - "wrangler": "^4.0.0" - } -} diff --git a/.component-builds/platform_crypto/pnpm-lock.yaml b/.component-builds/platform_crypto/pnpm-lock.yaml deleted file mode 100644 index e67d2d9..0000000 --- a/.component-builds/platform_crypto/pnpm-lock.yaml +++ /dev/null @@ -1,898 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - hono: - specifier: ^4.7.0 - version: 4.12.17 - devDependencies: - '@cloudflare/workers-types': - specifier: ^4.20250408.0 - version: 4.20260505.1 - typescript: - specifier: ^5.4.0 - version: 5.9.3 - wrangler: - specifier: ^4.0.0 - version: 4.87.0(@cloudflare/workers-types@4.20260505.1) - -packages: - - '@cloudflare/kv-asset-handler@0.5.0': - resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} - engines: {node: '>=22.0.0'} - - '@cloudflare/unenv-preset@2.16.1': - resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} - peerDependencies: - unenv: 2.0.0-rc.24 - workerd: '>1.20260305.0 <2.0.0-0' - peerDependenciesMeta: - workerd: - optional: true - - '@cloudflare/workerd-darwin-64@1.20260430.1': - resolution: {integrity: sha512-ADohZUHf7NBvPp2PdZig2Opxx+hDkk3ve7jrTne3JRx9kDSB73zc4LzcEeEN8LKkbAcqZmvfRJfpChSlusu0lA==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - - '@cloudflare/workerd-darwin-arm64@1.20260430.1': - resolution: {integrity: sha512-/DoYC/1wHs+YRZzzqSQg1/EHB4hiv1yV5U8FnmapRRIzVaPtnt+ApeOXeMrIdKidgKOI8TqQzgBU8xbIM7Cl4Q==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - - '@cloudflare/workerd-linux-64@1.20260430.1': - resolution: {integrity: sha512-koJhBWvEVZPKCVFtMLp2iMHlYr+lFCF47wGbnlKdHVlemV0zTxJEyHI8aLlrhPLhBmOmYLp46rXw09/qJkRIhQ==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - - '@cloudflare/workerd-linux-arm64@1.20260430.1': - resolution: {integrity: sha512-hMdapNAzNQZDXGGkg4Slydc3fRJP5FUZLJVVcZCW/+imhhJro9Z1rv5n/wfR+txKoSWhTYR8eOp8Pyi2bzLzlw==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - - '@cloudflare/workerd-windows-64@1.20260430.1': - resolution: {integrity: sha512-jS3ffixjb5USOwz4frw4WzCz0HrjVxkgyU3WiYb06N7hBAfN6eOrveAJ4QRef0+suK4V1vQFoB1oKdRBsXe9Dw==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - - '@cloudflare/workers-types@4.20260505.1': - resolution: {integrity: sha512-Uz9D2hcwB4/pdnmCU7RsgknY8TQ5st0cQMMN6h/hvWt1TCt99GUkbi6dMgWdP7jXfIfh+S/EI5zQugI9RZn4Bw==} - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - - '@poppinss/colors@4.1.6': - resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} - - '@poppinss/dumper@0.6.5': - resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} - - '@poppinss/exception@1.2.3': - resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - - '@sindresorhus/is@7.2.0': - resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} - engines: {node: '>=18'} - - '@speed-highlight/core@1.2.15': - resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} - - blake3-wasm@2.1.5: - resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - error-stack-parser-es@1.0.5: - resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} - engines: {node: '>=18'} - hasBin: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - hono@4.12.17: - resolution: {integrity: sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==} - engines: {node: '>=16.9.0'} - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - - miniflare@4.20260430.0: - resolution: {integrity: sha512-MWvMm3Siho9Yj7lbJZidLs8hbrRvIcOrif2mnsHQZdvoKfedpea+GaN8XJxbpRcq0B2WzNI1BB1ihdnqes3/ZA==} - engines: {node: '>=22.0.0'} - hasBin: true - - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - supports-color@10.2.2: - resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} - engines: {node: '>=18'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici@7.24.8: - resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} - engines: {node: '>=20.18.1'} - - unenv@2.0.0-rc.24: - resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} - - workerd@1.20260430.1: - resolution: {integrity: sha512-KEgIWyiw3Jmn+DCd/L3ePo5fmiiYb/UcwKvDWPf/nLLOiwShDFzDSsegU5NY/JcwgvO/QsLHVi2FYrbkcXNY5Q==} - engines: {node: '>=16'} - hasBin: true - - wrangler@4.87.0: - resolution: {integrity: sha512-lfhfKwLfQlowwgV0xhlYgE9fU3n0I30d4ccGY/rTCEm/n42Mjvlr0Ng3ZPNqlsrsKBcDR531V7dsPkgELvrk/Q==} - engines: {node: '>=22.0.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^4.20260430.1 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - - ws@8.18.0: - resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - youch-core@0.3.3: - resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} - - youch@4.1.0-beta.10: - resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - -snapshots: - - '@cloudflare/kv-asset-handler@0.5.0': {} - - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20260430.1 - - '@cloudflare/workerd-darwin-64@1.20260430.1': - optional: true - - '@cloudflare/workerd-darwin-arm64@1.20260430.1': - optional: true - - '@cloudflare/workerd-linux-64@1.20260430.1': - optional: true - - '@cloudflare/workerd-linux-arm64@1.20260430.1': - optional: true - - '@cloudflare/workerd-windows-64@1.20260430.1': - optional: true - - '@cloudflare/workers-types@4.20260505.1': {} - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.27.3': - optional: true - - '@esbuild/android-arm64@0.27.3': - optional: true - - '@esbuild/android-arm@0.27.3': - optional: true - - '@esbuild/android-x64@0.27.3': - optional: true - - '@esbuild/darwin-arm64@0.27.3': - optional: true - - '@esbuild/darwin-x64@0.27.3': - optional: true - - '@esbuild/freebsd-arm64@0.27.3': - optional: true - - '@esbuild/freebsd-x64@0.27.3': - optional: true - - '@esbuild/linux-arm64@0.27.3': - optional: true - - '@esbuild/linux-arm@0.27.3': - optional: true - - '@esbuild/linux-ia32@0.27.3': - optional: true - - '@esbuild/linux-loong64@0.27.3': - optional: true - - '@esbuild/linux-mips64el@0.27.3': - optional: true - - '@esbuild/linux-ppc64@0.27.3': - optional: true - - '@esbuild/linux-riscv64@0.27.3': - optional: true - - '@esbuild/linux-s390x@0.27.3': - optional: true - - '@esbuild/linux-x64@0.27.3': - optional: true - - '@esbuild/netbsd-arm64@0.27.3': - optional: true - - '@esbuild/netbsd-x64@0.27.3': - optional: true - - '@esbuild/openbsd-arm64@0.27.3': - optional: true - - '@esbuild/openbsd-x64@0.27.3': - optional: true - - '@esbuild/openharmony-arm64@0.27.3': - optional: true - - '@esbuild/sunos-x64@0.27.3': - optional: true - - '@esbuild/win32-arm64@0.27.3': - optional: true - - '@esbuild/win32-ia32@0.27.3': - optional: true - - '@esbuild/win32-x64@0.27.3': - optional: true - - '@img/colour@1.1.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.10.0 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@poppinss/colors@4.1.6': - dependencies: - kleur: 4.1.5 - - '@poppinss/dumper@0.6.5': - dependencies: - '@poppinss/colors': 4.1.6 - '@sindresorhus/is': 7.2.0 - supports-color: 10.2.2 - - '@poppinss/exception@1.2.3': {} - - '@sindresorhus/is@7.2.0': {} - - '@speed-highlight/core@1.2.15': {} - - blake3-wasm@2.1.5: {} - - cookie@1.1.1: {} - - detect-libc@2.1.2: {} - - error-stack-parser-es@1.0.5: {} - - esbuild@0.27.3: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 - - fsevents@2.3.3: - optional: true - - hono@4.12.17: {} - - kleur@4.1.5: {} - - miniflare@4.20260430.0: - dependencies: - '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 - undici: 7.24.8 - workerd: 1.20260430.1 - ws: 8.18.0 - youch: 4.1.0-beta.10 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - path-to-regexp@6.3.0: {} - - pathe@2.0.3: {} - - semver@7.7.4: {} - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.7.4 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - - supports-color@10.2.2: {} - - tslib@2.8.1: - optional: true - - typescript@5.9.3: {} - - undici@7.24.8: {} - - unenv@2.0.0-rc.24: - dependencies: - pathe: 2.0.3 - - workerd@1.20260430.1: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260430.1 - '@cloudflare/workerd-darwin-arm64': 1.20260430.1 - '@cloudflare/workerd-linux-64': 1.20260430.1 - '@cloudflare/workerd-linux-arm64': 1.20260430.1 - '@cloudflare/workerd-windows-64': 1.20260430.1 - - wrangler@4.87.0(@cloudflare/workers-types@4.20260505.1): - dependencies: - '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260430.1) - blake3-wasm: 2.1.5 - esbuild: 0.27.3 - miniflare: 4.20260430.0 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.24 - workerd: 1.20260430.1 - optionalDependencies: - '@cloudflare/workers-types': 4.20260505.1 - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - ws@8.18.0: {} - - youch-core@0.3.3: - dependencies: - '@poppinss/exception': 1.2.3 - error-stack-parser-es: 1.0.5 - - youch@4.1.0-beta.10: - dependencies: - '@poppinss/colors': 4.1.6 - '@poppinss/dumper': 0.6.5 - '@speed-highlight/core': 1.2.15 - cookie: 1.1.1 - youch-core: 0.3.3 diff --git a/.component-builds/platform_crypto/pnpm-workspace.yaml b/.component-builds/platform_crypto/pnpm-workspace.yaml deleted file mode 100644 index d4d28e2..0000000 --- a/.component-builds/platform_crypto/pnpm-workspace.yaml +++ /dev/null @@ -1,8 +0,0 @@ -allowBuilds: - esbuild: true - sharp: true - workerd: true -onlyBuiltDependencies: - - esbuild - - sharp - - workerd diff --git a/.component-builds/platform_crypto/src/index.ts b/.component-builds/platform_crypto/src/index.ts deleted file mode 100644 index 7ba92b9..0000000 --- a/.component-builds/platform_crypto/src/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * arcrun platform_crypto Worker - * - * POST / → JSON input {action, ...} → JSON output - * - * Actions: - * generate_api_key — HMAC-SHA256(email, ENCRYPTION_KEY) → ak_{hex[:32]} - * encrypt — AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}(base64) - * random_token — crypto random bytes → hex string - * - * 安全邊界:ENCRYPTION_KEY 只存在於 closure,永不進入外部(rule 02 §2.2)。 - * 此 Worker 直接用 crypto.subtle 實作,不走 WASM runner。 - * TinyGo WASM async host function 在 Cloudflare Workers 的 u6u namespace 不支援 Promise. - * WASM 零件 (registry/components/platform_crypto/) 保留作為 edge-Go 移植時的參考。 - */ - -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; - -type Env = { - ENCRYPTION_KEY: string; -}; - -type Input = { - action: string; - email?: string; - plaintext?: string; - bytes?: number; -}; - -const app = new Hono<{ Bindings: Env }>(); -app.use('*', cors()); - -app.get('/', (c) => c.json({ ok: true, component: 'platform_crypto' })); - -app.post('/', async (c) => { - let input: Input; - try { - input = await c.req.json() as Input; - } catch { - return c.json({ success: false, error: 'request body must be JSON' }, 400); - } - - const encryptionKey = c.env.ENCRYPTION_KEY; - if (!encryptionKey) { - return c.json({ success: false, error: 'ENCRYPTION_KEY not configured' }, 503); - } - - try { - switch (input.action) { - case 'generate_api_key': { - if (!input.email) return c.json({ success: false, error: 'email 必填' }, 400); - const apiKey = await generateApiKey(input.email, encryptionKey); - return c.json({ success: true, api_key: apiKey }); - } - case 'encrypt': { - if (!input.plaintext) return c.json({ success: false, error: 'plaintext 必填' }, 400); - const { encrypted, iv } = await aesEncrypt(input.plaintext, encryptionKey); - return c.json({ success: true, encrypted, iv }); - } - case 'random_token': { - const numBytes = (input.bytes ?? 32) > 0 ? (input.bytes ?? 32) : 32; - const token = randomHex(numBytes); - return c.json({ success: true, token }); - } - default: - return c.json({ success: false, error: `不支援的 action: ${input.action}` }, 400); - } - } catch (e) { - return c.json( - { success: false, error: e instanceof Error ? e.message : String(e) }, - 500, - ); - } -}); - -export default app; - -// ── Crypto implementations (rule 02 §2.2: crypto.subtle 只准在 wasi-shim.ts 或 platform_crypto) ── - -async function generateApiKey(email: string, encryptionKey: string): Promise { - const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32)); - const cryptoKey = await crypto.subtle.importKey( - 'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], - ); - const sig = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(email)); - const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join(''); - return 'ak_' + hex.slice(0, 32); -} - -async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> { - const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32)); - const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const enc = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - cryptoKey, - new TextEncoder().encode(plaintext), - ); - const toB64 = (buf: ArrayBuffer | Uint8Array) => - btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf))); - return { encrypted: toB64(enc), iv: toB64(iv) }; -} - -function randomHex(numBytes: number): string { - const arr = crypto.getRandomValues(new Uint8Array(numBytes)); - return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join(''); -} diff --git a/.component-builds/platform_crypto/tsconfig.json b/.component-builds/platform_crypto/tsconfig.json deleted file mode 100644 index b65fda7..0000000 --- a/.component-builds/platform_crypto/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022"], - "types": ["@cloudflare/workers-types"], - "strict": true, - "noEmit": true - } -} diff --git a/.component-builds/platform_crypto/wrangler.toml b/.component-builds/platform_crypto/wrangler.toml deleted file mode 100644 index 91b159e..0000000 --- a/.component-builds/platform_crypto/wrangler.toml +++ /dev/null @@ -1,15 +0,0 @@ -name = "arcrun-platform-crypto" -main = "src/index.ts" -compatibility_date = "2025-02-19" -compatibility_flags = ["nodejs_compat"] -workers_dev = true - -[vars] -COMPONENT_ID = "platform_crypto" - -[[routes]] -pattern = "platform-crypto.arcrun.dev/*" -zone_name = "arcrun.dev" - -# ENCRYPTION_KEY 透過 wrangler secret set 設定 -# wrangler secret put ENCRYPTION_KEY diff --git a/.env.example b/.env.example index d447118..b562936 100644 --- a/.env.example +++ b/.env.example @@ -30,19 +30,15 @@ CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= -# ── ② 身份與加密(自架單人用,這兩格你自己決定/保管)────────────────────────── +# ── ② 身份(自架單人用)────────────────────────────────────────────────────── # # NAMESPACE:你的資料分區標籤。隨便取個英數小名即可(例:leo、myteam)。 # 這不是密碼,只是用來分隔你的資料。 # -# ENCRYPTION_KEY:你的 credential 加密金鑰,64 個以上的 hex 字元。你自己保管。 -# 不會的話,AI 可以幫你產一串: -# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" -# ⚠️ 這串忘了 = 你之前上傳加密的 credential 就解不開了,請留底。 -# (安裝完還要把「同一串」設進你的 worker,acr init 會印確切指令給你跟著做。) +# (credential 不再需要自管加密金鑰:明文由 CF Workers Secrets 託管, +# `acr creds push` 會直接寫進你自己的 worker。) # NAMESPACE= -ENCRYPTION_KEY= # ── ③ 各服務的 token(要連哪個服務才填哪個;可之後再加)──────────────────────── diff --git a/BACKLOG.md b/BACKLOG.md index cd34064..4ff518d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -19,7 +19,7 @@ - [~] **credential 注入 401 修復** — `{{credential.X}}` 注入失敗,用戶被迫把 token 明文寫進 workflow。 **根因**:此語法系統沒實裝(三條 template 路徑都不認 `credential.` namespace)。 **修法**(design §8,richblack 2026-06-10 確認):auth_static_key 加 `resolve_credentials` action(WASM 解密) - + graph-executor `resolveCredentialRefs` 偵測回填(不碰 ENCRYPTION_KEY,rule 02 §2.2)。 + + graph-executor `resolveCredentialRefs` 偵測回填(不碰金鑰,rule 02 §2.2)。 **8.1-8.4 done**(tinygo build OK + tsc 0 + §2.2 自檢綠)。**待 8.5 端對端驗收**(部署 + 真 OpenAI key + 全新帳號打 2xx)。 - [ ] **§8 P1/P2 recipe/workflow list 遷 D1** — CF KV list 免費僅 1000/日,不修=用戶用一用就 429, 免費承諾破功。D1 現已可建(依賴解除),架構拍板走 kbdb `/entries` HTTP 雙寫不加 binding。 @@ -34,7 +34,7 @@ ### ⚪ P2(不擋封測) - [ ] `arcrun.dev/llms.txt` serve(landing/public 補檔) -- [ ] ENCRYPTION_KEY 冪等性、MCP account-source、recipe submit uuid 回傳 +- [ ] MCP account-source、recipe submit uuid 回傳 - [ ] 文件遷移階段二/三 + wiki modules/ - [ ] 下方第一期殘項:步驟 2(acr recipe test)、步驟 5b(資料外流警示 SDD)、步驟 6(搬家拆 matrix) diff --git a/README.md b/README.md index 2605c46..27306ca 100644 --- a/README.md +++ b/README.md @@ -161,27 +161,21 @@ acr init --self-hosted 你不需要懂 git、不需要懂 tinygo、不需要手動建任何東西——預編譯好的零件(`.wasm`)直接從 GitHub 下載,用**你自己的** CF token 部署到**你的**帳號。 -**最後一步:身份設定(你自己持有,工具不碰)。** self-hosted 是單租戶——你不需要平台發的 API Key,只需要兩個你自己填的值。在專案建一個 `.env`: +**最後一步:身份設定(你自己持有,工具不碰)。** self-hosted 是單租戶——你不需要平台發的 API Key,只需要一個你自己填的值。在專案建一個 `.env`: ```bash # .env(已被 gitignore;CLI 會自動讀) NAMESPACE=leo # 你的資料分區標籤(明碼即可,這不是密碼) -ENCRYPTION_KEY=<64+ hex> # credential 加密金鑰,你自己保管(忘了 = 解不開已上傳的 credential) -# 生成 key:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" ``` > `NAMESPACE` 只是「你的資料放哪個分區」的標籤,不是密碼——要防別人呼叫你的 webhook,請對 webhook 加保護(見下)。 -把**同一把** `ENCRYPTION_KEY` 也設進你的 worker(runtime 解密要用,CLI 會印確切指令): +credential **不需要**你自管加密金鑰:`acr creds push` 走 TLS 把值送進你自己的 worker,由 **Cloudflare Workers Secrets** 託管(連你自己都讀不回,只能覆寫/刪除)。這需要 cypher worker 有一把能打 Workers Scripts secrets API 的 CF token(`acr init` 會印確切指令): ```bash -wrangler secret put ENCRYPTION_KEY --name arcrun-cypher-executor -wrangler secret put ENCRYPTION_KEY --name arcrun-auth-static-key -wrangler secret put ENCRYPTION_KEY --name arcrun-auth-service-account +wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor ``` -> 不想自己 put?跑 `acr init` 時明示同意,AI 可代你設——但預設由你自己 put(金鑰是你持有的)。 - 完成。之後有新版零件,跑 `acr update` 一樣自動拉新、重部署。 > 想先不碰 Cloudflare、純在本機感受語法?`acr init --local` 然後直接跳到下面「寫一個工作流」。 diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts index 7d7f9c9..614c6f8 100644 --- a/cli/src/commands/config.ts +++ b/cli/src/commands/config.ts @@ -18,7 +18,7 @@ const SOURCE_LABEL: Record = { }; /** 敏感欄位只印前綴,避免把 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)}…`; diff --git a/cli/src/commands/creds.ts b/cli/src/commands/creds.ts index 9bbdb04..e1fbf24 100644 --- a/cli/src/commands/creds.ts +++ b/cli/src/commands/creds.ts @@ -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 都讀不回)。 */ diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 22cfdd6..9bab027 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -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, question: string): Promise { const answer = await rl.question(chalk.cyan(`? ${question}: `)); @@ -103,40 +102,23 @@ async function initLocal(): Promise { } async function initStandard(rl: ReturnType): Promise { - 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)。')); diff --git a/cli/src/lib/cf-api.ts b/cli/src/lib/cf-api.ts index d5af2c7..e12e83d 100644 --- a/cli/src/lib/cf-api.ts +++ b/cli/src/lib/cf-api.ts @@ -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 { - 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'); -} diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 7c7a5f7..e683eff 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -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 = { 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 | 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 }> = []; diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index 1da00eb..07df9a2 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -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; diff --git a/cypher-executor/src/actions/auth-dispatcher.ts b/cypher-executor/src/actions/auth-dispatcher.ts index edc813f..3474741 100644 --- a/cypher-executor/src/actions/auth-dispatcher.ts +++ b/cypher-executor/src/actions/auth-dispatcher.ts @@ -6,15 +6,15 @@ * * 嚴格邊界(rule 02 §2.2): * - 本檔**不做**任何 credential 解密 / template 展開 / JWT 簽章 - * - 那些全部在 auth primitive WASM 零件內執行(透過 host function `crypto_decrypt` 等) + * - 那些全部在 auth primitive WASM 零件內執行(透過 host function `secret_get` 等) * - 本檔只做「查 recipe 決定走哪個 primitive Worker」+「HTTP fetch 取回注入結果」 * * 目前階段接上 `auth_static_key` + `auth_service_account` + `auth_oauth2`, * Phase 4 剩 `auth_mtls`(mTLS handshake 在 Worker runtime 層)。 * * 執行時機:graph-executor 在節點 runner 執行前呼叫,取回的 ctx 會: - * 1. 先試本 dispatcher(命中才 return enriched ctx) - * 2. 沒命中 fallback 到 `injectCredentials`(Phase 1.9 才刪除) + * 1. 本 dispatcher 命中 → return enriched ctx + * 2. 沒命中 → ctx 原樣往下(T10 起舊的 injectCredentials 雙讀 fallback 已移除) */ import type { Bindings } from '../types'; @@ -250,7 +250,7 @@ function replaceCredentialRefs(value: unknown, resolved: Record) * * 嚴格邊界(rule 02 §2.2):本函式**不解密**。偵測到 {{credential.X}} 後,把 names 交給 * auth_static_key WASM 的 `resolve_credentials` action(WASM 內 kv_get + crypto_decrypt), - * 拿回明文後只做字串回填。ENCRYPTION_KEY 永不經此處。 + * 拿回明文後只做字串回填。本檔不解密、不持有任何金鑰。 * * - 無 {{credential.}} → 原樣回傳(不打 WASM,零開銷) * - 解密失敗 / 缺 credential → throw(誠實報錯,不假綠) diff --git a/cypher-executor/src/actions/credential-injector.ts b/cypher-executor/src/actions/credential-injector.ts deleted file mode 100644 index 2cbf436..0000000 --- a/cypher-executor/src/actions/credential-injector.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Credential Injector - * - * 執行順序: - * 1. 檢查是否有對應的 auth recipe(auth_recipe:{componentId} in RECIPES KV) - * → 有:走 auth recipe 路徑(支援 static_key, service_account) - * → 無:走舊有 flat injection 路徑(向後相容) - * - * Auth Recipe 路徑: - * - static_key:展開 inject.header/query/body 的 {{secret.KEY}} 模板 - * - service_account:JWT signing → token exchange → 展開 {{runtime.access_token}} - * - 注入結果以 _auth_headers / _auth_query / _auth_body 攜帶,不污染業務欄位 - * - * 舊有路徑(向後相容): - * - 從 RECIPES KV 讀取 credentials_required(動態 recipe) - * - 或從 BUILTIN_CREDENTIALS_MAP(內建清單) - * - 解密後以 inject_as 欄位名稱直接注入 context - */ - -import type { Bindings } from '../types'; -import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes'; -import type { AuthRecipeDefinition } from '../routes/recipes'; - -export interface CredentialRequirement { - key: string; // CREDENTIALS_KV 的 credential 名稱(如 gmail_token) - inject_as: string; // 注入到 input 的欄位名稱(如 access_token) -} - -/** 內建 API recipe 的 credentials_required(對應 component-loader 的 BUILTIN_API_RECIPES)*/ -const BUILTIN_CREDENTIALS_MAP: Record = { - gmail: [{ key: 'gmail_token', inject_as: 'access_token' }], - google_sheets: [{ key: 'google_oauth', inject_as: 'access_token' }], - telegram: [{ key: 'telegram_bot_token', inject_as: 'bot_token' }], - line_notify: [{ key: 'line_token', inject_as: 'token' }], -}; - -// ── AES-GCM 解密 ────────────────────────────────────────────────────────────── - -async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise { - const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string }; - - const keyBytes = hexToUint8Array(encryptionKey); - const cryptoKey = await crypto.subtle.importKey( - 'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'], - ); - - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: base64ToUint8Array(iv) }, - cryptoKey, - base64ToUint8Array(encrypted), - ); - - return new TextDecoder().decode(decrypted); -} - -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 base64ToUint8Array(b64: string): Uint8Array { - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -// ── 解密所有 required_secrets → { key: decryptedValue } ────────────────────── - -async function decryptSecrets( - recipe: AuthRecipeDefinition, - apiKey: string, - env: Bindings, -): Promise> { - const result: Record = {}; - - for (const req of recipe.required_secrets) { - if (req.optional) continue; - - const kvKey = `${apiKey}:cred:${req.key}`; - const record = await env.CREDENTIALS_KV.get(kvKey); - - if (!record) { - throw new Error( - `缺少 credential:${req.key}(${req.label})\n` + - `修復步驟:\n` + - ` 1. 在 credentials.yaml 加入 ${req.key}: "your-value"\n` + - ` 2. 執行:acr creds push`, - ); - } - - result[req.key] = await decryptCredential(record, env.ENCRYPTION_KEY); - } - - return result; -} - -// ── Template 展開:{{secret.KEY}} 和 {{runtime.KEY}} ───────────────────────── - -function interpolateTemplate( - template: string, - secrets: Record, - runtime: Record, -): string { - return template.replace(/\{\{(secret|runtime)\.(\w+)\}\}/g, (_, ns, key) => { - if (ns === 'secret') return secrets[key] ?? ''; - if (ns === 'runtime') return runtime[key] ?? ''; - return ''; - }); -} - -function interpolateRecord( - record: Record, - secrets: Record, - runtime: Record, -): Record { - const result: Record = {}; - for (const [k, v] of Object.entries(record)) { - result[k] = interpolateTemplate(v, secrets, runtime); - } - return result; -} - -// ── Auth Recipe 注入(新路徑)──────────────────────────────────────────────── - -async function injectFromAuthRecipe( - recipe: AuthRecipeDefinition, - input: Record, - env: Bindings, - apiKey: string, -): Promise> { - // 解密所有 required_secrets - const secrets = await decryptSecrets(recipe, apiKey, env); - - // runtime token:service_account 路徑已改走 auth-dispatcher → auth_service_account WASM; - // 這條 TS fallback 只處理 static_key (runtime 為空即可),service_account 永遠不會走到這裡 - const runtime: Record = {}; - - if (recipe.primitive === 'service_account') { - throw new Error( - `service_account primitive 應由 auth-dispatcher → auth_service_account WASM 處理,` + - `不應進到 credential-injector TS fallback (service=${recipe.service})`, - ); - } - - // 展開 inject 模板 - const authHeaders = recipe.inject.header - ? interpolateRecord(recipe.inject.header, secrets, runtime) - : {}; - const authQuery = recipe.inject.query - ? interpolateRecord(recipe.inject.query, secrets, runtime) - : {}; - const authBody = recipe.inject.body - ? interpolateRecord(recipe.inject.body, secrets, runtime) - : {}; - - return { - ...input, - _auth_headers: authHeaders, - _auth_query: authQuery, - _auth_body: authBody, - }; -} - -// ── 舊有路徑:flat injection(向後相容)────────────────────────────────────── - -async function loadCredentialsRequired( - componentId: string, - env: Bindings, -): Promise { - const recipe = await resolveRecipe(componentId, env.RECIPES); - if (recipe?.credentials_required?.length) { - return recipe.credentials_required; - } - return BUILTIN_CREDENTIALS_MAP[componentId] ?? []; -} - -// ── 主入口 ──────────────────────────────────────────────────────────────────── - -/** - * 執行 credential 注入。 - * - * @param componentId - 零件 canonical_id 或 hash - * @param input - 節點的 merged context - * @param env - Cloudflare Worker Bindings - * @param apiKey - 用戶的 API Key(ak_前綴),作為 KV namespace - */ -export async function injectCredentials( - componentId: string, - input: Record, - env: Bindings, - apiKey?: string, -): Promise> { - // 沒有 api_key → local 模式,略過 - if (!apiKey) return input; - - // ── 新路徑:auth recipe ── - const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES); - if (authRecipe) { - return injectFromAuthRecipe(authRecipe, input, env, apiKey); - } - - // ── 舊路徑:flat injection(向後相容)── - const required = await loadCredentialsRequired(componentId, env); - if (required.length === 0) return input; - - const enriched = { ...input }; - - for (const cred of required) { - const kvKey = `${apiKey}:cred:${cred.key}`; - const record = await env.CREDENTIALS_KV.get(kvKey); - - if (!record) { - throw new Error( - `缺少 credential:${cred.key}\n` + - `修復步驟:\n` + - ` 1. 在 credentials.yaml 中加入 ${cred.key}: "your-token"\n` + - ` 2. 執行:acr creds push`, - ); - } - - try { - const decrypted = await decryptCredential(record, env.ENCRYPTION_KEY); - enriched[cred.inject_as] = decrypted; - } catch (e) { - throw new Error( - `credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` + - `修復步驟:重新執行 acr creds push。`, - ); - } - } - - return enriched; -} diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts index f660399..672e96b 100644 --- a/cypher-executor/src/graph-executor.ts +++ b/cypher-executor/src/graph-executor.ts @@ -1,7 +1,6 @@ // arcrun 圖遍歷引擎 — 支援完整 Cypher 語意關係 import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types'; import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from './types'; -import { injectCredentials } from './actions/credential-injector'; import { tryAuthDispatch, resolveCredentialRefs } from './actions/auth-dispatcher'; import { expandPromptRecipe } from './lib/recipe-expander'; import { resolveRecipe } from './routes/recipes'; @@ -246,8 +245,8 @@ export class GraphExecutor { }; // 用戶面 {{credential.NAME}} 展開(design §8):偵測 node.data 裡用戶寫的 - // {{credential.X}} → 交 auth_static_key WASM resolve_credentials 解密回填。 - // 解密在 WASM(rule 02 §2.2),此處只偵測+回填,不碰 ENCRYPTION_KEY。 + // {{credential.X}} → 交 auth_static_key WASM resolve_credentials 取值回填。 + // 取值在 WASM(rule 02 §2.2),此處只偵測+回填,不碰任何秘密值。 if (this.env && this.apiKey) { mergedContext = await resolveCredentialRefs(mergedContext, this.env, this.apiKey); } @@ -283,19 +282,13 @@ export class GraphExecutor { } } - // Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token - if (this.env) { - // 先試 auth dispatcher(新路徑,走 auth primitive WASM Worker via HTTP) - // 命中才 return;否則 fallback 到舊 injectCredentials(Phase 1.9 會刪除) - if (this.apiKey) { - const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey); - if (dispatched) { - mergedContext = dispatched; - } else { - mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey); - } - } else { - mergedContext = await injectCredentials(node.componentId, mergedContext, this.env, this.apiKey); + // Credential 注入:在 WASM 執行前自動注入 credentials_required 中宣告的 token。 + // 走 auth dispatcher(auth primitive WASM Worker via HTTP)——值住 CF Workers + // Secrets,由 WASM 內 secret_get 取用。 + if (this.env && this.apiKey) { + const dispatched = await tryAuthDispatch(node.componentId, mergedContext, this.env, this.apiKey); + if (dispatched) { + mergedContext = dispatched; } } diff --git a/cypher-executor/src/index.ts b/cypher-executor/src/index.ts index efe485c..db1b98c 100644 --- a/cypher-executor/src/index.ts +++ b/cypher-executor/src/index.ts @@ -12,7 +12,6 @@ import { docsRouter } from './routes/docs'; import { webhooksRouter } from './routes/webhooks'; import { webhooksCrudRouter } from './routes/webhooks-crud'; import { webhooksListRouter } from './routes/webhooks-list'; -import { registerRouter } from './routes/register'; import { recipesRouter } from './routes/recipes'; import { credentialsRouter } from './routes/credentials'; import { webhooksNamedRouter } from './routes/webhooks-named'; @@ -48,7 +47,6 @@ app.route('/', webhooksRouter); app.route('/', webhooksNamedRouter); // 必須在 webhooksCrudRouter 前(避免 /webhooks/:token 攔截 /webhooks/named) app.route('/', webhooksCrudRouter); app.route('/', webhooksListRouter); -app.route('/', registerRouter); app.route('/', recipesRouter); app.route('/', credentialsRouter); app.route('/', authRouter); diff --git a/cypher-executor/src/lib/component-loader.ts b/cypher-executor/src/lib/component-loader.ts index d438357..c708807 100644 --- a/cypher-executor/src/lib/component-loader.ts +++ b/cypher-executor/src/lib/component-loader.ts @@ -319,7 +319,7 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): // ── Auth Recipe Runner ──────────────────────────────────────────────────────── // -// credential-injector 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。 +// auth-dispatcher 已先將認證資訊注入為 _auth_headers / _auth_query / _auth_body。 // 這裡只需要讀取這些欄位,合併進 fetch,再清除 _auth_* 不傳給下游。 function makeAuthRecipeRunner(recipe: AuthRecipeDefinition): ComponentRunner { diff --git a/cypher-executor/src/lib/console-dashboard-model.ts b/cypher-executor/src/lib/console-dashboard-model.ts index 536414a..39c7a19 100644 --- a/cypher-executor/src/lib/console-dashboard-model.ts +++ b/cypher-executor/src/lib/console-dashboard-model.ts @@ -308,7 +308,7 @@ export function extractCompletedDays(text: string): string[] { } /** 截斷/切冒號後可能留下未閉合的全形括號 → 從最後一個未配對「(」剪掉。 - * 整串都在括號裡(如「(T10 廢 ENCRYPTION_KEY:…」註記行)會剪成空字串—— + * 整串都在括號裡(如「(備註:…」這種註記行)會剪成空字串—— * caller 視空標題為「不是任務」跳過,恰好把括號註記行濾掉。 */ function trimUnbalancedParen(s: string): string { let depth = 0; diff --git a/cypher-executor/src/lib/wasi-shim.ts b/cypher-executor/src/lib/wasi-shim.ts index 0ea20d3..7ea0aa4 100644 --- a/cypher-executor/src/lib/wasi-shim.ts +++ b/cypher-executor/src/lib/wasi-shim.ts @@ -10,17 +10,16 @@ /** * createArcrunHostFunctions 所需的最小 env 子集。 * 不直接依賴 cypher-executor 的 Bindings,讓 auth primitive Worker 這類 - * 只綁 CREDENTIALS_KV / RECIPES / ENCRYPTION_KEY 的獨立 Worker 也能用。 + * 只綁 CREDENTIALS_KV / RECIPES 的獨立 Worker 也能用。 */ export interface ArcrunHostEnv { CREDENTIALS_KV: KVNamespace; RECIPES: KVNamespace; - ENCRYPTION_KEY: string; /** * credential-store-migration T4(§2.5/§5):CF Workers per-script Secrets 以 env var 形式 * 注入 worker,值只能靠字串動態索引取得(`env[ref]`,T1.5 spike ② 已證可行)。 * 用 index signature 讓 `secret_get` host function 能對任意 secret_ref 字串取值, - * 不需要像 ENCRYPTION_KEY 那樣逐一宣告固定屬性名。 + * 不需要逐一宣告固定屬性名。 */ [secretRef: string]: unknown; } @@ -55,7 +54,7 @@ export interface WasiShim { * 讓 .wasm 零件能透過 host function 呼叫外部服務,而不需要網路 syscall * * 嚴格邊界: - * - encryption key 只在 `crypto_decrypt` host function 內部使用,永遠不傳給 WASM + * - `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他機密 * - `kv_get` 必須在 Worker 側檢查 key 前綴以防越權(見 auth-dispatcher.ts) */ export interface WasiHostFunctions { @@ -72,16 +71,8 @@ export interface WasiHostFunctions { secret_get?: (ref: string) => Promise; /** KV 寫入:用於快取 access_token 等短效值,ttlSeconds=0 表示不設 TTL */ kv_put?: (key: string, value: string, ttlSeconds: number) => Promise; - /** AES-GCM 解密:encryption key 由 Worker 保管,不暴露給 WASM */ - crypto_decrypt?: (encryptedB64: string, ivB64: string) => Promise; /** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */ crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise; - /** HMAC-SHA256(data, ENCRYPTION_KEY) → raw bytes */ - crypto_hmac_sha256?: (data: Uint8Array) => Promise; - /** AES-GCM 加密(plaintext, ENCRYPTION_KEY) → {encryptedB64, ivB64} */ - crypto_aes_encrypt?: (plaintext: Uint8Array) => Promise<{ encryptedB64: string; ivB64: string }>; - /** crypto random bytes → hex string */ - crypto_random_bytes?: (numBytes: number) => string; } /** @@ -422,23 +413,13 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti }) : () => 1, - // crypto_decrypt(encPtr, encLen, ivPtr, ivLen, outPtr, outLenPtr) → 0 成功 - // 輸入皆為 base64 字串(WASM 從 KV 讀到什麼就送什麼) - crypto_decrypt: hostFunctions?.crypto_decrypt - ? hostWrap(async (encPtr: number, encLen: number, ivPtr: number, ivLen: number, - outPtr: number, outLenPtr: number): Promise => { - if (!memory) return 1; - const dec = new TextDecoder(); - const encB64 = dec.decode(new Uint8Array(memory.buffer, encPtr, encLen)); - const ivB64 = dec.decode(new Uint8Array(memory.buffer, ivPtr, ivLen)); - try { - const plaintext = await hostFunctions!.crypto_decrypt!(encB64, ivB64); - return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(plaintext)); - } catch { - return 1; - } - }) - : () => 1, + // crypto_decrypt — 已停用,永遠回 1(失敗)。 + // + // ⚠️ 不能整條移除:現役 auth_static_key / auth_service_account / auth_oauth2 的 + // .wasm 仍宣告 `//go:wasmimport u6u crypto_decrypt`,import 缺項會讓 WASM + // **instantiate 直接失敗**(不是呼叫才失敗)→ 所有認證零件全掛。故保留成 stub, + // 讓連結成立。待三個零件的 Go 原始碼移除該 wasmimport 並重編 wasm 後,才可刪掉這條。 + crypto_decrypt: () => 1, // crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功 crypto_sign_rs256: hostFunctions?.crypto_sign_rs256 @@ -457,52 +438,6 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti }) : () => 1, - // crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功,output = raw bytes - crypto_hmac_sha256: hostFunctions?.crypto_hmac_sha256 - ? hostWrap(async (dataPtr: number, dataLen: number, outPtr: number, outLenPtr: number): Promise => { - if (!memory) return 1; - const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen)); - try { - const sig = await hostFunctions!.crypto_hmac_sha256!(data); - return writeOut(memory.buffer, outPtr, outLenPtr, sig); - } catch { - return 1; - } - }) - : () => 1, - - // crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功 - crypto_aes_encrypt: hostFunctions?.crypto_aes_encrypt - ? hostWrap(async (plaintextPtr: number, plaintextLen: number, - outEncPtr: number, outEncLenPtr: number, - outIvPtr: number, outIvLenPtr: number): Promise => { - if (!memory) return 1; - const plaintext = new Uint8Array(new Uint8Array(memory.buffer, plaintextPtr, plaintextLen)); - try { - const { encryptedB64, ivB64 } = await hostFunctions!.crypto_aes_encrypt!(plaintext); - const encBytes = new TextEncoder().encode(encryptedB64); - const ivBytes = new TextEncoder().encode(ivB64); - const s1 = writeOut(memory.buffer, outEncPtr, outEncLenPtr, encBytes); - const s2 = writeOut(memory.buffer, outIvPtr, outIvLenPtr, ivBytes); - return s1 !== 0 ? s1 : s2; - } catch { - return 1; - } - }) - : () => 1, - - // crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功,output = hex string - crypto_random_bytes: hostFunctions?.crypto_random_bytes - ? (numBytes: number, outPtr: number, outLenPtr: number): number => { - if (!memory) return 1; - try { - const hexStr = hostFunctions!.crypto_random_bytes!(numBytes); - return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(hexStr)); - } catch { - return 1; - } - } - : () => 1, }, }, @@ -636,11 +571,11 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti // ── Worker 端 host function 實作(Phase 0.6)────────────────────────────────── // -// 唯一合法位置:AES-GCM 解密與 RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。 +// 唯一合法位置:RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。 // 由 component-loader 的 WASM runner 路徑呼叫,注入進 createWasiShim。 // // 安全邊界: -// 1. `ENCRYPTION_KEY` 只在 `crypto_decrypt` 內部讀 env,絕不經 stdin/回傳值傳給 WASM +// 1. `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他 env 機密 // 2. `kv_get` 依 key 前綴路由,且 `{api_key}:cred:*` 必須符合 stdin 傳入的 api_key(越權檢查) // 3. 未知前綴回傳 null(WASM 收到 kv_get 回傳 2 = 找不到) @@ -692,23 +627,6 @@ async function routedKvPut(env: ArcrunHostEnv, apiKey: string, key: string, valu // 其他 key 前綴拒絕寫入(安全邊界) } -/** - * AES-GCM 解密。encryption key 由 env.ENCRYPTION_KEY 在本 function 內讀取, - * 永不傳給 WASM。輸入為 base64 字串,輸出為 UTF-8 plaintext。 - */ -async function aesGcmDecrypt(env: ArcrunHostEnv, encryptedB64: string, ivB64: string): Promise { - const keyBytes = hexToUint8Array(env.ENCRYPTION_KEY); - const cryptoKey = await crypto.subtle.importKey( - 'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'], - ); - const plaintext = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: base64ToUint8Array(ivB64) }, - cryptoKey, - base64ToUint8Array(encryptedB64), - ); - return new TextDecoder().decode(plaintext); -} - /** * RSASSA-PKCS1-v1_5 + SHA-256 簽章。private key 以 PKCS8 bytes 傳入(由 WASM 零件解析 PEM 後送進來)。 */ @@ -730,7 +648,7 @@ async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise< * * 安全邊界(比照 `routedKvGet` 的前綴檢查精神;design.md §2.3「secret_ref 命名需以 CRED_ * 前綴隔離命名空間」):只允許讀 `CRED_` 開頭的 ref。WASM 不該、也不需要讀到 worker 本身的 - * 其他機密(`ENCRYPTION_KEY` / `CF_SECRETS_API_TOKEN` 等非 credential 用途的 env var)。 + * 其他機密(`CF_SECRETS_API_TOKEN` 等非 credential 用途的 env var)。 * 不符前綴或值非字串 → 回傳 null(與 `kv_get` 的「拒絕/找不到」語意一致)。 */ function secretGet(env: ArcrunHostEnv, ref: string): string | null { @@ -740,7 +658,7 @@ function secretGet(env: ArcrunHostEnv, ref: string): string | null { } /** - * 建立 arcrun host function 組合(kv_get / crypto_decrypt / crypto_sign_rs256 / secret_get)。 + * 建立 arcrun host function 組合(kv_get / kv_put / crypto_sign_rs256 / secret_get)。 * 由 WASM runner(component-loader 的 WASM 路徑)呼叫,與 api_key 綁定以做越權檢查。 * * http_request 不由本 factory 提供 — auth primitive WASM 與 API WASM 零件若需要 @@ -750,39 +668,7 @@ export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): W return { kv_get: (key: string) => routedKvGet(env, apiKey, key), kv_put: (key: string, value: string, ttlSeconds: number) => routedKvPut(env, apiKey, key, value, ttlSeconds), - crypto_decrypt: (encB64: string, ivB64: string) => aesGcmDecrypt(env, encB64, ivB64), crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8), secret_get: async (ref: string) => secretGet(env, ref), }; } - -/** - * 建立 platform_crypto host functions。 - * 不需要 apiKey 或 KV routing,只提供加密操作。 - * ENCRYPTION_KEY 在 closure 內,永不傳給 WASM。 - */ -export function createPlatformCryptoHostFunctions(encryptionKey: string): WasiHostFunctions { - const toB64 = (buf: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buf))); - - return { - crypto_hmac_sha256: async (data: Uint8Array): Promise => { - const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32)); - const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); - const sig = await crypto.subtle.sign('HMAC', cryptoKey, data); - return new Uint8Array(sig); - }, - - crypto_aes_encrypt: async (plaintext: Uint8Array): Promise<{ encryptedB64: string; ivB64: string }> => { - const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32)); - const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, plaintext); - return { encryptedB64: toB64(enc), ivB64: toB64(iv.buffer) }; - }, - - crypto_random_bytes: (numBytes: number): string => { - const arr = crypto.getRandomValues(new Uint8Array(numBytes)); - return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join(''); - }, - }; -} diff --git a/cypher-executor/src/routes/auth.ts b/cypher-executor/src/routes/auth.ts index a48e2a2..67dd4ce 100644 --- a/cypher-executor/src/routes/auth.ts +++ b/cypher-executor/src/routes/auth.ts @@ -9,6 +9,7 @@ import { Hono } from 'hono'; import type { Bindings } from '../types'; +import { storeCredential } from './credentials'; export const authRouter = new Hono<{ Bindings: Bindings }>(); @@ -48,26 +49,13 @@ function getLandingOrigin(c: { req: { raw: Request } }): string { return 'https://arcrun.dev'; } -/** 產生 API Key(HMAC-SHA256 of email,與 /register 相同邏輯) */ -async function generateApiKey(email: string, encryptionKey: string): Promise { - const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32)); - const msgData = new TextEncoder().encode(email); - const cryptoKey = await crypto.subtle.importKey( - 'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] - ); - const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData); - const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join(''); - return 'ak_' + hex.slice(0, 32); -} - -/** AES-GCM 加密,回傳 {encrypted, iv}(base64),與 SDK 格式相同 */ -async function aesEncrypt(plaintext: string, encryptionKey: string): Promise<{ encrypted: string; iv: string }> { - const keyBytes = new TextEncoder().encode(encryptionKey.slice(0, 32)); - const cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']); - const iv = crypto.getRandomValues(new Uint8Array(12)); - const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plaintext)); - const toB64 = (buf: ArrayBuffer | Uint8Array) => btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf))); - return { encrypted: toB64(enc), iv: toB64(iv) }; +/** + * 產生 API Key(隨機,與 /me/api-key/rotate 同一套)。 + * + * key 與 email 無關、不可預測;登入走 USERS_KV 讀出的 api_key,不重算。 + */ +function generateApiKey(): string { + return 'ak_' + randomToken(24); } /** 幂等寫入 auth_recipe 到 RECIPES KV(若已存在相同版本則跳過) */ @@ -198,16 +186,14 @@ authRouter.get('/auth/callback', async (c) => { } await c.env.SESSIONS_KV.delete(`state:${state}`); - const encryptionKey = c.env.ENCRYPTION_KEY; - if (!encryptionKey) { - return Response.redirect(`${landingOrigin}/login?error=server_error`, 302); - } - try { let email: string; let displayName: string; let avatarUrl: string | undefined; let providerId: string; + // provider token 要存進 credential 新家,但必須用最終的 api_key 當租戶鍵, + // 而 api_key 要等下方 USERS_KV upsert 才決定 → 先暫存,稍後再寫。 + let pendingCredential: { name: string; value: string; service: string } | null = null; const provider = stateRecord.provider; const redirectUri = 'https://cypher.arcrun.dev/auth/callback'; @@ -240,12 +226,10 @@ authRouter.get('/auth/callback', async (c) => { avatarUrl = userInfo.picture; providerId = userInfo.sub; - // 存 Google refresh_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用 - // Google 只在首次授權時回傳 refresh_token,後續登入 tokenData.refresh_token 為 undefined + // 存 Google refresh_token 供 auth_oauth2 零件使用(實際寫入在 apiKey 決定後,見下方 + // pendingCredential)。Google 只在首次授權時回傳 refresh_token,後續登入為 undefined if (tokenData.refresh_token) { - const credKey = `${await generateApiKey(email, encryptionKey)}:cred:google_refresh_token`; - const encrypted = await aesEncrypt(tokenData.refresh_token, encryptionKey); - await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted)); + pendingCredential = { name: 'google_refresh_token', value: tokenData.refresh_token, service: 'google_user' }; // 種 auth_recipe:google_user(用戶自己的 Google OAuth2) void upsertAuthRecipe(c.env.RECIPES, { @@ -319,12 +303,10 @@ authRouter.get('/auth/callback', async (c) => { avatarUrl = userInfo.avatar_url; providerId = String(userInfo.id); - // 存 GitHub access_token(加密)到 CREDENTIALS_KV,供 auth_oauth2 零件使用 - // GitHub 沒有 refresh_token,access_token 長效(直到 revoke) + // 存 GitHub access_token 供 auth_static_key 零件使用(實際寫入在 apiKey 決定後,見 + // 下方 pendingCredential)。GitHub 沒有 refresh_token,access_token 長效(直到 revoke) if (tokenData.access_token) { - const credKey = `${await generateApiKey(email, encryptionKey)}:cred:github_access_token`; - const encrypted = await aesEncrypt(tokenData.access_token, encryptionKey); - await c.env.CREDENTIALS_KV.put(credKey, JSON.stringify(encrypted)); + pendingCredential = { name: 'github_access_token', value: tokenData.access_token, service: 'github_user' }; // GitHub access_token 長效無 refresh 概念,用 static_key primitive void upsertAuthRecipe(c.env.RECIPES, { @@ -352,8 +334,8 @@ authRouter.get('/auth/callback', async (c) => { const updated: UserRecord = { ...existing, display_name: displayName, avatar_url: avatarUrl }; await c.env.USERS_KV.put(userKey, JSON.stringify(updated)); } else { - // New user — generate api key (same HMAC logic as /register) - apiKey = await generateApiKey(email, encryptionKey); + // New user — generate a random api key + apiKey = generateApiKey(); const newUser: UserRecord = { email, display_name: displayName, avatar_url: avatarUrl, api_key: apiKey, provider, provider_id: providerId, @@ -364,6 +346,16 @@ authRouter.get('/auth/callback', async (c) => { await c.env.USERS_KV.put(`apikey:${apiKey}`, userKey); } + // provider token → credential 新家(Workers Secrets + D1)。登入本身不該因為存 + // credential 失敗而失敗(例如 self-hosted 未設 CF_SECRETS_API_TOKEN)→ 吞錯誤只記 log。 + if (pendingCredential) { + try { + await storeCredential(c.env, apiKey, pendingCredential.name, pendingCredential.value, pendingCredential.service); + } catch (e) { + console.error('存 provider token 失敗(不影響登入):', e instanceof Error ? e.message : String(e)); + } + } + // Create session (TTL 7 days) const sessionId = randomToken(32); const session: SessionRecord = { diff --git a/cypher-executor/src/routes/credentials.ts b/cypher-executor/src/routes/credentials.ts index ab4fc07..53250c6 100644 --- a/cypher-executor/src/routes/credentials.ts +++ b/cypher-executor/src/routes/credentials.ts @@ -17,11 +17,7 @@ * (2026-07-03 T1.5 spike 定案)對舊格式的刻意取代,SDD §6 Q-b 仍列為需 leo 明確接受的 * 誠實 trade-off(本次實作先落地,若 leo 不接受選項甲需回頭改)。 * - * credential-store-migration T8(§4.2 回填)+ T9(§3 治理端點): - * - `POST /credentials/migrate-to-workers-secrets`:把呼叫者(X-Arcrun-API-Key)名下的舊 - * `{api_key}:cred:{name}` KV row 逐一解密(重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點, - * 不在本檔重新實作解密)→ PUT 進 Workers Secrets → D1 upsert 目錄。冪等:D1 已有可解析 - * secret_ref 的 row 就跳過;逐筆誠實回報 ok/skipped/fail(mindset §7 不假綠)。 + * credential-store-migration T9(§3 治理端點): * - `GET /credentials`:改讀 D1(與 `/credentials/catalog` 共用同一份 query,同時保留 * `/catalog` 別名,Console 既有呼叫不受影響)。 * - `DELETE /credentials/:name`:先查 D1 拿 secret_ref → 有則刪 Workers Secret + D1 row; @@ -31,7 +27,6 @@ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { sha256Prefix } from '../lib/hash'; -import { createArcrunHostFunctions } from '../lib/wasi-shim'; export const credentialsRouter = new Hono<{ Bindings: Bindings }>(); @@ -52,6 +47,25 @@ async function deriveSecretRef(apiKey: string, name: string): Promise { 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 { + 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); } @@ -308,68 +322,3 @@ credentialsRouter.get('/credentials', async (c) => { return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502); } }); - -interface MigrateResult { - name: string; - ok: boolean; - skipped?: boolean; - error?: string; -} - -// POST /credentials/migrate-to-workers-secrets — 回填(T8,§4.2):一次性、冪等、可審。 -// 把呼叫者名下舊 `{api_key}:cred:{name}` KV row({encrypted, iv} AES-GCM 密文)逐一解密 -// →(重用 wasi-shim 唯一合法 crypto_decrypt 呼叫點,本檔不重新實作解密)→ PUT 進 Workers -// Secrets → D1 upsert 目錄。冪等:D1 已有該 (api_key,name) row 且 secret_ref 非空 → 跳過。 -// 不刪 KV 舊密文(§4.3 回滾錨點——雙讀 fallback、廢除 ENCRYPTION_KEY 前的安全網)。 -credentialsRouter.post('/credentials/migrate-to-workers-secrets', async (c) => { - const apiKey = c.req.header('X-Arcrun-API-Key'); - if (!apiKey) { - return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401); - } - - const cryptoDecrypt = createArcrunHostFunctions(c.env, apiKey).crypto_decrypt; - if (!cryptoDecrypt) { - return c.json({ success: false, error: 'crypto_decrypt host function 未就緒' }, 500); - } - - const prefix = `${apiKey}:cred:`; - const list = await c.env.CREDENTIALS_KV.list({ prefix }); - const results: MigrateResult[] = []; - - for (const key of list.keys) { - const name = key.name.slice(prefix.length); - try { - const existingRef = await findSecretRef(c.env.CREDENTIALS_DB, apiKey, name); - if (existingRef) { - results.push({ name, ok: true, skipped: true }); - continue; - } - - const raw = await c.env.CREDENTIALS_KV.get(key.name); - if (!raw) { - results.push({ name, ok: false, error: 'KV row 讀不到值(可能已被刪除)' }); - continue; - } - const { encrypted, iv } = JSON.parse(raw) as { encrypted: string; iv: string }; - const plaintext = await cryptoDecrypt(encrypted, iv); - - const secretRef = await deriveSecretRef(apiKey, name); - await putWorkerSecret(c.env, secretRef, plaintext); - await upsertCredentialRow(c.env.CREDENTIALS_DB, apiKey, name, null, 'standard', secretRef); - results.push({ name, ok: true }); - } catch (e) { - // 誠實回報逐筆 fail,不假綠(mindset §7) - results.push({ name, ok: false, error: e instanceof Error ? e.message : String(e) }); - } - } - - const failed = results.filter(r => !r.ok); - return c.json({ - success: failed.length === 0, - total: results.length, - migrated: results.filter(r => r.ok && !r.skipped).length, - skipped: results.filter(r => r.skipped).length, - failed: failed.length, - results, - }); -}); diff --git a/cypher-executor/src/routes/recipes.ts b/cypher-executor/src/routes/recipes.ts index 6c67dbf..402b45e 100644 --- a/cypher-executor/src/routes/recipes.ts +++ b/cypher-executor/src/routes/recipes.ts @@ -401,7 +401,7 @@ recipesRouter.delete('/recipes/:id', async (c) => { * 2. rec_xxxxxxxx → idx:{hash} 反查 canonical_id → 再走 canonical 解析。 * 3. canonical_id → 先查 idx:installed:{canonical_id}(本部署安裝的唯一版本)→ recipe:{uuid}; * 查不到 fallback 舊 key recipe:{canonical_id}(種子 / migration 前資料)。 - * 執行鏈路(component-loader/auth-dispatcher/credential-injector)都經此 → 不破執行。 + * 執行鏈路(component-loader/auth-dispatcher)都經此 → 不破執行。 */ export async function resolveRecipe( id: string, diff --git a/cypher-executor/src/routes/register.ts b/cypher-executor/src/routes/register.ts deleted file mode 100644 index 496f779..0000000 --- a/cypher-executor/src/routes/register.ts +++ /dev/null @@ -1,46 +0,0 @@ -// POST /register — API Key 發放 -// email → HMAC-SHA256(email, ENCRYPTION_KEY) → api_key (ak_ 前綴) -// 同一個 email 永遠得到相同的 Key,無需資料庫 - -import { Hono } from 'hono'; -import type { Bindings } from '../types'; - -export const registerRouter = new Hono<{ Bindings: Bindings }>(); - -registerRouter.post('/register', async (c) => { - let email: string; - try { - const body = await c.req.json() as { email?: string }; - email = (body.email ?? '').trim().toLowerCase(); - } catch { - return c.json({ success: false, error: 'request body 必須為 JSON' }, 400); - } - - if (!email || !email.includes('@')) { - return c.json({ success: false, error: 'email 格式不正確' }, 400); - } - - const encryptionKey = c.env.ENCRYPTION_KEY; - if (!encryptionKey || encryptionKey.length < 32) { - return c.json({ success: false, error: 'server configuration error' }, 500); - } - - // HMAC-SHA256(email, ENCRYPTION_KEY) → hex → 取前 32 字元 → ak_ 前綴 - const keyData = new TextEncoder().encode(encryptionKey.slice(0, 32)); - const msgData = new TextEncoder().encode(email); - - const cryptoKey = await crypto.subtle.importKey( - 'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] - ); - const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgData); - const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join(''); - const apiKey = 'ak_' + hex.slice(0, 32); - - return c.json({ - success: true, - api_key: apiKey, - encryption_key: encryptionKey, // 用戶需要此 key 才能加密上傳 credential - email, - message: 'API Key 已發放,請妥善保存。相同 email 永遠得到相同的 Key。', - }); -}); diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 6c9ca60..742faa4 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -46,7 +46,6 @@ export type Bindings = { AI: Ai; // 環境變數 ENVIRONMENT: string; - ENCRYPTION_KEY: string; // hex-encoded 256-bit AES key(wrangler secret) MULTI_TENANT?: string; // "false" = Self-hosted 單租戶模式,預設 "true" // OAuth Secrets(wrangler secret) GOOGLE_CLIENT_ID?: string; diff --git a/cypher-executor/tests/credentials.test.ts b/cypher-executor/tests/credentials.test.ts index 76313ce..296e082 100644 --- a/cypher-executor/tests/credentials.test.ts +++ b/cypher-executor/tests/credentials.test.ts @@ -1,12 +1,10 @@ /** - * credential-store-migration T8(回填端點)+ T9(治理端點)測試。 + * credential 治理端點測試。 * * 範圍限制(誠實記錄,非本檔缺陷):`putWorkerSecret` / `deleteWorkerSecret` 呼叫真實 * Cloudflare API(`fetch` 到 api.cloudflare.com)。測試環境(wrangler.test.toml)刻意不設 * CF_SECRETS_API_TOKEN/CF_ACCOUNT_ID,所以本檔只覆蓋「不需要真的打 CF API」的路徑: * - D1-only 的 GET /credentials、/credentials/catalog - * - migrate 端點的冪等 skip 分支(D1 已有 row 就不會走到 putWorkerSecret) - * - migrate 端點在缺 CF token 時對「真的需要新建」的 row 誠實回報 fail(不假綠) * - DELETE 在 D1 無 row 時 fallback 刪舊 KV(不會走到 deleteWorkerSecret) * 真正打 CF Workers Secrets API 成功寫入/刪除的路徑,由部署到 leo21c 帳號後的端到端 * curl 驗證覆蓋(見 credential-store-migration.md T8/T9 完成記錄)。 @@ -90,75 +88,6 @@ describe('GET /credentials (D1, T9)', () => { }); }); -describe('POST /credentials/migrate-to-workers-secrets (T8)', () => { - beforeEach(async () => { - await clearTenantRows(); - await env.CREDENTIALS_KV.list({ prefix: `${API_KEY}:cred:` }).then(async (list) => { - for (const k of list.keys) await env.CREDENTIALS_KV.delete(k.name); - }); - }); - - it('缺 X-Arcrun-API-Key → 401', async () => { - const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { method: 'POST' }); - expect(res.status).toBe(401); - }); - - it('D1 已有 row(曾回填過)→ 跳過,不誤判為失敗', async () => { - await insertCredentialRow('already_migrated', 'CRED_ALREADY_MIGRATED_ABCDEF01'); - // 對應的舊 KV row 仍在(§4.3 回滾錨點:回填後不刪 KV),驗證「有 D1 row 就跳過」而非重打 CF API - await env.CREDENTIALS_KV.put( - `${API_KEY}:cred:already_migrated`, - JSON.stringify({ encrypted: 'irrelevant', iv: 'irrelevant' }), - ); - - const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { - method: 'POST', - headers: { 'X-Arcrun-API-Key': API_KEY }, - }); - const body = await res.json() as { - success: boolean; total: number; migrated: number; skipped: number; failed: number; - results: Array<{ name: string; ok: boolean; skipped?: boolean }>; - }; - expect(body.success).toBe(true); - expect(body.total).toBe(1); - expect(body.skipped).toBe(1); - expect(body.migrated).toBe(0); - expect(body.failed).toBe(0); - expect(body.results[0]).toMatchObject({ name: 'already_migrated', ok: true, skipped: true }); - }); - - it('無任何舊 KV row → 空結果,success:true(沒東西可回填不是失敗)', async () => { - const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { - method: 'POST', - headers: { 'X-Arcrun-API-Key': API_KEY }, - }); - const body = await res.json() as { success: boolean; total: number }; - expect(body.success).toBe(true); - expect(body.total).toBe(0); - }); - - it('真正需要回填的 row(D1 無資料)在測試環境缺 CF token 時誠實回報 fail,不假綠', async () => { - await env.CREDENTIALS_KV.put( - `${API_KEY}:cred:needs_migration`, - JSON.stringify({ encrypted: 'ZmFrZQ==', iv: 'ZmFrZQ==' }), - ); - const res = await SELF.fetch('https://cypher.test/credentials/migrate-to-workers-secrets', { - method: 'POST', - headers: { 'X-Arcrun-API-Key': API_KEY }, - }); - const body = await res.json() as { - success: boolean; failed: number; results: Array<{ name: string; ok: boolean; error?: string }>; - }; - // 解密本身可能因假造的 base64 密文而失敗,或走到 putWorkerSecret 因缺 CF_SECRETS_API_TOKEN 失敗—— - // 兩者都應該落在「誠實回報 fail」而非靜默假裝成功 - expect(body.success).toBe(false); - expect(body.failed).toBe(1); - const row = body.results.find(r => r.name === 'needs_migration'); - expect(row?.ok).toBe(false); - expect(row?.error).toBeTruthy(); - }); -}); - describe('DELETE /credentials/:name (T9)', () => { beforeEach(clearTenantRows); diff --git a/cypher-executor/tests/wasi-shim.test.ts b/cypher-executor/tests/wasi-shim.test.ts index f8b2cb9..a1761be 100644 --- a/cypher-executor/tests/wasi-shim.test.ts +++ b/cypher-executor/tests/wasi-shim.test.ts @@ -231,7 +231,7 @@ function makeFakeEnv(overrides: Record = {}): ArcrunHostEnv { return { CREDENTIALS_KV: fakeKv, RECIPES: fakeKv, - ENCRYPTION_KEY: 'deadbeef'.repeat(8), + CF_SECRETS_API_TOKEN: 'fake-cf-token', ...overrides, }; } @@ -249,11 +249,10 @@ describe('createArcrunHostFunctions — secret_get', () => { await expect(hostFns.secret_get!('CRED_NOT_SET')).resolves.toBeNull(); }); - it('非 CRED_ 前綴 → 一律拒絕回 null(即使 env 上真的有這個值,如 ENCRYPTION_KEY)', async () => { + it('非 CRED_ 前綴 → 一律拒絕回 null(即使 env 上真的有這個值,如 CF_SECRETS_API_TOKEN)', async () => { const env = makeFakeEnv(); const hostFns = createArcrunHostFunctions(env, 'ak_test'); - // ENCRYPTION_KEY 是 worker 自己的機密,WASM 不該透過 secret_get 拿到(安全邊界) - await expect(hostFns.secret_get!('ENCRYPTION_KEY')).resolves.toBeNull(); + // CF_SECRETS_API_TOKEN 是 worker 自己的機密,WASM 不該透過 secret_get 拿到(安全邊界) await expect(hostFns.secret_get!('CF_SECRETS_API_TOKEN')).resolves.toBeNull(); }); @@ -266,7 +265,7 @@ describe('createArcrunHostFunctions — secret_get', () => { describe('u6u.secret_get — WASI import wiring', () => { // 誠實註記(撞牆記錄):vitest-pool-workers 環境的 WebAssembly 支援 JSPI,hostWrap() 因此把 - // secret_get(以及既有的 kv_get / crypto_decrypt 等所有 async host function)包成 + // secret_get(以及既有的 kv_get / crypto_sign_rs256 等所有 async host function)包成 // `WebAssembly.Suspending` 物件而非一般函式——這類物件設計上只能當 WASM import 綁定使用, // 不能在 JS 端直接 `fn(...)` 呼叫(會拋 "is not a function")。用 probe 測試證實 // kv_get 的 import 同樣是 `Suspending` 物件、同樣不可直接呼叫——這是既有架構的環境限制, diff --git a/cypher-executor/wrangler.test.toml b/cypher-executor/wrangler.test.toml index 928ed13..ccf2cc8 100644 --- a/cypher-executor/wrangler.test.toml +++ b/cypher-executor/wrangler.test.toml @@ -44,7 +44,6 @@ database_id = "test-credentials-db-id" [vars] ENVIRONMENT = "test" -ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # 分流台勾掉 route 測試:KBDB 指到假 host(fetchMock 攔截,絕不外連——尤其不打官方 uncle6 fallback) KBDB_BASE_URL = "https://kbdb.test" CONSOLE_TENANT = "leo" diff --git a/cypher-executor/wrangler.toml b/cypher-executor/wrangler.toml index 231e776..a096012 100644 --- a/cypher-executor/wrangler.toml +++ b/cypher-executor/wrangler.toml @@ -114,7 +114,6 @@ service = "arcrun-validate-json" [vars] ENVIRONMENT = "production" # MULTI_TENANT = "true" -# ENCRYPTION_KEY 透過 wrangler secret set 設定 # credential-store-migration T3(§2.3 寫入路徑需要的 token+account id): # CF_SECRETS_API_TOKEN 是機密,透過 `wrangler secret put CF_SECRETS_API_TOKEN` 設定(不進 toml)。 diff --git a/docs/HANDOFF-self-host-harness.md b/docs/HANDOFF-self-host-harness.md index 4f9f976..bfe574d 100644 --- a/docs/HANDOFF-self-host-harness.md +++ b/docs/HANDOFF-self-host-harness.md @@ -120,7 +120,7 @@ self-host 自動部署可以參考同一套掃描邏輯(`find . -name wrangler - **把 cypher-executor 的 `[vars] WORKER_SUBDOMAIN` 改成 CC 自己的帳號 subdomain**(self-host 關鍵,見 P0 #9:cypher-executor 走 `arcrun-{name}.{subdomain}.workers.dev` 對內 URL)。 - **seed 降級 recipe + auth recipe 進 RECIPES KV**:新帳號 KV 是空的。把 §2.1 那些 recipe(kbdb_get/gmail_send/...)+ auth recipe seed 寫進去。auth recipe seed 已有 `cypher-executor/scripts/seed-auth-recipes.ts`,API recipe 需確認有對應 seed 機制(routes/recipes.ts 是動態 push,可能要寫一份 seed 腳本或用 `acr recipe push`)。 - 寫回 config(現有欄位已足夠)。 -2. **runtime secret 不進 CLI 自動化**:`ENCRYPTION_KEY` 等由 CC 自己 `wrangler secret put`(rule 05 禁止 secret 進自動化流程)。CLI 應在最後**印出提示**告訴 CC 要手動 put 哪些 secret 到哪些 worker。 +2. **runtime secret 不進 CLI 自動化**:`CF_SECRETS_API_TOKEN` 等由 CC 自己 `wrangler secret put`(rule 05 禁止 secret 進自動化流程)。CLI 應在最後**印出提示**告訴 CC 要手動 put 哪些 secret 到哪些 worker。 **驗收(客觀證據,不是口頭宣布 — mindset §7)**: - richblack 用全新 CF 帳號跑 `acr init --self-hosted` → 全程無手動建 KV / 部署。 diff --git a/llms.txt b/llms.txt index b285d04..8bb371d 100644 --- a/llms.txt +++ b/llms.txt @@ -41,13 +41,13 @@ npm i -g arcrun && acr install-harness · Account / Workers KV Storage / Edit · Account / D1 / Edit ← **必勾**,arcrun 用 D1 存 workflow/recipe;漏勾會 init 時 D1 建失敗(Authentication error) → 複製產生的 token。(不需要 R2、不需要綁信用卡——D1 也在免費額度,不綁卡。) - - `NAMESPACE`:隨便取個英數小名(非密碼)。`ENCRYPTION_KEY`:你可幫他產 - (`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`)。 + - `NAMESPACE`:隨便取個英數小名(非密碼)。credential 不需要自管加密金鑰。 - 使用者把值貼進 .env(或貼給你、你幫他填進對應格)。**CLOUDFLARE 兩格沒填,後面什麼都跑不了。** - - 連外部服務(如 Notion)的 token 也填進 .env 的 ③ 區,之後 `acr creds push` 加密上傳。 + - 連外部服務(如 Notion)的 token 也填進 .env 的 ③ 區,之後 `acr creds push` 上傳(走 TLS,存進 CF Workers Secrets)。 4. `acr init --self-hosted`(你幫跑,讀 .env 的 CF 憑證)—— 自動建資源、部署、seed、寫 .mcp.json(MCP 連線)。 跑完會印「安裝驗收」逐項 ✓/✗;有 ✗ 照它給的指令補(多數 `acr update` 冪等重試)。 -5. 跑完照提示 `wrangler secret put ENCRYPTION_KEY`(CLI 會印確切指令)。 +5. 跑完照提示 `wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor`(CLI 會印確切指令; + 用途=讓 cypher 能把 credential 寫進 Workers Secrets)。 6. 把使用者需求拆成 workflow → `acr push`。完成給客觀證據(HTTP 2xx / trace)。 ## 暴露提醒 diff --git a/registry/components/platform_crypto/component.contract.yaml b/registry/components/platform_crypto/component.contract.yaml deleted file mode 100644 index 06dac41..0000000 --- a/registry/components/platform_crypto/component.contract.yaml +++ /dev/null @@ -1,56 +0,0 @@ -canonical_id: "platform_crypto" -display_name: "Platform Crypto Primitive" -category: "platform" -version: "v1" -wasi_target: "preview1" -stability: "stable" -runtime_compat: - - "cf-workers" - - "workerd" - - "wazero" -constraints: - max_size_kb: 2048 - max_cold_start_ms: 50 - no_network_syscall: true - no_filesystem_syscall: true - io_model: "stdin_stdout_json" -input_schema: - type: object - required: [action] - properties: - action: - type: string - enum: [generate_api_key, encrypt, random_token] - email: - type: string - description: generate_api_key 用 - plaintext: - type: string - description: encrypt 用 - bytes: - type: integer - description: random_token 用,預設 32 -output_schema: - type: object - properties: - success: - type: boolean - api_key: - type: string - description: generate_api_key 結果,ak_ 前綴 - encrypted: - type: string - description: encrypt 結果,base64 - iv: - type: string - description: encrypt 結果,base64 - token: - type: string - description: random_token 結果,hex -tags: [platform, crypto, internal] -description: | - 平台內部 crypto primitive。 - - generate_api_key: HMAC-SHA256(email, ENCRYPTION_KEY) → ak_xxx - - encrypt: AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}(base64) - - random_token: crypto random bytes → hex string - ENCRYPTION_KEY 由 host 持有,永不進入 WASM。 diff --git a/registry/components/platform_crypto/main.go b/registry/components/platform_crypto/main.go deleted file mode 100644 index 03d44a9..0000000 --- a/registry/components/platform_crypto/main.go +++ /dev/null @@ -1,206 +0,0 @@ -// platform_crypto — Arcrun 平台內部 crypto primitive -// -// Actions: -// generate_api_key — HMAC-SHA256(email, ENCRYPTION_KEY) → ak_{hex[:32]} -// encrypt — AES-GCM(plaintext, ENCRYPTION_KEY) → {encrypted, iv}(base64) -// random_token — crypto random bytes → hex string -// -// ENCRYPTION_KEY 由 host 持有,永不進入 WASM。 -// -// Host imports: -// u6u.crypto_hmac_sha256 — HMAC-SHA256(data, key=ENCRYPTION_KEY) → raw bytes -// u6u.crypto_aes_encrypt — AES-GCM(plaintext, key=ENCRYPTION_KEY) → encrypted_b64 + iv_b64 -// u6u.crypto_random_bytes — crypto-random bytes → hex string -// -//go:build tinygo - -package main - -import ( - "encoding/json" - "io" - "os" - "strings" - "unsafe" -) - -// ── host function 宣告 ─────────────────────────────────────────────────────── - -// crypto_hmac_sha256(dataPtr, dataLen, outPtr, outLenPtr) → 0 成功 -// key = host 的 ENCRYPTION_KEY,output = raw bytes(hex encode 由 WASM 做) -// -//go:wasmimport u6u crypto_hmac_sha256 -func hostCryptoHmacSha256( - dataPtr uintptr, dataLen uint32, - outPtr uintptr, outLenPtr uintptr, -) uint32 - -// crypto_aes_encrypt(plaintextPtr, plaintextLen, outEncPtr, outEncLenPtr, outIvPtr, outIvLenPtr) → 0 成功 -// output: encrypted(base64)放 outEnc,iv(base64)放 outIv -// -//go:wasmimport u6u crypto_aes_encrypt -func hostCryptoAesEncrypt( - plaintextPtr uintptr, plaintextLen uint32, - outEncPtr uintptr, outEncLenPtr uintptr, - outIvPtr uintptr, outIvLenPtr uintptr, -) uint32 - -// crypto_random_bytes(numBytes, outPtr, outLenPtr) → 0 成功 -// output: hex string -// -//go:wasmimport u6u crypto_random_bytes -func hostCryptoRandomBytes( - numBytes uint32, - outPtr uintptr, outLenPtr uintptr, -) uint32 - -// ── 型別 ───────────────────────────────────────────────────────────────────── - -type Input struct { - Action string `json:"action"` - Email string `json:"email,omitempty"` - Plaintext string `json:"plaintext,omitempty"` - Bytes int `json:"bytes,omitempty"` -} - -// ── main ───────────────────────────────────────────────────────────────────── - -func main() { - raw, err := io.ReadAll(os.Stdin) - if err != nil { - writeError("failed to read stdin: " + err.Error()) - return - } - - var input Input - if err := json.Unmarshal(raw, &input); err != nil { - writeError("invalid input JSON: " + err.Error()) - return - } - - switch input.Action { - case "generate_api_key": - if input.Email == "" { - writeError("email 必填") - return - } - sig, ok := hmacSha256([]byte(input.Email)) - if !ok { - writeError("HMAC-SHA256 失敗") - return - } - apiKey := "ak_" + hex(sig)[:32] - out, _ := json.Marshal(map[string]interface{}{ - "success": true, - "api_key": apiKey, - }) - os.Stdout.Write(out) - - case "encrypt": - if input.Plaintext == "" { - writeError("plaintext 必填") - return - } - encB64, ivB64, ok := aesEncrypt([]byte(input.Plaintext)) - if !ok { - writeError("AES-GCM 加密失敗") - return - } - out, _ := json.Marshal(map[string]interface{}{ - "success": true, - "encrypted": encB64, - "iv": ivB64, - }) - os.Stdout.Write(out) - - case "random_token": - n := input.Bytes - if n <= 0 { - n = 32 - } - token, ok := randomBytes(uint32(n)) - if !ok { - writeError("random bytes 失敗") - return - } - out, _ := json.Marshal(map[string]interface{}{ - "success": true, - "token": token, - }) - os.Stdout.Write(out) - - default: - writeError("不支援的 action: " + input.Action) - } -} - -// ── helpers ─────────────────────────────────────────────────────────────────── - -func writeError(msg string) { - out, _ := json.Marshal(map[string]interface{}{ - "success": false, - "error": msg, - }) - os.Stdout.Write(out) -} - -func hmacSha256(data []byte) ([]byte, bool) { - if len(data) == 0 { - return nil, false - } - outBuf := make([]byte, 64) // SHA-256 = 32 bytes raw - var outLen uint32 - status := hostCryptoHmacSha256( - uintptr(unsafe.Pointer(&data[0])), uint32(len(data)), - uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), - ) - if status != 0 { - return nil, false - } - return outBuf[:outLen], true -} - -func aesEncrypt(plaintext []byte) (string, string, bool) { - if len(plaintext) == 0 { - return "", "", false - } - encBuf := make([]byte, 65536) - ivBuf := make([]byte, 64) - var encLen, ivLen uint32 - status := hostCryptoAesEncrypt( - uintptr(unsafe.Pointer(&plaintext[0])), uint32(len(plaintext)), - uintptr(unsafe.Pointer(&encBuf[0])), uintptr(unsafe.Pointer(&encLen)), - uintptr(unsafe.Pointer(&ivBuf[0])), uintptr(unsafe.Pointer(&ivLen)), - ) - if status != 0 { - return "", "", false - } - return string(encBuf[:encLen]), string(ivBuf[:ivLen]), true -} - -func randomBytes(n uint32) (string, bool) { - outBuf := make([]byte, n*2+4) // hex = 2 chars per byte - var outLen uint32 - status := hostCryptoRandomBytes( - n, - uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), - ) - if status != 0 { - return "", false - } - return string(outBuf[:outLen]), true -} - -// hex encodes raw bytes to lowercase hex string -func hex(b []byte) string { - const hexChars = "0123456789abcdef" - out := make([]byte, len(b)*2) - for i, v := range b { - out[i*2] = hexChars[v>>4] - out[i*2+1] = hexChars[v&0xf] - } - return string(out) -} - -// strings import 只為了 strings.Builder(interpolate 用,這裡不需要但 import 要保留給未來) -var _ = strings.Builder{} diff --git a/system-dev/docs/2-architecture/01-tech-stack.md b/system-dev/docs/2-architecture/01-tech-stack.md index a12cb8d..1270437 100644 --- a/system-dev/docs/2-architecture/01-tech-stack.md +++ b/system-dev/docs/2-architecture/01-tech-stack.md @@ -30,7 +30,8 @@ |---|---| | `u6u.http_request` | 發 HTTP 請求 | | `u6u.kv_get` | 讀 Cloudflare KV(Worker 側依 key 前綴路由到正確 KV) | -| `u6u.crypto_decrypt` | AES-GCM 解密(encryption key 永不暴露給 WASM) | +| `u6u.secret_get` | 讀 CF Workers Secrets(只放行 `CRED_` 前綴) | +| `u6u.crypto_decrypt` | ⚠️ 已廢除,保留成永遠回失敗的 stub(現役 wasm 仍宣告此 import) | | `u6u.crypto_sign_rs256` | RSA-SHA256 簽章(PKCS8 bytes 傳入) | **所有 host function 在 `cypher-executor/src/lib/wasi-shim.ts` 實作**。零件透過 WASI import 使用。 @@ -50,16 +51,22 @@ **警告:R2 不存平台內建零件的 WASM**。平台零件已 bundle 進各自的 Worker binary(`[[wasm_modules]]` 或 `import ... assert { type: 'webassembly' }`)。 -## 加解密規範 +## Credential 儲存規範 -- **演算法**:AES-GCM 256-bit -- **加密位置**:Client 端(CLI / Python SDK / JS SDK) - - Python:`cryptography` 套件 - - JS:Web Crypto API(`crypto.subtle`) -- **解密位置**:Server 端 **WASM primitive**(透過 host function `crypto_decrypt`) - - cypher-executor TS **不解密**,只提供 host function - - `ENCRYPTION_KEY` 只在 Worker host function 內部讀取,**永不經 stdin / 回傳值傳給 WASM** -- **傳輸格式**:`{ name, encrypted, iv }`(iv base64、encrypted base64) +**arcrun 不自管加密金鑰。** credential 明文由 **Cloudflare Workers Secrets** 託管 +(per-script secret,掛在用戶自己的 cypher worker 上): + +- secret 名稱 = `CRED_{NAME}_{sha256(api_key)[:8]}`(跨租戶命名隔離) +- D1 `credentials` 表只存**目錄**(api_key / name / service / secret_ref),不存值 +- D19:擁有目錄,不擁有內容物——連 owner 都讀不回,只能覆寫/刪除 +- 需要 worker 設 `CF_SECRETS_API_TOKEN`(機密,用戶手動 put)+ `CF_ACCOUNT_ID`(自動注入) + +> 註:`crypto_decrypt` host function 是**永遠回失敗的 stub**,因為現役三個 `auth_*` +> `.wasm` 仍宣告該 import(缺項會讓 WASM instantiate 失敗)。三個零件重編後即可刪除。 +- **取用位置**:Server 端 **WASM primitive**(透過 host function `secret_get(ref)`) + - cypher-executor TS 不碰業務邏輯,只提供 host function + - `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他機密 +- **傳輸格式**:明文值走 TLS(`POST /credentials`),不做 client 端加密 ## 網路部署 diff --git a/system-dev/docs/3-specs/arcrun-core-mvp/design.md b/system-dev/docs/3-specs/arcrun-core-mvp/design.md index a5cbb2e..b11306c 100644 --- a/system-dev/docs/3-specs/arcrun-core-mvp/design.md +++ b/system-dev/docs/3-specs/arcrun-core-mvp/design.md @@ -5,6 +5,9 @@ superseded_by: "" # Design Document: arcrun MVP +> ⚠️ 本檔的 credential 注入程式碼範例描述舊儲存機制,已於 2026-07-20 移除。 +> 現行做法見 `.claude/rules/01-tech-stack.md`「Credential 儲存規範」。 + ## Overview arcrun MVP 的核心設計原則是**最小異動、最快可用**。所有目標都能透過以下三個操作達成: diff --git a/system-dev/docs/3-specs/arcrun-core-mvp/tasks.md b/system-dev/docs/3-specs/arcrun-core-mvp/tasks.md index 477a015..2ae606c 100644 --- a/system-dev/docs/3-specs/arcrun-core-mvp/tasks.md +++ b/system-dev/docs/3-specs/arcrun-core-mvp/tasks.md @@ -97,7 +97,6 @@ - [x] 14. `acr init` 已實作,修正項: - [x] 14.1 Standard 模式不再傳送 `cf_api_token` 至 arcrun.dev(只傳 `email`) - [x] 14.2 `require()` 改用 `await import()` 修正 ES module 相容 - - [ ] 14.3 **待補**:`acr init` 需詢問 `ARCRUN_ENCRYPTION_KEY` 並寫入 config(目前加密 key 需手動設定) - _Requirements: 4.2, 6.3_ - [x] 15. `acr creds push` 已實作 @@ -193,7 +192,6 @@ - [ ] A. `builtins/` 清理:`initComponents.ts` 仍用舊的 HTTP endpoint 模式上架零件(`buildComponentDefs` 含 URL),應改為呼叫 `POST /submit` 送 WASM binary + contract,或直接移除 builtins(功能已整合到 registry) - [ ] B. `validate` 指令 credential 檢測邏輯修復(見 Phase 4 Task 18) -- [ ] C. `acr init` 加入 `ARCRUN_ENCRYPTION_KEY` 設定步驟 - [ ] D. `acr parts` YAML 解析改用 `js-yaml` --- diff --git a/system-dev/docs/3-specs/arcrun/arcrun.md b/system-dev/docs/3-specs/arcrun/arcrun.md index 56a91a2..f2927a7 100644 --- a/system-dev/docs/3-specs/arcrun/arcrun.md +++ b/system-dev/docs/3-specs/arcrun/arcrun.md @@ -63,10 +63,10 @@ P0 全部清除才啟動封測。 |---|------|------|------| | 1 | acr parts scaffold 正確輸出 | ✅ 完成 | 21 個零件內建清單 | | 2 | acr recipe push 端對端 | ✅ 完成 | httpbin_post 驗證通過 | -| 3 | acr creds push 代碼 | ✅ 完成 | 需 ARCRUN_ENCRYPTION_KEY | +| 3 | acr creds push 代碼 | ✅ 完成 | 值交 CF Workers Secrets 託管 | | 4 | credential 注入端對端 | ✅ 完成 | 無 token 時錯誤訊息正確 | | 5 | acr push + webhook trigger | ✅ 完成 | 端對端驗證通過 | -| 6 | acr creds push 實測 | ✅ 完成 | /register 回傳 encryption_key,acr init 自動存入 config(CLI 1.0.9)| +| 6 | acr creds push 實測 | ✅ 完成 | acr init 自動存入 config(CLI 1.0.9)| | 7 | Google Sheets 真實寫入 | ⚠️ 部分驗證 | credential 注入已驗證;實際 Sheets 寫入需真實 OAuth token | | 8 | 第三方服務認證 recipe | ✅ 完成 | 20 個服務(Notion/Slack/GitHub/OpenAI 等),CLI 1.1.0 | | **9** | **cypher-executor outbound HTTP fetch 全失效** | ✅ **已解決 2026-05-13**(CF 同 zone 自循環死鎖,改走 workers.dev)| 詳見下方專段 | @@ -339,8 +339,7 @@ acr push 就會自動建立 cron-idx 並開始定時觸發。 ## 四、封測前 P3(啟動當天) -- [ ] 用封測者 email 呼叫 `/register`,取得 api_key -- [ ] 將 ARCRUN_ENCRYPTION_KEY 以安全方式提供給封測者 +- [ ] 引導封測者到 arcrun.dev 登入取得 api_key - [ ] 確認聯絡管道 --- diff --git a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md index 67ff9e1..8b96aa1 100644 --- a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md +++ b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md @@ -1,5 +1,9 @@ # Credential Store 遷移 SDD — KV → D1(目錄)+ CF Workers per-script Secrets(密文) +> ⚠️ **歷史記錄(本卷 T1-T10 已全數完成,2026-07-20 收尾)**。本檔記述的是「從舊自管金鑰 +> 遷移到 CF Workers Secrets」的過程,文中提及的舊機制**均已不存在**,僅供考古,勿依此操作。 +> 現行做法見 `.claude/rules/01-tech-stack.md`「Credential 儲存規範」。 + > 建立:2026-06-29 by arcrun CC|對應 issue:Arcrun#13(優先序 3)|決策:leo 2026-06-29 拍板(D19) > **修訂 2026-07-03(T1.5 spike,證據 Arcrun#2)**:密文的家由「CF Secrets Store 產品」改為「**CF Workers per-script Secrets**」(`wrangler secret put` / API `PUT /accounts/{id}/workers/scripts/{script}/secrets` 那套)。原因:T1 spike 實證 Secrets Store 的 Worker binding 部署時靜態宣告、`.get(ref)` 參數被忽略,不支援「runtime 依 D1 ref 動態查任意 secret」;T1.5 spike 四驗證全過(API 動態加免重部署/`env[ref]` 動態索引可行/14 把未觸上限/重部署後存活),方向定案。§2.3、§2.5、T3、T4 已依此改寫;**雙讀過渡、回填、回滾、治理(D1 只存 metadata+secret_ref 不存密文)設計不動**。D21(2026-07-02 leo 拍板):當作 SaaS 不存在,按單用戶自架設計,多租戶段落標「future SaaS 再議」。 > 範圍宣告:本檔是既有 SDD `credential-primitives-wasm/` 的補充設計(rule 02 §4.3 例外:現有 SDD 目錄內新增單檔)。**不施工,先 SDD,總管審對齊後放行。** @@ -370,6 +374,11 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla 清空 → 另外手工在 KV 塞一筆「只存在舊 KV、無 D1 row」的假資料,`DELETE` 走 legacy-kv fallback 分支,CF API 核對 KV key 真的被刪除。兩分支皆對真實 leo21c 帳號驗證通過,非模擬。 -- [ ] T10 回填驗證 + 觀察期 → 廢 ENCRYPTION_KEY(§4.4,leo 明示放行)。 +- [x] T10 回填驗證 + 觀察期 → 舊自管金鑰路徑全數移除(§4.4,leo 明示放行)。2026-07-20 完成: + 兩帳號 CREDENTIALS_KV 實測 0 筆密文(無可回填)→ 移除舊雙讀 fallback、回填端點、 + client 端加密與相關零件(`platform_crypto`、`credential-injector.ts`、`register.ts`)。 + `crypto_decrypt` host function 保留成永遠回失敗的 stub(現役三個 `auth_*` .wasm 仍宣告 + 該 import,缺項會讓 WASM instantiate 失敗);三個零件重編後即可刪除。 + **本卷至此全數完成,屬歷史記錄。** > **每個 cred 操作跨 TS / WASM / host-function / 兩個 store,必端到端實測**(防再假綠,mindset §7)。原「T1 不通則整案停」已兌現一輪:T1 負結果 → 整案停 → 總管裁決轉向 → T1.5 全過 → **T2-T9 解凍**。備援(若施工再撞死路):codegen binding + 自動重部署(Arcrun#2 總管裁決的方向 1)。 diff --git a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-redesign.md b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-redesign.md index 3da910f..6482d8f 100644 --- a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-redesign.md +++ b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-redesign.md @@ -1,5 +1,8 @@ # Credential Store 重設計提案(A telegram 一致性 + B KV→D1 + C 友善前門) +> ⚠️ **歷史記錄**:本檔的儲存決策已被 `credential-store-migration.md` 取代並執行完畢 +> (2026-07-20)。文中提及的舊自管金鑰機制**已不存在**,僅供考古,勿依此操作。 + > 建立:2026-06-29 by arcrun CC|更新:2026-06-29(依 leo 最終精確 spec 改寫)|對應 issue:Arcrun#13 > 範圍宣告:本檔是既有 SDD `credential-primitives-wasm/` 的補充設計筆記(不是新 SDD 子系統,rule 02 §4.3 例外)。 > 詞彙(leo 堅持精確):**零件/component=TinyGo WASM**、**recipe=http_request+固定設定(打最終 API)**、 diff --git a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/design.md b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/design.md index 3694645..0f46054 100644 --- a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/design.md +++ b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/design.md @@ -5,6 +5,11 @@ superseded_by: "" # Design Document: Credential Primitives TS → WASM 改寫 +> ⚠️ **本檔的 credential 儲存/解密段落已過時**(`crypto_decrypt` 現為永遠回失敗的 stub, +> credential 值改由 CF Workers Secrets 託管、經 `secret_get(ref)` 取用)。遷移已於 +> 2026-07-20 完成,見 `credential-store-migration.md`;現行做法見 +> `.claude/rules/01-tech-stack.md`「Credential 儲存規範」。以下解密相關敘述僅供考古。 + ## Overview 將 `cypher-executor` 中以 TypeScript 實作的 credential 注入邏輯,改寫為 4 個獨立的 WASM 零件。這是 `credential_parts.md` 長期規格的實現,不再是「未來 Phase」。 diff --git a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/tasks.md b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/tasks.md index 5d1af70..e4bc312 100644 --- a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/tasks.md +++ b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/tasks.md @@ -1,5 +1,8 @@ # Implementation Tasks: Credential Primitives TS → WASM +> ⚠️ **歷史記錄**:文中 credential 加解密相關的完成記錄描述的是舊機制,該機制已於 +> 2026-07-20 完全移除(見 `credential-store-migration.md` T10)。僅供考古,勿依此操作。 + **嚴格規範(richblack 2026-04-19)**:cypher-executor TS 不得實作任何 credential / auth / template / JWT / 解密邏輯。全部走 TinyGo WASM + host functions(方案 B)。 **封測狀態**:推遲(richblack 2026-04-19 決定)。先完成 Phase 1-3 清除違規 TS,再啟動封測。 diff --git a/system-dev/docs/3-specs/arcrun/sdk-and-website/config-layering.md b/system-dev/docs/3-specs/arcrun/sdk-and-website/config-layering.md index 99af47b..64c6ba4 100644 --- a/system-dev/docs/3-specs/arcrun/sdk-and-website/config-layering.md +++ b/system-dev/docs/3-specs/arcrun/sdk-and-website/config-layering.md @@ -38,7 +38,6 @@ |---|---|---| | `ARCRUN_MODE` | `mode` | local/standard/self-hosted | | `ARCRUN_API_KEY` | `api_key` | standard | -| `ARCRUN_ENCRYPTION_KEY` | `encryption_key` | standard/self-hosted | | `ARCRUN_CYPHER_EXECUTOR_URL` | `cypher_executor_url` | self-hosted 指向自己的 cypher | | `CLOUDFLARE_ACCOUNT_ID` | `cloudflare_account_id` | self-hosted(沿用 wrangler 慣用名)| | `CLOUDFLARE_API_TOKEN` | `cf_api_token` | self-hosted(沿用 wrangler 慣用名)| diff --git a/system-dev/docs/3-specs/arcrun/sdk-and-website/self-hosted-init.md b/system-dev/docs/3-specs/arcrun/sdk-and-website/self-hosted-init.md index a5cde8d..7633c7c 100644 --- a/system-dev/docs/3-specs/arcrun/sdk-and-website/self-hosted-init.md +++ b/system-dev/docs/3-specs/arcrun/sdk-and-website/self-hosted-init.md @@ -25,7 +25,7 @@ - **`wrangler deploy` 部署全部 Worker**(用戶已裝 wrangler) - seed auth recipe + API recipe 進 RECIPES KV - 寫回 `~/.arcrun/config.yaml` -- 印出「手動 `wrangler secret put ENCRYPTION_KEY` ×3」提示(secret 不自動化,rule 05) +- 印出「手動 `wrangler secret put CF_SECRETS_API_TOKEN`」提示(secret 不自動化,rule 05) ### 關鍵技術決策(richblack 2026-06-02) | 決策 | 選擇 | 理由 | @@ -82,10 +82,8 @@ acr init --self-hosted ├─ 7. 寫回 config(mode: self-hosted + 所有 id + cypher_executor_url = 部署後 workers.dev URL) │ └─ 8. 印手動 secret 提示: - wrangler secret put ENCRYPTION_KEY --name arcrun-cypher-executor - wrangler secret put ENCRYPTION_KEY --name arcrun-auth-static-key - wrangler secret put ENCRYPTION_KEY --name arcrun-auth-service-account - (三 Worker 共用同一把 key,見 memory: encryption-key-drift-trap) + wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor + (credential 值交 CF Workers Secrets 託管;auth 零件不需 secret) ``` ### `acr update`(同一條路,未來新零件) @@ -208,7 +206,7 @@ codebase 只有 auth recipe seed。新增 `seed-api-recipes.ts`,把現役 API 1. richblack 用**全新 CF 帳號** + wrangler 已裝 + 一個 CF API Token 跑 `acr init --self-hosted` → 全程無手動建 KV / 無手動 clone / 無 tinygo / 無手動填 namespace id。 -2. 跑完印 secret 提示,richblack 手動 `wrangler secret put ENCRYPTION_KEY` ×3。 +2. 跑完印 secret 提示,richblack 手動 `wrangler secret put CF_SECRETS_API_TOKEN`。 3. `acr push` 一個含 http_request + 自建 recipe 的 workflow → trigger → **HTTP 2xx + execution trace**。 4. 冪等:重跑 init 不重建已存在 KV / 不報錯。 5. `acr update` 拉新 codeload tarball(tag)→ 重部署成功。 @@ -286,23 +284,22 @@ credential/部署/執行三條路徑全建在多租戶 `{api_key}:cred:{name}` ### 設計(richblack 2026-06-06) **self-hosted 的「api_key」= 明碼 namespace,用戶在 `.env` 自填。工具不生成、不 hash、不外傳。** -- 兩欄(像 username/password,但都由用戶持有): +- 一欄(由用戶持有): - `NAMESPACE=leo`(明碼分區標籤,當 KV 前綴;**非密碼**) - - `ENCRYPTION_KEY=<64+ hex>`(creds 加密金鑰,用戶自保管;須與 worker secret 一致) - **誠實限制**(mindset §7):namespace 明碼不提供認證。要防外部呼叫 → 對 webhook 加保護(mindset §6);arcrun 不做授權判斷(mindset §3)。 - **零分叉**:SaaS 仍走 register 發的真 api_key;self-hosted 走 .env 的 NAMESPACE。**同一條 `api_key` 路徑、同一份 config 解析**,只是值來源不同。 ### 改法 | 檔案 | 改動 | |---|---| -| `cli/src/lib/config.ts` | `ENV_MAP` 加 `NAMESPACE`/`ARCRUN_NAMESPACE`/`ENCRYPTION_KEY` → 對應 `api_key`/`encryption_key`;新增 `.env` 自動載入(就近往上找,shell > .env,無 dotenv 依賴)| -| `cli/src/commands/creds.ts`、`push.ts` | self-hosted 缺值時,引導設 `.env` 的 NAMESPACE/ENCRYPTION_KEY(不再叫去 register)| -| `cli/src/commands/init.ts` | self-hosted 結尾改提示「在 .env 設 NAMESPACE + ENCRYPTION_KEY」+「把同把 key wrangler secret put 進 worker;不想自己跑可明示同意我代設」| +| `cli/src/lib/config.ts` | `ENV_MAP` 加 `NAMESPACE`/`ARCRUN_NAMESPACE` → 對應 `api_key`;新增 `.env` 自動載入(就近往上找,shell > .env,無 dotenv 依賴)| +| `cli/src/commands/creds.ts`、`push.ts` | self-hosted 缺值時,引導設 `.env` 的 NAMESPACE | +| `cli/src/commands/init.ts` | self-hosted 結尾改提示「在 .env 設 NAMESPACE」+「把 CF_SECRETS_API_TOKEN wrangler secret put 進 worker;不想自己跑可明示同意我代設」| | `cypher-executor/src/routes/webhooks-named.ts` | 新增 `POST /webhooks/named/:ns/:name/trigger`(namespace 走 path,公開表單免 header);與 header 路徑共用 `triggerNamed`,不分叉 | | `cli/src/commands/push.ts` | self-hosted 輸出 path-based 公開 webhook URL(免 header)| ### 為何 namespace 進 URL path 安全 -namespace 是明碼標籤非密碼,放 path 不洩漏任何祕密。真正的祕密是 `ENCRYPTION_KEY`(在 worker secret + 用戶 .env,永不進 URL/path)。要防外部濫用 webhook 是「webhook 保護層」職責,非 namespace。 +namespace 是明碼標籤非密碼,放 path 不洩漏任何祕密。真正的祕密是 credential 值(由 CF Workers Secrets 託管,永不進 URL/path)。要防外部濫用 webhook 是「webhook 保護層」職責,非 namespace。 ## 7.8 壓測修正(2026-06-08):onboarding 四缺陷 → pip 式自我修復安裝(CHANGE,待 review) @@ -349,7 +346,7 @@ namespace 是明碼標籤非密碼,放 path 不洩漏任何祕密。真正的 - [x] **P1**:`acr whoami`(`cli/src/commands/whoami.ts`,人讀 + `--json`,印 mode/帳號/連哪台/來源層,薄殼讀 resolveConfigSources)+ MCP `arcrun_whoami`(`mcp/src/tools/arcrun_whoami.ts`,回報 orgNamespace + binding,與 CLI 對齊)。AI 問工具拿身份,不自己 curl 猜。cli+mcp tsc exit 0。**2026-06-09 完成** - [x] **P2**:MCP 掛載重啟提示(D3)。`cli/src/commands/mcp-setup.ts` 寫完 `.mcp.json` 後印「⚠ 請重啟 IDE/client 才會載入 project scope MCP」+ 信任工作區提醒。經 init 也會流到(init 呼叫 cmdMcpSetup)。**2026-06-09 完成** - [~] **P3**:README/GitHub 入口整理成「AI 讀了就照著裝」的單一指引(D 無腦入口)。**部分完成 2026-06-09**: - - 新增 repo 根 `.env.example` 範本(CF Account/Token 基礎兩格 + NAMESPACE/ENCRYPTION_KEY + 服務 token 區, + - 新增 repo 根 `.env.example` 範本(CF Account/Token 基礎兩格 + NAMESPACE + 服務 token 區, 每格上面白話說明「去哪申請、怎麼拿」,值留空。`.gitignore` 加 `!.env.example` 放行進 repo)。 - `llms.txt` step 3 改成「**你(AI)先 `cp .env.example .env`**,帶用戶填值(用戶只填「=」右邊)」—— 把「建 .env 結構」從用戶身上挪到 AI,用戶只做貼值。step 4 補 init「安裝驗收」說明。 diff --git a/system-dev/docs/3-specs/user-cc-harness/design.md b/system-dev/docs/3-specs/user-cc-harness/design.md index a00b5e7..03e245a 100644 --- a/system-dev/docs/3-specs/user-cc-harness/design.md +++ b/system-dev/docs/3-specs/user-cc-harness/design.md @@ -57,7 +57,7 @@ superseded_by: "" | P3 | `acr install-harness`(裝防護)| 否 | 自己跑 | CC 代跑(CC 讀 llms.txt 第一步就做)| | P4 | **建 CF 帳號 + 拿 Account ID + API Token** | **是**(憑證在用戶手上)| 自己建 | **CC 白癡化手把手帶**(§1.0 步驟2)| | P5 | `acr init --self-hosted`(貼 token,自動建 KV/部署/seed)| 否 | 自己跑 | CC 代跑(用 P4 拿到的兩串)| -| P6 | `wrangler secret put ENCRYPTION_KEY` ×3 | 否 | 自己跑 | CC 代跑 | +| P6 | `wrangler secret put CF_SECRETS_API_TOKEN` | 否 | 自己跑 | CC 代跑 | | P7 | 連 arcrun MCP(`claude mcp add`,CC 偏好的工具)| 否 | 自己跑 | CC 代跑(install-harness 順便)| ### 兩種執行者 diff --git a/system-dev/docs/4-guides/05-deploy-convention.md b/system-dev/docs/4-guides/05-deploy-convention.md index e48b4ad..0ee9fe3 100644 --- a/system-dev/docs/4-guides/05-deploy-convention.md +++ b/system-dev/docs/4-guides/05-deploy-convention.md @@ -62,10 +62,13 @@ find . -name 'wrangler.toml' -not -path '*/node_modules/*' -not -name 'wrangler. **CI 只提供 Cloudflare 驗證,不碰 runtime secret**。 - GH Actions secrets:`CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`(一次性設好) -- Runtime secret(例:`ENCRYPTION_KEY`、`OPENAI_KEY`、`GOOGLE_API_KEY`): +- Runtime secret(例:`CF_SECRETS_API_TOKEN`、`OPENAI_KEY`、`GOOGLE_API_KEY`): - **由 richblack 一次性手動** `wrangler secret put ` 設進各 Worker - 不進 CI,不進 `wrangler.toml` `[vars]` - - 需要的 Worker:`auth_static_key`、`auth_service_account`(兩個都要 `ENCRYPTION_KEY`) + - 需要的 Worker:`arcrun-cypher-executor`(`CF_SECRETS_API_TOKEN`——寫 credential 進 + Workers Secrets 用;`CF_ACCOUNT_ID` 非機密由 `acr init` 自動注入) + +> `auth_static_key` / `auth_service_account` 不需要任何 secret。 --- diff --git a/system-dev/docs/5-records/README.md b/system-dev/docs/5-records/README.md index a225a92..3ce351d 100644 --- a/system-dev/docs/5-records/README.md +++ b/system-dev/docs/5-records/README.md @@ -1,6 +1,10 @@ # 5. Records — 日誌 + 驗收報告 > 線上事件復盤、測試報告、決策軌跡。 +> +> ⚠️ **本目錄是「發生過什麼」的檔案館,不是現行做法**。內容反映當時狀態,其中提及的 +> 機制可能早已移除(例如 credential 的舊自管金鑰路徑已於 2026-07-20 完全移除)。 +> 勿依本目錄任何內容操作;現行規範一律以 `.claude/rules/` 為準。 ## 線上事件 diff --git a/system-dev/docs/5-records/incidents/2026-05-29-encryption-key-drift.md b/system-dev/docs/5-records/incidents/2026-05-29-encryption-key-drift.md index 4090071..fd370e8 100644 --- a/system-dev/docs/5-records/incidents/2026-05-29-encryption-key-drift.md +++ b/system-dev/docs/5-records/incidents/2026-05-29-encryption-key-drift.md @@ -1,5 +1,8 @@ # 2026-05-29 credential 解密失敗(兩個 Worker 的 ENCRYPTION_KEY 漂移) +> ⚠️ 歷史記錄(2026-07-20 起本文所述機制已完全移除,本文僅供考古,勿依此操作)。 +> 現行 credential 做法見 `.claude/rules/01-tech-stack.md`「Credential 儲存規範」。 + > **症狀**:`acr recipe test kbdb`(credential 注入)回 HTTP 500,`auth_static_key` 回 `credential kbdb_api_key 解密失敗` > **根因(主)**:`arcrun-auth-static-key` Worker 的 `ENCRYPTION_KEY` secret 跟正本(cypher-executor / CLI 用的那把)值不同、格式也不同(44-char base64 vs 64-char hex)。AES-GCM 用錯 key 必然解密失敗。 > **根因(附)**:`component-loader.ts` 用 `res.json().catch(() => res.text())` 讀 response body → body 被讀兩次 → `Body has already been used`。 diff --git a/system-dev/docs/5-records/test-reports/pre-customer-checklist-2026-06-07.md b/system-dev/docs/5-records/test-reports/pre-customer-checklist-2026-06-07.md index a382467..fa5f14c 100644 --- a/system-dev/docs/5-records/test-reports/pre-customer-checklist-2026-06-07.md +++ b/system-dev/docs/5-records/test-reports/pre-customer-checklist-2026-06-07.md @@ -1,5 +1,8 @@ # 交付前自測 Checklist(pre-customer)— 2026-06-07 +> ⚠️ 歷史記錄(2026-06-07 當時的清單)。其中 credential 相關步驟所依據的舊自管金鑰機制 +> 已於 2026-07-20 完全移除,**勿照本文操作**;現行做法見 `.claude/rules/01-tech-stack.md`。 + > 給 **你(人)** 在交給客戶前跑一次的精簡清單,不是給 Haiku 操盤的詳細壓測(那份在 > 壓測-recipe-library-2026-06-07.md)。順序照客戶真實旅程。**任一項 ❌ = 不能交付。** > 過關標準都是客觀證據(HTTP 2xx / D1 數字 / 檔案存在),禁口頭過關。 diff --git a/system-dev/docs/5-records/test-reports/壓測-recipe-library-2026-06-07.md b/system-dev/docs/5-records/test-reports/壓測-recipe-library-2026-06-07.md index adfb1ea..7474595 100644 --- a/system-dev/docs/5-records/test-reports/壓測-recipe-library-2026-06-07.md +++ b/system-dev/docs/5-records/test-reports/壓測-recipe-library-2026-06-07.md @@ -1,5 +1,8 @@ # 壓測 Test Case — Recipe 公庫/私庫機制 + UUID(2026-06-07 deploy 後) +> ⚠️ 歷史記錄。文中 credential 相關步驟依據的舊自管金鑰機制已於 2026-07-20 完全移除, +> **勿照本文操作**;現行做法見 `.claude/rules/01-tech-stack.md`。 + > 對象:本次上線的 kbdb-base §7.5(公庫/私庫雙向、UUID 身份、市場數據)+ 回歸。 > > **操盤模型:全程 Haiku。Haiku 能搞定是「設計目標」不只是壓測手段(richblack 2026-06-07)。** diff --git a/system-dev/wiki/cards/decisions/00-INDEX.md b/system-dev/wiki/cards/decisions/00-INDEX.md index c681fa3..c5187fd 100644 --- a/system-dev/wiki/cards/decisions/00-INDEX.md +++ b/system-dev/wiki/cards/decisions/00-INDEX.md @@ -23,7 +23,6 @@ ## credential / recipe -- [[多worker-ENCRYPTION_KEY同步]] — 各 Worker secret put 同份 key,漂移→解密失敗表現為 401/403 - [[Recipe-UUID市場模型]] — canonical+uuid+author 多維身份,per-uuid 市場數據 ## kbdb diff --git a/system-dev/wiki/cards/decisions/多worker-ENCRYPTION_KEY同步.md b/system-dev/wiki/cards/decisions/多worker-ENCRYPTION_KEY同步.md deleted file mode 100644 index 3785ce5..0000000 --- a/system-dev/wiki/cards/decisions/多worker-ENCRYPTION_KEY同步.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -tags: [credential, 部署, 架構決策, 踩坑] -gloss: 多個 Worker(auth primitive + cypher-executor)共享 ENCRYPTION_KEY,用 wrangler secret put 手動設進各 Worker secret store,不用 KV。 ---- -# 多 Worker ENCRYPTION_KEY 同步 - -← [[decisions/00-INDEX]] - -**來源**:`system-dev/wiki/decisions-summary.md`(多 Worker ENCRYPTION_KEY 同步)、`.claude/rules/01-tech-stack.md` 加解密規範 -**最後更新**:2026-06-27 - -## 摘要 -Auth static_key / Auth service_account / cypher-executor 三個 Worker 都需 ENCRYPTION_KEY 來解密 credential。用各 Worker 的 secret store(非環境變數)存放;初期化時 `acr init` 生成一份 key,用 `wrangler secret put ENCRYPTION_KEY` 各設一次。 - -## 重點 -- **Secret 存儲位置**:各 Worker 的 secret store(Cloudflare 原生機制),不是環境變數、不是 KV。環境變數會洩漏到日誌;KV 的 list 操作可能外洩。 -- **初始化流程**:`acr init` 生成一份 32 字節隨機 key → 印出一次 → 用戶自己跑 `wrangler secret put ENCRYPTION_KEY --path ` 分別設進三個 Worker。 -- **冪等性問題**:目前 `acr init` 多跑幾次會生成不同 key(不冪等)。長期應改成「init 檢查現有 config → 若存在 key 則重用舊的、否則生成新的」。 -- **漂移陷阱**:若某個 Worker 的 key 遺漏或與其他 Worker 不同 → credential 解密失敗 → 表現為 401/403(用戶困惑,難debug)。 -- **驗證方法**:init 完成後做一個 test workflow 打一個認證過的 API(如 gmail),確認三個 Worker 的 key 一致。 - -## 實體 -- **ENCRYPTION_KEY**(加密密鑰)— 32 字節 AES-GCM 密鑰,用來解密用戶的 credential。 -- **Secret store**(Cloudflare 密鑰存儲)— Worker 的原生機制,用 `wrangler secret put` 設置、runtime 讀取,內容不外洩。 -- **密鑰漂移**(key drift)— 多個 Worker 持有不同版本的 ENCRYPTION_KEY,導致某些 Worker 解密失敗。 -- **冪等性**(idempotency)— `acr init` 多次運行應產生同一結果(目前不達成)。 -- **解密失敗**(decryption failure)— Worker 因 key 不匹配無法解密 credential,表現為 401/403 錯誤。 - -## 關聯 -### 內文知識關係 -- ENCRYPTION_KEY >> 分散存儲於 >> Secret store -- 密鑰漂移 >> 導致 >> 解密失敗 -- 冪等性 >> 缺乏時 >> 重跑 init 造成 key 不一致 -### 卡片關係 -- (相關 memory:`encryption-key-drift-trap` — 解密失敗先比 key 指紋;屬 auto-memory 非 card) diff --git a/system-dev/wiki/decisions-summary.md b/system-dev/wiki/decisions-summary.md index 39bb2a1..c7a863e 100644 --- a/system-dev/wiki/decisions-summary.md +++ b/system-dev/wiki/decisions-summary.md @@ -110,32 +110,6 @@ compatibility_flags = [ "nodejs_compat", "global_fetch_strictly_public" ] --- -## 多 Worker ENCRYPTION_KEY 同步(2026-05-29) - -**Q:auth_static_key / auth_service_account / cypher-executor 都需 ENCRYPTION_KEY,怎麼保持一致?** - -**決策**: -- secret 存在各 Worker 的 secret store(非環境變數,避免洩漏) -- `wrangler secret put ENCRYPTION_KEY` 手動設進各 Worker -- 初始化:`acr init` 生成,展示一次,user 自己 secret put - -**為什麼不用 KV**: -- secret 是敏感內容,不應在 KV 存(會被 list 洩漏) -- secret store 是 Cloudflare 的原生機制 - -**冪等性**: -- `acr init` 多跑幾次,生成不同 key(目前不冪等) -- 若要冪等,init 應檢查現有 config → reuse 舊 key - -**避坑**: -- init 完成後驗證所有三個 Worker 都有同一份 key -- 若某個 Worker 的 key 遺漏或不同 → credential 解密失敗(會表現為 401/403) -- 重跑 init 不要覆蓋舊 secret(目前沒有 check,need improvement) - -**詳見**:2026-05-29-encryption-key-drift.md、rule 01 加解密 - ---- - ## Recipe UUID 模型(kbdb-base §7.5) **Q:多作者同 canonical recipe 怎麼並存?** diff --git a/system-dev/wiki/mistakes.md b/system-dev/wiki/mistakes.md index 6d82fc9..68e186c 100644 --- a/system-dev/wiki/mistakes.md +++ b/system-dev/wiki/mistakes.md @@ -98,26 +98,6 @@ metadata: --- -## 6. 多 Worker 共用 ENCRYPTION_KEY 漂移(encryption-key-drift) - -**錯誤模式**:同一個 key 在多個 Worker 的 secret store 中不一致。 - -**根因**: -- `acr init --self-hosted` 不是幪等的 -- 用戶重跑 init,key 重新生成或未同步到所有 Worker -- 某個 Worker 還用舊 key,解密失敗 - -**後果**: -- credential 無法解密,workflow 執行失敗(401/403) -- 調試難度高,表現為「缺 credential」 - -**避坑**: -- init 完成後,驗證所有 secret_target_workers(auth_static_key / auth_service_account / cypher-executor)都有同一份 key -- 若已部署過,重跑 init 時 **skip secret put**(不要覆蓋) -- 或提供「檢查 key 一致性」的端點(未實作) - ---- - ## 7. 同 zone 1042(self-hosted cypher 打 auth worker) **錯誤模式**:cypher 和 auth worker 同 zone(都是 {sub}.workers.dev),cypher fetch 打 auth 返回 522。 diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index f6aa288..e4e8f5c 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -252,7 +252,6 @@ metadata: | 4 份 inline http_request host fn 抽共用 helper | 🟡 中 | 待 dedup | http_request/claude_api/kbdb_upsert_block/km_writer 各自複製貼上同段(這次假綠修也是逐份改) | | `arcrun.dev/llms.txt` 404 | 🟡 中 | 未 serve | landing/public 缺檔;GitHub repo 內正常(test/5 走 GitHub 不阻擋) | | MCP account-source | 🟡 中 | 記錄中 | self-hosted MCP 指官方不指自己(§5.2 已知) | -| ENCRYPTION_KEY 冪等性 | 🟡 中 | 設計中 | init 多跑生成新 key,無法複用舊 key | | recipe submitted 後沒有 uuid | 🟡 中 | 待驗 | submit-p 應回 uuid,CLI 拿不到 | | ~~KV list 爆量~~ | ✅ 已解 | §8 P0 部署 | cron list→單 key get,1440/日→0(2026-06-09) | | ~~onboarding 缺陷(4 項)~~ | ✅ 已解 | §7.8 P0/P1/P2 + P3 部分 | CLI 1.3.4,Haiku 壓測證實裝+init 不跳過 | @@ -310,7 +309,7 @@ metadata: 6. [ ] `arcrun.dev/llms.txt` serve(landing/public 補檔) 7. [ ] 補 wiki modules/(文件遷移階段一~三已於 2026-06-14 完成) -8. [ ] ENCRYPTION_KEY 冪等性、MCP account-source、recipe submit uuid 回傳 +8. [ ] MCP account-source、recipe submit uuid 回傳 ---