From b83c508c22aae147bb8d582aeb38a1e41aa6bd75 Mon Sep 17 00:00:00 2001 From: bookworm Date: Mon, 6 Apr 2026 22:47:04 +0800 Subject: [PATCH] feat: replace master password auth with time-limited authorization codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth codes use format BW-YYYYMMDD-TOKEN (24-hex, 96-bit entropy). Token doubles as the AES-256-CBC decryption key for secrets.enc. Expiry is enforced client-side; format/expiry errors don't consume the 3 valid-attempt quota. - gen-authcode.js: new admin tool — generates BW auth code + re-encrypts secrets.enc - install.ps1: Parse-AuthCode validates format/expiry, Decrypt-Secrets uses token as key - auto-setup.ps1: Show-AuthCodeDialog WinForms input + Parse-AuthCode-GUI loop - Bookworm-Setup.sh: parse_authcode() bash function + while-loop with format/expiry handling Co-Authored-By: Claude Opus 4.6 (1M context) --- Bookworm-Setup.sh | 49 ++++++++++++++++++---- auto-setup.ps1 | 102 ++++++++++++++++++++++++++++++---------------- gen-authcode.js | 96 +++++++++++++++++++++++++++++++++++++++++++ install.ps1 | 37 ++++++++++++++--- 4 files changed, 233 insertions(+), 51 deletions(-) create mode 100644 gen-authcode.js diff --git a/Bookworm-Setup.sh b/Bookworm-Setup.sh index c47d457..4c6570e 100644 --- a/Bookworm-Setup.sh +++ b/Bookworm-Setup.sh @@ -274,17 +274,48 @@ _decrypt_secrets() { fi } +# 解析授权码: BW-YYYYMMDD-TOKEN (24位Hex) +# 返回: 小写 token (成功) | "EXPIRED" (已过期) | "" (格式错误) +parse_authcode() { + local code + code=$(echo "$1" | tr '[:lower:]' '[:upper:]' | tr -d ' ') + # 格式校验: BW-8位数字-24位Hex + if [[ ! "$code" =~ ^BW-([0-9]{8})-([A-F0-9]{24})$ ]]; then + echo "" + return + fi + local expiry_str="${BASH_REMATCH[1]}" + local token_upper="${BASH_REMATCH[2]}" + local today + today=$(date +%Y%m%d) + if [ "$expiry_str" -lt "$today" ]; then + echo "EXPIRED" + return + fi + echo "${token_upper,,}" # bash4+ 转小写 +} + # 先尝试缓存 if load_cached_secrets 2>/dev/null; then : # 缓存加载成功 elif [ -f "$SECRETS_ENC" ]; then DECRYPTED="" - for attempt in 1 2 3; do + valid_attempts=0 + while [ $valid_attempts -lt 3 ]; do echo "" - read -rs -p " 输入主密码解密凭证 (第 $attempt/3 次): " PASSWORD - echo "" - DECRYPTED=$(_decrypt_secrets "$PASSWORD" "$SECRETS_ENC") || true - PASSWORD="" + read -p " 输入授权码 (BW-YYYYMMDD-XXXXXX, 第 $((valid_attempts+1))/3 次): " AUTH_CODE + TOKEN=$(parse_authcode "$AUTH_CODE") + AUTH_CODE="" + if [ "$TOKEN" = "EXPIRED" ]; then + warn "授权码已过期, 请联系管理员获取新授权码" + continue # 不消耗尝试次数 + elif [ -z "$TOKEN" ]; then + warn "授权码格式错误 (格式: BW-YYYYMMDD-24位字母数字)" + continue # 不消耗尝试次数 + fi + valid_attempts=$((valid_attempts + 1)) + DECRYPTED=$(_decrypt_secrets "$TOKEN" "$SECRETS_ENC") || true + TOKEN="" if [ -n "$DECRYPTED" ]; then while IFS= read -r line; do [ -z "$line" ] && continue @@ -306,10 +337,10 @@ elif [ -f "$SECRETS_ENC" ]; then fi break else - if [ $attempt -lt 3 ]; then - warn "密码错误, 剩余重试: $((3 - attempt)) 次" + if [ $valid_attempts -lt 3 ]; then + warn "授权码无效 (解密失败), 剩余重试: $((3 - valid_attempts)) 次" else - fail "3 次密码均错误, 凭证未解密" + fail "3 次授权码均无效, 凭证未解密" warn "可稍后手动配置 API Key" fi fi @@ -317,7 +348,7 @@ elif [ -f "$SECRETS_ENC" ]; then else if [ ! -f "$SECRETS_ENC" ]; then warn "secrets.enc 不存在, 跳过凭证解密" - info "请联系管理员获取加密凭证文件" + info "请联系管理员获取授权码" fi fi diff --git a/auto-setup.ps1 b/auto-setup.ps1 index 067e54a..35e7896 100644 --- a/auto-setup.ps1 +++ b/auto-setup.ps1 @@ -48,10 +48,21 @@ function Show-MsgBox($text, $title = "Bookworm 安装", $buttons = "OK", $icon = [System.Windows.Forms.MessageBox]::Show($text, $title, $buttons, $icon) } -function Show-PasswordDialog($prompt = "输入主密码解密凭证", $attempt = 1, $maxAttempts = 3) { +function Parse-AuthCode-GUI { + param([string]$code) + $code = $code.Trim() + if ($code -notmatch '^BW-(\d{8})-([A-Fa-f0-9]{24})$') { return $null } + $expiryStr = $Matches[1] + $token = $Matches[2].ToLower() + $today = (Get-Date).ToString("yyyyMMdd") + if ([int]$expiryStr -lt [int]$today) { return 'EXPIRED' } + return $token +} + +function Show-AuthCodeDialog($attempt = 1, $maxAttempts = 3) { $form = New-Object System.Windows.Forms.Form - $form.Text = "Bookworm - 凭证解密 ($attempt/$maxAttempts)" - $form.Size = New-Object System.Drawing.Size(420, 220) + $form.Text = "Bookworm - 授权码验证 ($attempt/$maxAttempts)" + $form.Size = New-Object System.Drawing.Size(480, 240) $form.StartPosition = "CenterScreen" $form.FormBorderStyle = "FixedDialog" $form.MaximizeBox = $false @@ -59,40 +70,49 @@ function Show-PasswordDialog($prompt = "输入主密码解密凭证", $attempt = $form.TopMost = $true $label = New-Object System.Windows.Forms.Label - $label.Location = New-Object System.Drawing.Point(20, 20) - $label.Size = New-Object System.Drawing.Size(360, 40) - $label.Text = $prompt - $label.Font = New-Object System.Drawing.Font("Segoe UI", 10) + $label.Location = New-Object System.Drawing.Point(20, 18) + $label.Size = New-Object System.Drawing.Size(440, 36) + $label.Text = "请输入管理员提供的授权码:`n格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXX XXXX" + $label.Font = New-Object System.Drawing.Font("Segoe UI", 9) $form.Controls.Add($label) - $passBox = New-Object System.Windows.Forms.TextBox - $passBox.Location = New-Object System.Drawing.Point(20, 70) - $passBox.Size = New-Object System.Drawing.Size(360, 30) - $passBox.PasswordChar = '*' - $passBox.Font = New-Object System.Drawing.Font("Consolas", 12) - $form.Controls.Add($passBox) + # 授权码可见 (用于粘贴验证), 不用 PasswordChar + $codeBox = New-Object System.Windows.Forms.TextBox + $codeBox.Location = New-Object System.Drawing.Point(20, 65) + $codeBox.Size = New-Object System.Drawing.Size(430, 30) + $codeBox.Font = New-Object System.Drawing.Font("Consolas", 11) + $codeBox.CharacterCasing = "Upper" # 自动转大写 + $form.Controls.Add($codeBox) + + $hint = New-Object System.Windows.Forms.Label + $hint.Location = New-Object System.Drawing.Point(20, 100) + $hint.Size = New-Object System.Drawing.Size(440, 20) + $hint.Text = "提示: 直接粘贴管理员发送的授权码即可 (Ctrl+V)" + $hint.Font = New-Object System.Drawing.Font("Segoe UI", 8) + $hint.ForeColor = [System.Drawing.Color]::Gray + $form.Controls.Add($hint) $btnOK = New-Object System.Windows.Forms.Button - $btnOK.Location = New-Object System.Drawing.Point(200, 120) + $btnOK.Location = New-Object System.Drawing.Point(250, 145) $btnOK.Size = New-Object System.Drawing.Size(90, 35) - $btnOK.Text = "确定" + $btnOK.Text = "验证" $btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK $form.AcceptButton = $btnOK $form.Controls.Add($btnOK) $btnCancel = New-Object System.Windows.Forms.Button - $btnCancel.Location = New-Object System.Drawing.Point(300, 120) + $btnCancel.Location = New-Object System.Drawing.Point(350, 145) $btnCancel.Size = New-Object System.Drawing.Size(80, 35) - $btnCancel.Text = "取消" + $btnCancel.Text = "跳过" $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $form.CancelButton = $btnCancel $form.Controls.Add($btnCancel) - $form.Add_Shown({ $passBox.Focus() }) + $form.Add_Shown({ $codeBox.Focus() }) $result = $form.ShowDialog() if ($result -eq [System.Windows.Forms.DialogResult]::OK) { - return $passBox.Text + return $codeBox.Text.Trim() } return $null } @@ -592,24 +612,34 @@ if (-not $useNode -and -not $opensslCmd) { Log-Fail "无解密工具 (需要 Node.js 或 OpenSSL)" } elseif (Test-Path $SecretsEnc) { - for ($attempt = 1; $attempt -le 3; $attempt++) { - $password = Show-PasswordDialog "输入主密码解密凭证`n(非 Gitea 密码, 区分大小写)" $attempt 3 - if (-not $password) { - Log-Warn "用户取消密码输入" + $validAttempts = 0 + while ($validAttempts -lt 3) { + $rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3 + if (-not $rawCode) { + Log-Warn "用户跳过授权码输入" break } + $token = Parse-AuthCode-GUI $rawCode + if ($token -eq 'EXPIRED') { + Show-MsgBox "授权码已过期。`n请联系管理员获取新授权码。" "授权码过期" "OK" "Warning" + continue + } + if (-not $token) { + Show-MsgBox "格式错误。`n正确格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXX XXXX`n`n请检查后重新粘贴。" "格式错误" "OK" "Warning" + continue + } + $validAttempts++ + try { if ($useNode) { - # Node.js crypto-helper (BWENC1 格式, 跨平台一致) - $decrypted = & node $cryptoHelper decrypt $password $SecretsEnc 2>&1 + $decrypted = & node $cryptoHelper decrypt $token $SecretsEnc 2>&1 $decExit = $LASTEXITCODE } else { - # OpenSSL 回退 (仅支持 Salted__ 格式) - $decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $SecretsEnc -pass "pass:$password" 2>$null + $decrypted = & $opensslCmd enc -aes-256-cbc -d -pbkdf2 -iter 600000 -in $SecretsEnc -pass "pass:$token" 2>$null $decExit = $LASTEXITCODE } - $password = $null # 立即清零 + $token = $null # 立即清零 if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') { $count = 0 @@ -624,26 +654,26 @@ elseif (Test-Path $SecretsEnc) { $count++ } } - $decrypted = $null # 清零 + $decrypted = $null $secretsDecrypted = $true - # 询问缓存 - $cacheResult = Show-MsgBox "凭证解密成功 ($count 个变量)。`n`n是否缓存至今日 23:59?`n(下次启动免输密码)" "本日免密" "YesNo" "Question" + $cacheResult = Show-MsgBox "授权码验证成功 ($count 个凭证)。`n`n是否缓存至今日 23:59?`n(下次启动免输授权码)" "本日免密" "YesNo" "Question" if ($cacheResult -eq "Yes") { Save-SecretsToCache Log-OK "凭证已缓存至今日 23:59" } break } else { - $password = $null - if ($attempt -lt 3) { - Show-MsgBox "密码错误, 剩余重试: $(3 - $attempt) 次" "密码错误" "OK" "Warning" + $token = $null + $remaining = 3 - $validAttempts + if ($remaining -gt 0) { + Show-MsgBox "授权码无效(解密失败),剩余重试: $remaining 次`n`n请确认管理员已更新 secrets.enc。" "验证失败" "OK" "Warning" } else { - Show-MsgBox "3 次密码均错误。`n凭证未解密, Claude Code 可能无法启动。`n请联系管理员确认密码。" "解密失败" "OK" "Error" + Show-MsgBox "3 次验证均失败。`n请联系管理员重新获取授权码。" "解密失败" "OK" "Error" } } } catch { - $password = $null + $token = $null Log-Warn "解密异常: $_" } } diff --git a/gen-authcode.js b/gen-authcode.js new file mode 100644 index 0000000..6aa10af --- /dev/null +++ b/gen-authcode.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +'use strict'; +/** + * Bookworm 授权码生成工具 (管理员使用) + * + * 用法: + * node gen-authcode.js [secrets.txt路径] + * node gen-authcode.js 30 + * node gen-authcode.js 90 /path/to/secrets.txt + * + * 原理: + * 1. 生成随机 24位Hex Token (96bit 熵) + * 2. 将 Token 作为密码重新加密 secrets.enc (BWENC1 格式) + * 3. 输出授权码 BW-YYYYMMDD-TOKEN (发给用户) + * 4. 安全说明: Token = 解密密钥, YYYYMMDD = 客户端到期校验 + */ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const ALGO = 'aes-256-cbc'; +const ITERATIONS = 600000; +const DIGEST = 'sha256'; +const SALT_LEN = 16; +const KEY_LEN = 32; +const IV_LEN = 16; + +function deriveKey(password, salt) { + return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN + IV_LEN, DIGEST); +} + +function encrypt(plaintext, password) { + const salt = crypto.randomBytes(SALT_LEN); + const derived = deriveKey(password, salt); + const key = derived.slice(0, KEY_LEN); + 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]); +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'); + 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'); + +if (!fs.existsSync(SECRETS_TXT)) { + console.error(`[错误] 找不到 secrets.txt: ${SECRETS_TXT}`); + console.error('请先创建 secrets.txt, 每行格式: KEY=VALUE'); + process.exit(1); +} + +// 生成到期日 (YYYYMMDD) +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)}`; + +console.log('\n═══════════════════════════════════════════════════'); +console.log(' Bookworm 授权码生成完毕'); +console.log('═══════════════════════════════════════════════════'); +console.log(''); +console.log(` 授权码: ${authCode}`); +console.log(` 有效期: ${DAYS} 天 (至 ${expiryDisplay})`); +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(''); +console.log(' ⚠ 安全提醒:'); +console.log(' - 授权码即解密密钥, 请勿通过不安全渠道明文发送'); +console.log(` - 到期日 (${expiryDisplay}) 后自动失效, 无需撤销操作`); +console.log('═══════════════════════════════════════════════════\n'); diff --git a/install.ps1 b/install.ps1 index a8f33a9..f78884e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -197,6 +197,26 @@ function New-DesktopShortcuts { } } +function Parse-AuthCode { + param([string]$code) + $code = $code.Trim() + # 格式: BW-YYYYMMDD-24位HexToken + if ($code -notmatch '^BW-(\d{8})-([A-Fa-f0-9]{24})$') { + Write-Host " [!!] 格式错误,应为 BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXX XXXX" -ForegroundColor Red + return $null + } + $expiryStr = $Matches[1] + $token = $Matches[2].ToLower() # 解密用小写 + $today = (Get-Date).ToString("yyyyMMdd") + if ([int]$expiryStr -lt [int]$today) { + $d = "$($expiryStr.Substring(0,4))-$($expiryStr.Substring(4,2))-$($expiryStr.Substring(6,2))" + Write-Host " [!!] 授权码已过期 (有效期至 $d)" -ForegroundColor Red + Write-Host " 请联系管理员获取新授权码" -ForegroundColor Yellow + return $null + } + return $token +} + function Decrypt-Secrets { if ($SkipSecrets -or -not (Test-Path $SecretsEnc)) { Write-Host " [!] 跳过凭证解密 (无 secrets.enc)" -ForegroundColor Yellow @@ -213,10 +233,16 @@ function Decrypt-Secrets { $maxRetries = 3 for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { - $label = if ($attempt -gt 1) { " 重新输入主密码 (第 $attempt/$maxRetries 次)" } else { " 输入主密码解密凭证" } - $password = Read-Host $label -AsSecureString - $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password) - $plainPwd = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + $label = if ($attempt -gt 1) { " 重新输入授权码 (第 $attempt/$maxRetries 次)" } else { " 输入授权码 (格式: BW-YYYYMMDD-...)" } + $authCodeRaw = Read-Host $label + $plainPwd = Parse-AuthCode $authCodeRaw + if (-not $plainPwd) { + # 格式错误或已过期: 不计入密码重试, 直接继续 + $attempt-- + $maxRetries-- # 最多给 3 次有效尝试 + if ($maxRetries -lt 1) { break } + continue + } $prevEAP = $ErrorActionPreference $ErrorActionPreference = "Continue" @@ -232,9 +258,8 @@ function Decrypt-Secrets { } $ErrorActionPreference = $prevEAP - # 清除内存中的密码 + # 清除内存中的 token $plainPwd = $null - [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'PASSWORD_ERROR|FORMAT_ERROR|bad decrypt') { # 解密成功,注入环境变量