From 5141b7882bfc1a53d15c1cedc7c5803f6706fd3d Mon Sep 17 00:00:00 2001 From: bookworm Date: Mon, 6 Apr 2026 23:39:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=9A=E7=94=A8=E6=88=B7=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=20relay=20Key=20=E6=94=AF=E6=8C=81=20(=E6=96=B9?= =?UTF-8?q?=E6=A1=88B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gen-authcode.js: - 新增 --relay-key/-k 参数,替换 ANTHROPIC_API_KEY 为中转站限额子 Key - 新增 --user/-u 参数(仅显示标识) - 多用户模式输出 secrets-{token前8位}.enc,单用户仍输出 secrets.enc install.ps1: - 新增 Resolve-SecretsFile: 优先找 secrets-XXXXXXXX.enc,回退 secrets.enc - Decrypt-Secrets 按 token 前8位定位加密文件 auto-setup.ps1: - Phase 4 同步 Resolve-SecretsFile 逻辑(GUI 路径) - 文件未找到时弹窗提示拉取对应文件 Bookworm-Setup.sh: - 新增 resolve_secrets_file() bash 函数 - 解密循环按 token 前8位定位 .enc 文件 Co-Authored-By: Claude Opus 4.6 (1M context) --- Bookworm-Setup.sh | 26 ++++++++- auto-setup.ps1 | 24 ++++++--- gen-authcode.js | 133 ++++++++++++++++++++++++++++++++++------------ install.ps1 | 31 +++++++---- 4 files changed, 163 insertions(+), 51 deletions(-) diff --git a/Bookworm-Setup.sh b/Bookworm-Setup.sh index bdaf6cf..5712c64 100644 --- a/Bookworm-Setup.sh +++ b/Bookworm-Setup.sh @@ -274,6 +274,20 @@ _decrypt_secrets() { fi } +# 按 token 前8位定位 .enc 文件 (多用户模式),回退 secrets.enc +resolve_secrets_file() { + local token="$1" + local file_id="${token:0:8}" + local per_user="$BOOT_DIR/secrets-${file_id}.enc" + if [ -f "$per_user" ]; then + echo "$per_user" + elif [ -f "$SECRETS_ENC" ]; then + echo "$SECRETS_ENC" + else + echo "" + fi +} + # 解析授权码: BW-YYYYMMDD-TOKEN (24位Hex) # 返回: 小写 token (成功) | "EXPIRED" (已过期) | "" (格式错误) parse_authcode() { @@ -298,7 +312,7 @@ parse_authcode() { # 先尝试缓存 if load_cached_secrets 2>/dev/null; then : # 缓存加载成功 -elif [ -f "$SECRETS_ENC" ]; then +elif [ -f "$SECRETS_ENC" ] || ls "$BOOT_DIR"/secrets-*.enc 2>/dev/null | head -1 | grep -q .; then DECRYPTED="" valid_attempts=0 total_attempts=0 @@ -316,7 +330,15 @@ elif [ -f "$SECRETS_ENC" ]; then continue # 不消耗有效次数 fi valid_attempts=$((valid_attempts + 1)) - DECRYPTED=$(_decrypt_secrets "$TOKEN" "$SECRETS_ENC") || true + ENC_FILE=$(resolve_secrets_file "$TOKEN") + if [ -z "$ENC_FILE" ]; then + FILE_ID="${TOKEN:0:8}" + warn "未找到对应凭证文件 (secrets-${FILE_ID}.enc / secrets.enc)" + warn "请联系管理员确认已推送对应文件,然后重新运行安装器" + TOKEN="" + continue + fi + DECRYPTED=$(_decrypt_secrets "$TOKEN" "$ENC_FILE") || true TOKEN="" if [ -n "$DECRYPTED" ]; then while IFS= read -r line; do diff --git a/auto-setup.ps1 b/auto-setup.ps1 index 76282cc..b4759cf 100644 --- a/auto-setup.ps1 +++ b/auto-setup.ps1 @@ -611,7 +611,7 @@ $useNode = (Test-Cmd "node") -and (Test-Path $cryptoHelper) if (-not $useNode -and -not $opensslCmd) { Log-Fail "无解密工具 (需要 Node.js 或 OpenSSL)" } -elseif (Test-Path $SecretsEnc) { +elseif ((Test-Path $SecretsEnc) -or (Get-ChildItem $ScriptDir -Filter "secrets-*.enc" -ErrorAction SilentlyContinue)) { $validAttempts = 0 while ($validAttempts -lt 3) { $rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3 @@ -631,15 +631,25 @@ elseif (Test-Path $SecretsEnc) { } $validAttempts++ + # 按 token 前8位定位 .enc 文件 (多用户独立 Key),回退 secrets.enc + $fileId = $token.Substring(0, 8) + $encFile = Join-Path $ScriptDir "secrets-$fileId.enc" + if (-not (Test-Path $encFile)) { $encFile = $SecretsEnc } + if (-not (Test-Path $encFile)) { + Show-MsgBox "未找到对应凭证文件。`n请确认管理员已推送 secrets-$fileId.enc 到 Gitea`n并重新运行安装器(会自动拉取)。" "文件未找到" "OK" "Warning" + $token = $null + continue + } + try { if ($useNode) { - $decrypted = & node $cryptoHelper decrypt $token $SecretsEnc 2>&1 + $decrypted = & node $cryptoHelper decrypt $token $encFile 2>&1 $decExit = $LASTEXITCODE } else { - $decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $SecretsEnc -pass "pass:$token" 2>$null + $decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $encFile -pass "pass:$token" 2>$null $decExit = $LASTEXITCODE } - $token = $null # 立即清零 + $token = $null if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') { $count = 0 @@ -667,7 +677,7 @@ elseif (Test-Path $SecretsEnc) { $token = $null $remaining = 3 - $validAttempts if ($remaining -gt 0) { - Show-MsgBox "授权码无效(解密失败),剩余重试: $remaining 次`n`n请确认管理员已更新 secrets.enc。" "验证失败" "OK" "Warning" + Show-MsgBox "授权码无效(解密失败),剩余重试: $remaining 次" "验证失败" "OK" "Warning" } else { Show-MsgBox "3 次验证均失败。`n请联系管理员重新获取授权码。" "解密失败" "OK" "Error" } @@ -678,8 +688,8 @@ elseif (Test-Path $SecretsEnc) { } } } -elseif (-not (Test-Path $SecretsEnc)) { - Log-Warn "secrets.enc 不存在, 跳过凭证解密" +else { + Log-Warn "未找到任何 secrets*.enc,跳过凭证解密" } } # end if (-not $secretsDecrypted) diff --git a/gen-authcode.js b/gen-authcode.js index 6aa10af..a2158e4 100644 --- a/gen-authcode.js +++ b/gen-authcode.js @@ -4,14 +4,23 @@ * Bookworm 授权码生成工具 (管理员使用) * * 用法: - * node gen-authcode.js [secrets.txt路径] - * node gen-authcode.js 30 - * node gen-authcode.js 90 /path/to/secrets.txt + * node gen-authcode.js [选项] + * + * 选项: + * --relay-key, -k 中转站限额子 Key (替换 ANTHROPIC_API_KEY) + * --user, -u 用户标识 (仅用于显示, 不影响加密) + * [secrets.txt路径] 明文凭证文件 (默认: ./secrets.txt) + * + * 示例: + * node gen-authcode.js 30 # 共享 Key (单用户) + * node gen-authcode.js 30 --relay-key sk-relay-xxx # 独立限额 Key + * node gen-authcode.js 90 -k sk-relay-xxx -u alice # 指定用户名 * * 原理: * 1. 生成随机 24位Hex Token (96bit 熵) - * 2. 将 Token 作为密码重新加密 secrets.enc (BWENC1 格式) - * 3. 输出授权码 BW-YYYYMMDD-TOKEN (发给用户) + * 2. Token 前8位 = 文件 ID → 输出 secrets-XXXXXXXX.enc (多用户模式) + * 无 --relay-key 时 → 输出 secrets.enc (单用户/共享模式) + * 3. 授权码 BW-YYYYMMDD-TOKEN (发给用户) * 4. 安全说明: Token = 解密密钥, YYYYMMDD = 客户端到期校验 */ const crypto = require('crypto'); @@ -36,22 +45,39 @@ function encrypt(plaintext, password) { const iv = derived.slice(KEY_LEN, KEY_LEN + IV_LEN); const cipher = crypto.createCipheriv(ALGO, key, iv); const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); - // 与 crypto-helper.js 保持 BWENC1 格式兼容 return Buffer.concat([Buffer.from('BWENC1'), salt, encrypted]); } -// ─── CLI ──────────────────────────────────────────────────────── -const DAYS = parseInt(process.argv[2]); +// ─── 解析 CLI 参数 ─────────────────────────────────────── +const rawArgs = process.argv.slice(2); +const DAYS = parseInt(rawArgs[0]); + if (!DAYS || DAYS < 1 || DAYS > 3650) { - console.error('用法: node gen-authcode.js <有效天数> [secrets.txt路径]'); - console.error('示例: node gen-authcode.js 30'); - console.error('示例: node gen-authcode.js 90 /path/to/secrets.txt'); + console.error('用法: node gen-authcode.js <有效天数> [选项]'); + console.error('选项:'); + console.error(' --relay-key, -k 中转站限额子 Key'); + console.error(' --user, -u 用户标识 (仅显示)'); + console.error(' [secrets.txt路径] 默认: ./secrets.txt'); + console.error(''); + console.error('示例:'); + console.error(' node gen-authcode.js 30 # 共享模式'); + console.error(' node gen-authcode.js 30 -k sk-relay-xxx -u alice # 独立限额'); process.exit(1); } -const SCRIPT_DIR = path.dirname(path.resolve(__filename)); -const SECRETS_TXT = process.argv[3] || path.join(SCRIPT_DIR, 'secrets.txt'); -const SECRETS_ENC = path.join(SCRIPT_DIR, 'secrets.enc'); +let relayKey = null; +let userName = null; +let secretsTxtArg = null; + +for (let i = 1; i < rawArgs.length; i++) { + const a = rawArgs[i]; + if (a === '--relay-key' || a === '-k') { relayKey = rawArgs[++i]; } + else if (a === '--user' || a === '-u') { userName = rawArgs[++i]; } + else if (!a.startsWith('-')) { secretsTxtArg = a; } +} + +const SCRIPT_DIR = path.dirname(path.resolve(__filename)); +const SECRETS_TXT = secretsTxtArg || path.join(SCRIPT_DIR, 'secrets.txt'); if (!fs.existsSync(SECRETS_TXT)) { console.error(`[错误] 找不到 secrets.txt: ${SECRETS_TXT}`); @@ -59,38 +85,79 @@ if (!fs.existsSync(SECRETS_TXT)) { process.exit(1); } -// 生成到期日 (YYYYMMDD) +if (relayKey && !/^[A-Za-z0-9\-_.]+$/.test(relayKey)) { + console.error('[错误] --relay-key 格式不合法'); + process.exit(1); +} + +// ─── 生成到期日 ────────────────────────────────────────── const expiry = new Date(); expiry.setDate(expiry.getDate() + DAYS); const pad = n => String(n).padStart(2, '0'); const expiryStr = `${expiry.getFullYear()}${pad(expiry.getMonth()+1)}${pad(expiry.getDate())}`; - -// 生成随机 Token (12 bytes = 24 hex, 96bit 熵) -const token = crypto.randomBytes(12).toString('hex'); // 小写 - -// 构造授权码 (Token 展示为大写, 解密时转小写) -const authCode = `BW-${expiryStr}-${token.toUpperCase()}`; - -// 读取并加密 secrets.txt -const secretsPlain = fs.readFileSync(SECRETS_TXT, 'utf8').trim(); -const encBuffer = encrypt(secretsPlain, token); // 用小写 token 加密 -fs.writeFileSync(SECRETS_ENC, encBuffer); - const expiryDisplay = `${expiryStr.slice(0,4)}-${expiryStr.slice(4,6)}-${expiryStr.slice(6,8)}`; +// ─── 生成随机 Token ────────────────────────────────────── +const token = crypto.randomBytes(12).toString('hex'); // 小写 24位 +const authCode = `BW-${expiryStr}-${token.toUpperCase()}`; +const fileId = token.slice(0, 8); // 前8位作为文件 ID + +// ─── 构建待加密内容 ────────────────────────────────────── +let secretsPlain = fs.readFileSync(SECRETS_TXT, 'utf8').trim(); + +const multiUser = !!relayKey; + +if (multiUser) { + // 用中转站 relay key 替换 ANTHROPIC_API_KEY + if (/^ANTHROPIC_API_KEY=/m.test(secretsPlain)) { + secretsPlain = secretsPlain.replace( + /^ANTHROPIC_API_KEY=.*/m, + `ANTHROPIC_API_KEY=${relayKey}` + ); + } else { + secretsPlain = `ANTHROPIC_API_KEY=${relayKey}\n${secretsPlain}`; + } +} + +// ─── 加密并写出 ────────────────────────────────────────── +const encBuffer = encrypt(secretsPlain, token); + +let outFileName, outFilePath; +if (multiUser) { + outFileName = `secrets-${fileId}.enc`; +} else { + outFileName = 'secrets.enc'; +} +outFilePath = path.join(SCRIPT_DIR, outFileName); +fs.writeFileSync(outFilePath, encBuffer); + +// ─── 输出 ──────────────────────────────────────────────── +const modeLabel = multiUser + ? `多用户独立 Key (${userName || '未命名'})` + : '共享 Key (单/多用户共享)'; + console.log('\n═══════════════════════════════════════════════════'); console.log(' Bookworm 授权码生成完毕'); console.log('═══════════════════════════════════════════════════'); console.log(''); -console.log(` 授权码: ${authCode}`); -console.log(` 有效期: ${DAYS} 天 (至 ${expiryDisplay})`); +console.log(` 模式: ${modeLabel}`); +if (userName) console.log(` 用户: ${userName}`); +console.log(` 授权码: ${authCode}`); +console.log(` 有效期: ${DAYS} 天 (至 ${expiryDisplay})`); +if (multiUser) { + console.log(` Relay Key: ${relayKey.slice(0,12)}... (已替换 ANTHROPIC_API_KEY)`); + console.log(` 文件 ID: ${fileId} → ${outFileName}`); +} console.log(''); console.log(' ▶ 操作步骤:'); -console.log(' 1. 将授权码通过微信/邮件发给用户'); -console.log(' 2. 推送新 secrets.enc 到 Gitea:'); -console.log(' git add secrets.enc && git commit -m "update secrets" && git push'); +console.log(` 1. 将授权码发给用户: ${authCode}`); +console.log(` 2. 推送 ${outFileName} 到 Gitea:`); +console.log(` git add ${outFileName} && git commit -m "add user ${userName || fileId}" && git push`); console.log(''); console.log(' ⚠ 安全提醒:'); console.log(' - 授权码即解密密钥, 请勿通过不安全渠道明文发送'); -console.log(` - 到期日 (${expiryDisplay}) 后自动失效, 无需撤销操作`); +console.log(` - 到期日 (${expiryDisplay}) 后自动失效`); +if (multiUser) { + console.log(` - 各用户 secrets-XXXXXXXX.enc 独立, 轮换互不影响`); +} console.log('═══════════════════════════════════════════════════\n'); diff --git a/install.ps1 b/install.ps1 index 6334435..2d2cd6c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -217,11 +217,18 @@ function Parse-AuthCode { return $token } +function Resolve-SecretsFile { + param([string]$token) + # 优先找 secrets-{token前8位}.enc (多用户独立 Key),回退 secrets.enc + $fileId = $token.Substring(0, 8) + $perUser = Join-Path $ScriptDir "secrets-$fileId.enc" + if (Test-Path $perUser) { return $perUser } + if (Test-Path $SecretsEnc) { return $SecretsEnc } + return $null +} + function Decrypt-Secrets { - if ($SkipSecrets -or -not (Test-Path $SecretsEnc)) { - Write-Host " [!] 跳过凭证解密 (无 secrets.enc)" -ForegroundColor Yellow - return - } + if ($SkipSecrets) { return } # 优先用 Node.js 解密 (跨平台兼容性最高), 回退 openssl $useNode = (Test-Command "node") -and (Test-Path (Join-Path $ScriptDir "crypto-helper.js")) @@ -238,20 +245,26 @@ function Decrypt-Secrets { $label = if ($validAttempts -gt 0) { " 重新输入授权码 (第 $($validAttempts+1)/3 次)" } else { " 输入授权码 (格式: BW-YYYYMMDD-...)" } $authCodeRaw = Read-Host $label $plainPwd = Parse-AuthCode $authCodeRaw - if (-not $plainPwd) { - # 格式错误或已过期: 不计入有效重试次数 + if (-not $plainPwd) { continue } + $validAttempts++ + + # 按 token 前8位定位 .enc 文件 + $encFile = Resolve-SecretsFile $plainPwd + if (-not $encFile) { + Write-Host " [!!] 未找到对应的凭证文件 (secrets-*.enc / secrets.enc)" -ForegroundColor Red + Write-Host " 请确认管理员已推送对应文件到 Gitea 并重新拉取" -ForegroundColor Yellow + $plainPwd = $null continue } - $validAttempts++ $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" if ($useNode) { - $decrypted = & node $cryptoHelper decrypt $plainPwd $SecretsEnc 2>&1 + $decrypted = & node $cryptoHelper decrypt $plainPwd $encFile 2>&1 $decExit = $LASTEXITCODE } else { - $decrypted = $plainPwd | & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -md sha256 -in $SecretsEnc -pass stdin 2>&1 + $decrypted = $plainPwd | & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -md sha256 -in $encFile -pass stdin 2>&1 $decExit = $LASTEXITCODE } $ErrorActionPreference = $prevEAP