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
}
# 解析授权码: 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

View File

@ -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 "解密异常: $_"
}
}

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 {
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') {
# 解密成功,注入环境变量