/** * 轮换「首页导航」密钥对,并同步更换首页 token(旧 token 立即作废) * * 为什么需要轮换两把: * 服务端校验首页 token 时会组成一个「信任池」,池中含 * · config/nav-private.jwk.json 的公钥部分 * · config/nav.jwk.json * 经审计,线上那枚旧 token 实际是由 nav.jwk.json 对应私钥签发的。 * 因此只换 nav-private 无效 —— 必须把两者都换成同一把新密钥, * 旧公钥才会彻底离开信任池,旧 token 才真正失效。 * * 本脚本动作: * 1) 备份现有两个密钥文件(时间戳目录,可回滚) * 2) 生成新的 RSA-2048 密钥对,写入上述两个文件(同一把新密钥) * 3) 用新私钥签发一枚新的首页 token * 4) 把短链接 PSq6xv 的目标地址替换为新 token(其它字段不动) * 5) 就地做黑盒校验:旧 token 必须 401,新 token 必须 302 并能进首页 * * 用法: * node dev_test_scripts/tools/tool_rotate_nav_key.js # 默认有效期 365 天 * node dev_test_scripts/tools/tool_rotate_nav_key.js 180 # 指定天数 * node dev_test_scripts/tools/tool_rotate_nav_key.js --dry-run # 只看计划,不写入 */ const fs = require('fs') const path = require('path') const crypto = require('crypto') const Database = require('better-sqlite3') const CONFIG_DIR = path.join(process.cwd(), 'config') const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db') const PRIV_PATH = path.join(CONFIG_DIR, 'nav-private.jwk.json') const PUB_PATH = path.join(CONFIG_DIR, 'nav.jwk.json') const BASE_URL = 'https://traesite.umersoft.com:8975/' const TARGET_CODE = 'PSq6xv' const LOCAL = 'http://localhost:8976' const args = process.argv.slice(2) const dryRun = args.includes('--dry-run') const daysArg = args.find(a => /^\d+$/.test(a)) const EXPIRE_DAYS = daysArg ? Math.max(1, Math.min(730, Number(daysArg))) : 365 let passed = 0 let failed = 0 const check = (name, ok, detail) => { if (ok) { passed += 1; console.log(` ✔ ${name}`) } else { failed += 1; console.log(` ✘ ${name}${detail ? ' → ' + detail : ''}`) } } const fp = jwk => { const n = String((jwk && jwk.n) || '') return n ? crypto.createHash('sha256').update(n).digest('hex').slice(0, 16) : '(空)' } const readJson = p => JSON.parse(fs.readFileSync(p, 'utf-8')) const signJwtRS256 = (payload, jwk) => { const enc = v => Buffer.from(JSON.stringify(v)).toString('base64') .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') const data = enc({ alg: 'RS256', typ: 'JWT' }) + '.' + enc(payload) const sig = crypto.sign('RSA-SHA256', Buffer.from(data), crypto.createPrivateKey({ key: jwk, format: 'jwk' })) .toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') return data + '.' + sig } /** 取短链接当前目标里的 token(用于轮换后验证"旧的确实失效了") */ const readCurrentTarget = () => { const db = new Database(DB_PATH, { readonly: true }) const row = db.prepare('SELECT target_url FROM short_links WHERE code = ?').get(TARGET_CODE) db.close() if (!row) throw new Error(`短链接 ${TARGET_CODE} 不存在`) const m = /[?&]token=([^&\s]+)/.exec(String(row.target_url || '')) return { url: String(row.target_url || ''), token: m ? decodeURIComponent(m[1]) : '' } } const updateShortLinkTarget = newUrl => { const db = new Database(DB_PATH) const info = db.prepare('UPDATE short_links SET target_url = ? WHERE code = ?').run(newUrl, TARGET_CODE) db.close() return info.changes } const getTokenFromUrl = async url => { // 跟随一次 302,取 Set-Cookie 与状态 const res = await fetch(url, { redirect: 'manual' }) return { status: res.status, setCookie: res.headers.get('set-cookie') || '', location: res.headers.get('location') || '' } } const probeWithToken = async token => { const res = await fetch(`${LOCAL}/?token=${encodeURIComponent(token)}`, { redirect: 'manual' }) return { status: res.status, setCookie: res.headers.get('set-cookie') || '' } } ;(async () => { console.log('=== 首页导航密钥轮换 ===') console.log(`有效期:${EXPIRE_DAYS} 天${dryRun ? '(dry-run,不写入)' : ''}\n`) // ---------- 0. 现状 ---------- const oldPriv = readJson(PRIV_PATH) const oldPub = readJson(PUB_PATH) const before = readCurrentTarget() console.log('[0] 现状') console.log(` nav-private.jwk.json 指纹 = ${fp(oldPriv)}(含私钥=${oldPriv.d ? '是' : '否'})`) console.log(` nav.jwk.json 指纹 = ${fp(oldPub)}(含私钥=${oldPub.d ? '是' : '否'})`) console.log(` 短链接 ${TARGET_CODE} 目标 = ${before.url.slice(0, 55)}...`) console.log(` 旧 token 长度 = ${before.token.length}`) check('旧 token 在轮换前是有效的(基线)', (await probeWithToken(before.token)).status === 302, '旧 token 当前不可用,请先确认现状') if (dryRun) { console.log('\n[dry-run] 将执行:备份密钥 → 生成新密钥对 → 覆写两个文件 → 签发新 token → 更新短链接 → 校验') console.log(` 新 token 过期时间:${new Date((Math.floor(Date.now() / 1000) + EXPIRE_DAYS * 86400) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`) process.exit(0) } // ---------- 1. 备份 ---------- const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) const backupDir = path.join(CONFIG_DIR, `_nav-key-backup-${stamp}`) fs.mkdirSync(backupDir, { recursive: true }) fs.copyFileSync(PRIV_PATH, path.join(backupDir, 'nav-private.jwk.json')) fs.copyFileSync(PUB_PATH, path.join(backupDir, 'nav.jwk.json')) // 一并备份短链接库,便于回滚目标地址 try { fs.copyFileSync(DB_PATH, path.join(backupDir, 'short_link.db')) } catch (_) { /* 非致命 */ } console.log(`\n[1] 已备份到 config/${path.basename(backupDir)}/`) check('备份目录创建成功', fs.existsSync(path.join(backupDir, 'nav-private.jwk.json'))) // ---------- 2. 生成新密钥对并写入 ---------- const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) const newPriv = privateKey.export({ format: 'jwk' }) const newPub = publicKey.export({ format: 'jwk' }) // 公钥文件只保留公开字段,避免私钥混进 nav.jwk.json(历史上它就是纯公钥文件) const newPubOnly = { kty: newPub.kty, n: newPub.n, e: newPub.e } fs.writeFileSync(PRIV_PATH, JSON.stringify(newPriv, null, 2)) fs.writeFileSync(PUB_PATH, JSON.stringify(newPubOnly, null, 2)) console.log('\n[2] 已写入新密钥对') console.log(` nav-private.jwk.json 新指纹 = ${fp(newPriv)}(含私钥=${newPriv.d ? '是' : '否'})`) console.log(` nav.jwk.json 新指纹 = ${fp(newPubOnly)}`) check('两个文件的公钥一致(同一把新密钥)', fp(newPriv) === fp(newPubOnly), `${fp(newPriv)} vs ${fp(newPubOnly)}`) check('nav.jwk.json 未混入私钥字段', !newPubOnly.d) check('新密钥已替换旧公钥(旧公钥离开信任池)', fp(newPubOnly) !== fp(oldPriv) && fp(newPubOnly) !== fp(oldPub)) // ---------- 3. 签发新 token ---------- let iss = 'TRAE-NAV' try { iss = String(readJson(path.join(CONFIG_DIR, 'flags.json')).navAuth.iss || iss) } catch (_) { /* 用默认 */ } const now = Math.floor(Date.now() / 1000) const payload = { iss, iat: now, exp: now + EXPIRE_DAYS * 86400, jti: crypto.randomUUID() } const newToken = signJwtRS256(payload, newPriv) const newUrl = `${BASE_URL}?token=${encodeURIComponent(newToken)}` console.log('\n[3] 已用新私钥签发新 token') console.log(` iss=${payload.iss} jti=${payload.jti}`) console.log(` 过期:${new Date(payload.exp * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`) // ---------- 4. 更新短链接目标 ---------- const changed = updateShortLinkTarget(newUrl) console.log(`\n[4] 短链接 ${TARGET_CODE} 目标已更新(影响行数 ${changed})`) check('短链接目标已成功更新', changed === 1, `影响 ${changed} 行`) // ---------- 5. 黑盒校验 ---------- console.log('\n[5] 黑盒校验(服务端按请求实时读密钥文件,无需重启)') const oldProbe = await probeWithToken(before.token) check('旧 token 已失效(401)', oldProbe.status === 401, `实际 ${oldProbe.status}`) const newProbe = await probeWithToken(newToken) check('新 token 可用(302,进入握手)', newProbe.status === 302, `实际 ${newProbe.status}`) check('新 token 能种下 nav_gate cookie', newProbe.setCookie.indexOf('nav_gate=1') >= 0, newProbe.setCookie.slice(0, 60)) // 带 cookie 取首页,确认真的能进 const homeRes = await fetch(`${LOCAL}/`, { headers: { Cookie: 'nav_gate=1' } }) const homeText = await homeRes.text() check('带 cookie 可打开九宫格首页(200)', homeRes.status === 200, `实际 ${homeRes.status}`) check('首页内容正确', homeText.indexOf("Yang's Toolbox") >= 0) // 公网链路:新 token 从公网进也应 302 try { const pubProbe = await getTokenFromUrl(`${BASE_URL}?token=${encodeURIComponent(newToken)}`) check('公网地址可用(302)', pubProbe.status === 302, `实际 ${pubProbe.status}`) } catch (e) { check('公网地址可用(302)', false, String(e.message || e)) } // ---------- 汇总 ---------- console.log('\n--- 结果 ---') console.log(` 新短链长链接:${BASE_URL}?token=<新token>(共 ${newUrl.length} 字符)`) console.log(` 短链接:https://maise.pro/to/${TARGET_CODE}`) console.log(` 备份目录:config/${path.basename(backupDir)}/`) console.log(' 回滚方法:把备份目录里的两个 jwk 文件覆盖回 config/,并从 short_link.db 备份恢复目标地址。') if (failed > 0) { console.log(`\n⚠️ 有 ${failed} 项未通过,请按上面提示检查;密钥与数据都已备份,可回滚。`) } console.log(`\n=== 校验:通过 ${passed} 项,失败 ${failed} 项 ===`) process.exit(failed === 0 ? 0 : 1) })().catch(e => { console.log('\n✘ 执行异常:' + String((e && e.stack) || e)) console.log('密钥与数据在写入前已备份,可在 config/_nav-key-backup-*/ 回滚。') process.exit(1) })