/** * 诊断:判定「当前首页 token 到底由哪把密钥签发」 * * 背景:config/ 下有两把候选公钥参与首页 token 验证: * - nav-private.jwk.json(含私钥) * - nav.jwk.json(仅公钥) * 若不清楚旧 token 实际由哪把私钥签出,就贸然轮换其中一把, * 可能出现「以为作废了、其实旧的还能用」的假安全。 * * 本脚本只读,不修改任何密钥或数据: * 1. 从 data/short_link.db 取当前首页 token * 2. 用 config 下每一把候选公钥逐一验签,指出「签名来源」 * 3. 打印 token 负载与过期情况 * * 运行:node dev_test_scripts/debug/debug_nav_token_key_audit.js */ 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 b64urlToBuf = s => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64') const decodePart = s => JSON.parse(b64urlToBuf(s).toString('utf-8')) const readJwk = name => { try { const p = path.join(CONFIG_DIR, name) if (!fs.existsSync(p)) return null return JSON.parse(fs.readFileSync(p, 'utf-8')) } catch { return null } } const fingerprint = jwk => { const n = String((jwk && jwk.n) || '') if (!n) return '(空)' return crypto.createHash('sha256').update(n).digest('hex').slice(0, 16) } // 从短链库里取一条指向首页(带 token)的目标 const pickHomepageToken = () => { const db = new Database(DB_PATH, { readonly: true }) const rows = db.prepare('SELECT code, title, target_url FROM short_links').all() db.close() for (const r of rows) { const m = /[?&]token=([^&\s]+)/.exec(String(r.target_url || '')) if (m) return { code: r.code, title: r.title, url: r.target_url, token: decodeURIComponent(m[1]) } } return null } ;(async () => { console.log('=== 首页 token 签发来源审计(只读) ===\n') const found = pickHomepageToken() if (!found) { console.log('未在 data/short_link.db 里找到任何带 token 的短链接目标。') process.exit(0) } console.log(`短链接:/${found.code} (${found.title || '无标题'})`) console.log(`目标:${String(found.url).slice(0, 60)}...`) console.log(`token 长度:${found.token.length}\n`) const parts = String(found.token).split('.') if (parts.length !== 3) { console.log('✘ token 不是标准三段式 JWT,无法审计') process.exit(1) } const header = decodePart(parts[0]) const payload = decodePart(parts[1]) console.log('--- 负载 ---') console.log(JSON.stringify(payload, null, 2)) const now = Math.floor(Date.now() / 1000) console.log(`\n过期:${new Date(payload.exp * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`) console.log(`剩余:${((payload.exp - now) / 86400).toFixed(1)} 天(${payload.exp > now ? '仍有效' : '已过期'})`) console.log(`算法:${header.alg}\n`) console.log('--- 用 config 下每把候选公钥验签 ---') const data = parts[0] + '.' + parts[1] const sig = b64urlToBuf(parts[2]) const candidates = [ 'nav-private.jwk.json', 'nav.jwk.json', ] let matched = [] for (const name of candidates) { const jwk = readJwk(name) if (!jwk || !jwk.n) { console.log(` ${name.padEnd(24)} 公钥不可用(无 n),跳过`) continue } let ok = false try { const pub = crypto.createPublicKey({ key: { kty: jwk.kty, n: jwk.n, e: jwk.e }, format: 'jwk' }) ok = crypto.verify('RSA-SHA256', Buffer.from(data), pub, sig) } catch (e) { console.log(` ${name.padEnd(24)} 验签异常:${String(e.message || e)}`) continue } if (ok) matched.push(name) console.log(` ${name.padEnd(24)} 指纹=${fingerprint(jwk)} 验签=${ok ? '通过 ← 就是这把签的' : '未通过'}`) } console.log('\n--- 结论 ---') if (matched.length === 0) { console.log('✘ 没有任何 config 下的公钥能验通该 token。') console.log(' 说明它是由「其它地方保存的私钥」签出的(例如 token_lab 浏览器里导入的私钥 XML)。') console.log(' 轮换 config 里的密钥无法作废它 —— 需要先找到那把私钥。') } else { console.log(`✔ 该 token 由这些公钥对应私钥签发:${matched.join('、')}`) console.log(` → 要让旧 token 立即失效,必须轮换${matched.length > 1 ? '全部这些' : '这把'}密钥。`) matched.forEach(n => console.log(` · ${n}(指纹 ${fingerprint(readJwk(n))})`)) } })()