a3e8911deb
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
119 lines
4.6 KiB
JavaScript
119 lines
4.6 KiB
JavaScript
/**
|
||
* 诊断:判定「当前首页 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))})`))
|
||
}
|
||
})() |