feat: 多用户独立 relay Key 支持 (方案B)
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) <noreply@anthropic.com>
This commit is contained in:
parent
68d0b8e1d3
commit
5141b7882b
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
133
gen-authcode.js
133
gen-authcode.js
@ -4,14 +4,23 @@
|
||||
* Bookworm 授权码生成工具 (管理员使用)
|
||||
*
|
||||
* 用法:
|
||||
* node gen-authcode.js <days> [secrets.txt路径]
|
||||
* node gen-authcode.js 30
|
||||
* node gen-authcode.js 90 /path/to/secrets.txt
|
||||
* node gen-authcode.js <days> [选项]
|
||||
*
|
||||
* 选项:
|
||||
* --relay-key, -k <key> 中转站限额子 Key (替换 ANTHROPIC_API_KEY)
|
||||
* --user, -u <name> 用户标识 (仅用于显示, 不影响加密)
|
||||
* [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> 中转站限额子 Key');
|
||||
console.error(' --user, -u <name> 用户标识 (仅显示)');
|
||||
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');
|
||||
|
||||
31
install.ps1
31
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user