Files
Toolbox/dev_test_scripts/debug/debug_nav_token_key_audit.js
T
yangxiangyuan a3e8911deb feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
2026-10-09 14:05:36 +08:00

119 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 诊断:判定「当前首页 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))})`))
}
})()