feat: replace master password auth with time-limited authorization codes

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) <noreply@anthropic.com>
This commit is contained in:
bookworm 2026-04-06 22:47:04 +08:00
parent 197396c5fe
commit b83c508c22
4 changed files with 233 additions and 51 deletions

View File

@ -274,17 +274,48 @@ _decrypt_secrets() {
fi 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 if load_cached_secrets 2>/dev/null; then
: # 缓存加载成功 : # 缓存加载成功
elif [ -f "$SECRETS_ENC" ]; then elif [ -f "$SECRETS_ENC" ]; then
DECRYPTED="" DECRYPTED=""
for attempt in 1 2 3; do valid_attempts=0
while [ $valid_attempts -lt 3 ]; do
echo "" echo ""
read -rs -p " 输入主密码解密凭证 (第 $attempt/3 次): " PASSWORD read -p " 输入授权码 (BW-YYYYMMDD-XXXXXX, 第 $((valid_attempts+1))/3 次): " AUTH_CODE
echo "" TOKEN=$(parse_authcode "$AUTH_CODE")
DECRYPTED=$(_decrypt_secrets "$PASSWORD" "$SECRETS_ENC") || true AUTH_CODE=""
PASSWORD="" 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 if [ -n "$DECRYPTED" ]; then
while IFS= read -r line; do while IFS= read -r line; do
[ -z "$line" ] && continue [ -z "$line" ] && continue
@ -306,10 +337,10 @@ elif [ -f "$SECRETS_ENC" ]; then
fi fi
break break
else else
if [ $attempt -lt 3 ]; then if [ $valid_attempts -lt 3 ]; then
warn "密码错误, 剩余重试: $((3 - attempt))" warn "授权码无效 (解密失败), 剩余重试: $((3 - valid_attempts))"
else else
fail "3 次密码均错误, 凭证未解密" fail "3 次授权码均无效, 凭证未解密"
warn "可稍后手动配置 API Key" warn "可稍后手动配置 API Key"
fi fi
fi fi
@ -317,7 +348,7 @@ elif [ -f "$SECRETS_ENC" ]; then
else else
if [ ! -f "$SECRETS_ENC" ]; then if [ ! -f "$SECRETS_ENC" ]; then
warn "secrets.enc 不存在, 跳过凭证解密" warn "secrets.enc 不存在, 跳过凭证解密"
info "请联系管理员获取加密凭证文件" info "请联系管理员获取授权码"
fi fi
fi fi

View File

@ -48,10 +48,21 @@ function Show-MsgBox($text, $title = "Bookworm 安装", $buttons = "OK", $icon =
[System.Windows.Forms.MessageBox]::Show($text, $title, $buttons, $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 = New-Object System.Windows.Forms.Form
$form.Text = "Bookworm - 凭证解密 ($attempt/$maxAttempts)" $form.Text = "Bookworm - 授权码验证 ($attempt/$maxAttempts)"
$form.Size = New-Object System.Drawing.Size(420, 220) $form.Size = New-Object System.Drawing.Size(480, 240)
$form.StartPosition = "CenterScreen" $form.StartPosition = "CenterScreen"
$form.FormBorderStyle = "FixedDialog" $form.FormBorderStyle = "FixedDialog"
$form.MaximizeBox = $false $form.MaximizeBox = $false
@ -59,40 +70,49 @@ function Show-PasswordDialog($prompt = "输入主密码解密凭证", $attempt =
$form.TopMost = $true $form.TopMost = $true
$label = New-Object System.Windows.Forms.Label $label = New-Object System.Windows.Forms.Label
$label.Location = New-Object System.Drawing.Point(20, 20) $label.Location = New-Object System.Drawing.Point(20, 18)
$label.Size = New-Object System.Drawing.Size(360, 40) $label.Size = New-Object System.Drawing.Size(440, 36)
$label.Text = $prompt $label.Text = "请输入管理员提供的授权码:`n格式: BW-YYYYMMDD-XXXXXXXXXXXXXXXXXXXX XXXX"
$label.Font = New-Object System.Drawing.Font("Segoe UI", 10) $label.Font = New-Object System.Drawing.Font("Segoe UI", 9)
$form.Controls.Add($label) $form.Controls.Add($label)
$passBox = New-Object System.Windows.Forms.TextBox # 授权码可见 (用于粘贴验证), 不用 PasswordChar
$passBox.Location = New-Object System.Drawing.Point(20, 70) $codeBox = New-Object System.Windows.Forms.TextBox
$passBox.Size = New-Object System.Drawing.Size(360, 30) $codeBox.Location = New-Object System.Drawing.Point(20, 65)
$passBox.PasswordChar = '*' $codeBox.Size = New-Object System.Drawing.Size(430, 30)
$passBox.Font = New-Object System.Drawing.Font("Consolas", 12) $codeBox.Font = New-Object System.Drawing.Font("Consolas", 11)
$form.Controls.Add($passBox) $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 = 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.Size = New-Object System.Drawing.Size(90, 35)
$btnOK.Text = "确定" $btnOK.Text = "验证"
$btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK $btnOK.DialogResult = [System.Windows.Forms.DialogResult]::OK
$form.AcceptButton = $btnOK $form.AcceptButton = $btnOK
$form.Controls.Add($btnOK) $form.Controls.Add($btnOK)
$btnCancel = New-Object System.Windows.Forms.Button $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.Size = New-Object System.Drawing.Size(80, 35)
$btnCancel.Text = "取消" $btnCancel.Text = "跳过"
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
$form.CancelButton = $btnCancel $form.CancelButton = $btnCancel
$form.Controls.Add($btnCancel) $form.Controls.Add($btnCancel)
$form.Add_Shown({ $passBox.Focus() }) $form.Add_Shown({ $codeBox.Focus() })
$result = $form.ShowDialog() $result = $form.ShowDialog()
if ($result -eq [System.Windows.Forms.DialogResult]::OK) { if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
return $passBox.Text return $codeBox.Text.Trim()
} }
return $null return $null
} }
@ -592,24 +612,34 @@ if (-not $useNode -and -not $opensslCmd) {
Log-Fail "无解密工具 (需要 Node.js 或 OpenSSL)" Log-Fail "无解密工具 (需要 Node.js 或 OpenSSL)"
} }
elseif (Test-Path $SecretsEnc) { elseif (Test-Path $SecretsEnc) {
for ($attempt = 1; $attempt -le 3; $attempt++) { $validAttempts = 0
$password = Show-PasswordDialog "输入主密码解密凭证`n(非 Gitea 密码, 区分大小写)" $attempt 3 while ($validAttempts -lt 3) {
if (-not $password) { $rawCode = Show-AuthCodeDialog ($validAttempts + 1) 3
Log-Warn "用户取消密码输入" if (-not $rawCode) {
Log-Warn "用户跳过授权码输入"
break 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 { try {
if ($useNode) { if ($useNode) {
# Node.js crypto-helper (BWENC1 格式, 跨平台一致) $decrypted = & node $cryptoHelper decrypt $token $SecretsEnc 2>&1
$decrypted = & node $cryptoHelper decrypt $password $SecretsEnc 2>&1
$decExit = $LASTEXITCODE $decExit = $LASTEXITCODE
} else { } else {
# OpenSSL 回退 (仅支持 Salted__ 格式) $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 $SecretsEnc -pass "pass:$password" 2>$null
$decExit = $LASTEXITCODE $decExit = $LASTEXITCODE
} }
$password = $null # 立即清零 $token = $null # 立即清零
if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') { if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'WRONG_PASSWORD|WRONG_FORMAT|bad decrypt|bad magic') {
$count = 0 $count = 0
@ -624,26 +654,26 @@ elseif (Test-Path $SecretsEnc) {
$count++ $count++
} }
} }
$decrypted = $null # 清零 $decrypted = $null
$secretsDecrypted = $true $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") { if ($cacheResult -eq "Yes") {
Save-SecretsToCache Save-SecretsToCache
Log-OK "凭证已缓存至今日 23:59" Log-OK "凭证已缓存至今日 23:59"
} }
break break
} else { } else {
$password = $null $token = $null
if ($attempt -lt 3) { $remaining = 3 - $validAttempts
Show-MsgBox "密码错误, 剩余重试: $(3 - $attempt)" "密码错误" "OK" "Warning" if ($remaining -gt 0) {
Show-MsgBox "授权码无效(解密失败),剩余重试: $remaining`n`n请确认管理员已更新 secrets.enc。" "验证失败" "OK" "Warning"
} else { } else {
Show-MsgBox "3 次密码均错误。`n凭证未解密, Claude Code 可能无法启动。`n请联系管理员确认密码。" "解密失败" "OK" "Error" Show-MsgBox "3 次验证均失败。`n请联系管理员重新获取授权码。" "解密失败" "OK" "Error"
} }
} }
} catch { } catch {
$password = $null $token = $null
Log-Warn "解密异常: $_" Log-Warn "解密异常: $_"
} }
} }

96
gen-authcode.js Normal file
View File

@ -0,0 +1,96 @@
#!/usr/bin/env node
'use strict';
/**
* Bookworm 授权码生成工具 (管理员使用)
*
* 用法:
* node gen-authcode.js <days> [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');

View File

@ -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 { function Decrypt-Secrets {
if ($SkipSecrets -or -not (Test-Path $SecretsEnc)) { if ($SkipSecrets -or -not (Test-Path $SecretsEnc)) {
Write-Host " [!] 跳过凭证解密 (无 secrets.enc)" -ForegroundColor Yellow Write-Host " [!] 跳过凭证解密 (无 secrets.enc)" -ForegroundColor Yellow
@ -213,10 +233,16 @@ function Decrypt-Secrets {
$maxRetries = 3 $maxRetries = 3
for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { for ($attempt = 1; $attempt -le $maxRetries; $attempt++) {
$label = if ($attempt -gt 1) { " 重新输入主密码 (第 $attempt/$maxRetries 次)" } else { " 输入主密码解密凭证" } $label = if ($attempt -gt 1) { " 重新输入授权码 (第 $attempt/$maxRetries 次)" } else { " 输入授权码 (格式: BW-YYYYMMDD-...)" }
$password = Read-Host $label -AsSecureString $authCodeRaw = Read-Host $label
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password) $plainPwd = Parse-AuthCode $authCodeRaw
$plainPwd = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) if (-not $plainPwd) {
# 格式错误或已过期: 不计入密码重试, 直接继续
$attempt--
$maxRetries-- # 最多给 3 次有效尝试
if ($maxRetries -lt 1) { break }
continue
}
$prevEAP = $ErrorActionPreference $prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue" $ErrorActionPreference = "Continue"
@ -232,9 +258,8 @@ function Decrypt-Secrets {
} }
$ErrorActionPreference = $prevEAP $ErrorActionPreference = $prevEAP
# 清除内存中的密码 # 清除内存中的 token
$plainPwd = $null $plainPwd = $null
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'PASSWORD_ERROR|FORMAT_ERROR|bad decrypt') { if ($decExit -eq 0 -and $decrypted -and $decrypted -notmatch 'PASSWORD_ERROR|FORMAT_ERROR|bad decrypt') {
# 解密成功,注入环境变量 # 解密成功,注入环境变量