文件
kdbx-viewer/server.js
T
wangchuanli 02e83f5b73 feat: 初始化 kdbx-viewer 项目
实现服务端解密的 KeePass 网页查看器,包含登录门户口令与验证码、RSA+会话级 AES 加密通道、审计日志持久化、HTTPS 自动证书、Docker 部署配置及端到端测试。
2026-08-26 10:42:48 +08:00

760 行
35 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'use strict';
require('dotenv').config(); // 必须在 require('./config') 之前加载 .env
const express = require('express');
const session = require('express-session');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const http = require('http');
const https = require('https');
const config = require('./config');
const { loadDatabase, extract, ProtectedValue } = require('./kdbxlib');
// 是否启用 HTTPS:证书存在则启用;否则尝试自动生成自签证书(容器/首次部署默认 HTTPS)
// 注意:session.cookie.secure 必须是布尔值;用函数形式在 trust proxy 下会导致 cookie 不下发
function ensureSsl() {
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) return keyPath;
// 证书缺失:自动生成自签证书,保证默认走 HTTPS(生产应使用受信任证书替换)
try {
const { generateSelfSigned } = require('./gen-cert');
generateSelfSigned(keyPath, certPath, 'localhost');
console.log(`[ssl] 未检测到证书,已自动生成自签证书: ${keyPath}`);
return fs.existsSync(keyPath) && fs.existsSync(certPath) ? keyPath : null;
} catch (e) {
console.error('[ssl] 自动生成证书失败,将回退 HTTP:', e.message);
return null;
}
}
function detectSsl() { return !!ensureSsl(); }
const HAS_SSL = detectSsl();
// ============ 密钥体系 ============
// 1) 长期 RSA(仅用于加密传输 login/unlock 的口令,私钥仅存服务端内存)
const TRANSPORT_RSA = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
// 解密前端用传输公钥加密的密文
function rsaDecrypt(b64) {
const buf = Buffer.from(b64, 'base64');
return crypto.privateDecrypt(
{ key: TRANSPORT_RSA.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
buf
).toString('utf8');
}
// 2) 会话级 AES(通道加密:login 协商,用于加密所有响应体)
const sessionKeys = new Map(); // sessionID -> Buffer(32)
function encPayload(req, obj) {
const key = sessionKeys.get(req.sessionID);
if (!key) return obj;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(JSON.stringify(obj), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return { iv: iv.toString('base64'), ct: ct.toString('base64'), tag: tag.toString('base64') };
}
function encRes(req, res, obj, status = 200) { res.status(status).json(encPayload(req, obj)); }
// ============ 错误码(便于审计与前端区分)============
const ERR = {
AUTH_APP_FAIL: 'AUTH_APP_FAIL', // 门户口令错误
CAPTCHA_FAIL: 'CAPTCHA_FAIL', // 验证码错误
IP_BLOCKED: 'IP_BLOCKED', // IP 被封锁
AUTH_MASTER_FAIL: 'AUTH_MASTER_FAIL', // 主密码/keyfile 错误
KEY_EXPIRED: 'KEY_EXPIRED', // 动态数据密钥过期
ENC_FAIL: 'ENC_FAIL', // 密文解析失败
MISSING: 'MISSING', // 缺少参数
NOT_FOUND: 'NOT_FOUND',
RATE_LIMIT: 'RATE_LIMIT',
};
// ============ 审计日志(本地文件持久化 + 索引文件,按大小切分)============
// 数据模型:
// - 每个日志文件(audit.log / audit.N.log)为 JSON Lines,每行一条记录,按全局 seq 单调递增
// - 索引文件 audit.index.json 记录每个文件的 起始序号/结束序号/条数/字节/时间范围,以及全局总条数
// - 前端分页先读取索引计算分布,再按需从对应文件提取片段,保证分页与文件一致
const AUDIT_FILE_MAX = config.auditFileMax; // 单文件切分阈值,可由 AUDIT_FILE_MAX 环境变量配置(默认 5M)
const AUDIT_FILE_KEEP = config.auditFileKeep; // 切分文件最多保留份数,可由 AUDIT_FILE_KEEP 环境变量配置(默认 10)
let auditSeq = 0; // 全局单调递增序号(持久化,重启后从索引恢复)
const logDir = config.logDir;
try { fs.mkdirSync(logDir, { recursive: true }); } catch (e) {}
function auditCurrentFile() { return path.join(logDir, 'audit.log'); }
function auditRotatedFile(i) { return path.join(logDir, `audit.${i}.log`); }
function auditIndexFile() { return path.join(logDir, 'audit.index.json'); }
// 索引:{ total, nextSeq, files: [ { file, startSeq, endSeq, count, bytes, firstTime, lastTime } ] }
// nextSeq:全局下一个序号(跨重启持久化,避免 seq 重叠导致分页/区间错乱)
let auditIndex = { total: 0, nextSeq: 0, files: [] };
// 启动时从索引文件恢复(若无则基于现有审计文件重建)
function auditLoadIndex() {
try {
const raw = fs.readFileSync(auditIndexFile(), 'utf8');
const idx = JSON.parse(raw);
if (idx && Array.isArray(idx.files)) {
auditIndex = idx;
auditSeq = auditIndex.nextSeq || auditIndex.files.reduce((m, f) => Math.max(m, f.endSeq || 0), 0);
return;
}
} catch (e) { /* 索引缺失或损坏,下面重建 */ }
// 重建:扫描存在的审计文件,重排行号(按文件名顺序,认为 audit.log 最新)
auditRebuildIndex();
auditIndex.nextSeq = auditSeq;
auditSaveIndex();
}
// 扫描磁盘上的审计文件并重建索引(在索引丢失/损坏时调用)
function auditRebuildIndex() {
const names = [];
if (fs.existsSync(auditCurrentFile())) names.push('audit.log');
for (let i = 1; i <= AUDIT_FILE_KEEP; i++) {
const n = `audit.${i}.log`;
if (fs.existsSync(auditRotatedFile(i))) names.push(n);
}
// 顺序:audit.10 ... audit.1 在前(旧),audit.log 在最后(新)
names.sort((a, b) => {
const num = (n) => n === 'audit.log' ? 0 : -parseInt(n.match(/(\d+)/)[1], 10);
return num(a) - num(b);
});
let seq = 0;
auditIndex = { total: 0, nextSeq: 0, files: [] };
for (const n of names) {
const fp = path.join(logDir, n);
let bytes = 0; try { bytes = fs.statSync(fp).size; } catch (e) {}
const lines = readAuditLines(fp);
if (!lines.length) continue;
const startSeq = seq + 1;
let firstTime = '', lastTime = '';
lines.forEach((e, i) => { e.seq = startSeq + i; seq = e.seq; if (!firstTime) firstTime = e.t; lastTime = e.t; });
const endSeq = seq;
auditIndex.files.push({ file: n, startSeq, endSeq, count: lines.length, bytes, firstTime, lastTime });
}
auditIndex.total = auditIndex.files.reduce((s, f) => s + f.count, 0);
auditSeq = auditIndex.files.reduce((m, f) => Math.max(m, f.endSeq || 0), 0);
auditIndex.nextSeq = auditSeq;
auditSaveIndex();
}
function readAuditLines(fp) {
let txt = '';
try { txt = fs.readFileSync(fp, 'utf8'); } catch (e) { return []; }
const out = [];
txt.split('\n').forEach((ln) => {
ln = ln.trim();
if (!ln) return;
try { out.push(JSON.parse(ln)); } catch (e) {}
});
return out;
}
function auditSaveIndex() {
// 索引文件很小(仅元数据),每次追加后同步落盘,保证重启/异常退出后分页与文件一致
try { fs.writeFileSync(auditIndexFile(), JSON.stringify(auditIndex)); } catch (e) {}
}
// 写入一行到当前审计文件(JSON Lines);超阈值则切分(滚动重命名),并同步更新索引
function auditAppendToFile(line) {
try {
const cur = auditCurrentFile();
let size = 0;
try { size = fs.statSync(cur).size; } catch (e) {}
if (size + line.length + 1 > AUDIT_FILE_MAX) {
// 将当前文件归档进索引(切分前先记录其区间)
const curLines = readAuditLines(cur);
let curStart = auditIndex.files.length && auditIndex.files[auditIndex.files.length - 1].file === 'audit.log'
? auditIndex.files[auditIndex.files.length - 1].startSeq : (auditSeq - curLines.length + 1);
const curEnd = curStart + curLines.length - 1;
// 滚动:audit.9 -> 删除, audit.8 -> audit.9, ... audit.log -> audit.1
for (let i = AUDIT_FILE_KEEP - 1; i >= 1; i--) {
const src = i === 1 ? cur : auditRotatedFile(i - 1);
const dst = auditRotatedFile(i);
try { if (fs.existsSync(src)) fs.renameSync(src, dst); } catch (e) {}
}
// 把刚归档的 audit.log 信息更新进索引(它现在变成 audit.1)
const fobj = { file: 'audit.log', startSeq: curStart, endSeq: curEnd, count: curLines.length, bytes: size, firstTime: curLines[0] ? curLines[0].t : '', lastTime: curLines[curLines.length - 1] ? curLines[curLines.length - 1].t : '' };
const existing = auditIndex.files.find((f) => f.file === 'audit.log');
if (existing) Object.assign(existing, fobj); else auditIndex.files.push(fobj);
// 重排:保证 audit.log 始终在数组末尾;其他按序号倒序
auditIndex.files.sort((a, b) => {
const num = (n) => n === 'audit.log' ? -1 : -parseInt(n.match(/(\d+)/)[1], 10);
return num(a) - num(b);
});
auditSaveIndex();
}
fs.appendFileSync(cur, line + '\n');
} catch (e) { /* 文件写入失败不阻断主流程 */ }
}
// 记录一条审计:详情包含来源 IP(含内网/外网、XFF 全链、直连地址)、UA、时间、方法、路径等
function audit({ ip, method, path: p, sessionId, code, ok, ua, referer, status, detail }) {
// ip 可为字符串(兼容旧调用)或结构化对象 { ip, remote, xff, isInternal }
const ipInfo = (typeof ip === 'object' && ip) ? ip : { ip: ip || 'unknown', remote: '', xff: '', isInternal: false };
const entry = {
seq: ++auditSeq,
t: new Date().toISOString(),
ip: ipInfo.ip || 'unknown',
ipType: ipInfo.isInternal ? 'internal' : 'external',
remote: ipInfo.remote || '-', // 直连(代理/服务端看到的)地址
xff: ipInfo.xff || '-', // 完整 X-Forwarded-For 链
ua: ua || '-',
referer: referer || '-',
method: method || '-',
path: p || '-',
status: status || 0,
code: code || '-',
ok: ok ? 1 : 0,
sid: sessionId ? sessionId.slice(0, 8) : '-',
detail: detail || '',
};
auditAppendToFile(JSON.stringify(entry));
// 维护索引中当前文件(audit.log)的区间
const curFile = auditIndex.files.find((f) => f.file === 'audit.log');
if (curFile) {
if (curFile.count === 0) { curFile.startSeq = entry.seq; curFile.firstTime = entry.t; }
curFile.endSeq = entry.seq;
curFile.count += 1;
curFile.lastTime = entry.t;
try { curFile.bytes = fs.statSync(auditCurrentFile()).size; } catch (e) {}
} else {
auditIndex.files.push({ file: 'audit.log', startSeq: entry.seq, endSeq: entry.seq, count: 1, bytes: 0, firstTime: entry.t, lastTime: entry.t });
}
auditIndex.total += 1;
auditIndex.nextSeq = auditSeq + 1;
auditSaveIndex();
}
// ============ IP 封锁(登录错误 5 次/30min)============
const ipFails = new Map(); // ip -> { count, first }
const IP_WINDOW = 30 * 60 * 1000;
const IP_MAX_FAIL = 5;
function recordIpFail(ip) {
const now = Date.now();
let r = ipFails.get(ip);
if (!r || now - r.first > IP_WINDOW) r = { count: 0, first: now };
r.count++; ipFails.set(ip, r);
return r.count >= IP_MAX_FAIL;
}
function isIpBlocked(ip) {
const r = ipFails.get(ip);
if (!r) return false;
if (Date.now() - r.first > IP_WINDOW) { ipFails.delete(ip); return false; }
return r.count >= IP_MAX_FAIL;
}
// 从请求构造审计的基础字段(IP/UA/referer),与各接口的具体 code/status 合并
function auditFromReq(req, res, extra) {
return Object.assign({
ip: getClientIp(req),
ua: req.headers['user-agent'] || '',
referer: req.headers['referer'] || '',
sessionId: req.sessionID,
status: res.statusCode,
}, extra);
}
// ============ 验证码 ============
const captchaStore = new Map(); // sessionID -> { text, expires }
const CAPTCHA_TTL = 5 * 60 * 1000;
function genCaptcha() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉易混淆字符
let text = '';
for (let i = 0; i < 5; i++) text += chars[Math.floor(Math.random() * chars.length)];
// 生成 SVG 图片
const colors = ['#2b6cb0', '#2f855a', '#c05621', '#6b46c1'];
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="44" viewBox="0 0 120 44">`;
svg += `<rect width="120" height="44" fill="#f7fafc"/>`;
for (let i = 0; i < text.length; i++) {
const x = 14 + i * 22, y = 30 + (Math.random() * 6 - 3);
const rot = Math.random() * 30 - 15;
const c = colors[i % colors.length];
svg += `<text x="${x}" y="${y}" font-size="26" font-family="monospace" font-weight="bold" fill="${c}" transform="rotate(${rot} ${x} ${y})">${text[i]}</text>`;
}
// 干扰线
for (let i = 0; i < 3; i++) {
svg += `<line x1="${Math.random() * 120}" y1="${Math.random() * 44}" x2="${Math.random() * 120}" y2="${Math.random() * 44}" stroke="#cbd5e0" stroke-width="1"/>`;
}
svg += `</svg>`;
return { text, svg };
}
// 解析客户端 IP 信息:完整记录直连地址、X-Forwarded-For 全链、真实客户端 IP 及是否内网
// 拦截器/反向代理场景:XFF 最右侧(最后一个)为真实客户端,前面为各级代理
const PRIVATE_RE = /^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/;
function isPrivateIp(ip) {
if (!ip) return false;
ip = ip.trim().replace(/^::ffff:/, '');
if (ip === '::1' || ip === 'localhost' || ip === 'unknown') return true;
return PRIVATE_RE.test(ip);
}
function getClientIp(req) {
const remote = (req.socket && req.socket.remoteAddress) || 'unknown';
const xffRaw = (req.headers['x-forwarded-for'] || '').toString().trim();
const xffList = xffRaw ? xffRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
// 真实客户端:XFF 链最后一跳(离服务端最远),若没有 XFF 则取直连
const realIp = xffList.length ? xffList[xffList.length - 1] : remote;
const isInternal = isPrivateIp(realIp) && xffList.length === 0 ? isPrivateIp(remote) : isPrivateIp(realIp);
return { ip: realIp, remote, xff: xffRaw, isInternal };
}
// ============ Express ============
const app = express();
// 注意:未启用 trust proxy。若部署在反向代理后,请按实际拓扑设置;
// 当前按直连处理,IP 通过 req.socket.remoteAddress 获取,避免误判。
app.disable('x-powered-by');
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'no-referrer');
// 基础 CSP:仅允许同源脚本/样式,防 XSS 与注入
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
next();
});
app.use(express.json({ limit: '8kb' }));
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
// 注:登录不复用 express-rate-limit(用户要求 login 不限流);
// 防爆破由下方「登录失败 IP 封锁」机制(30 分钟内 5 次失败封 IP)承担。
// unlock 同样处于 login 之后,且主密码错误仅返回 401,无额外限流。
app.use(session({
secret: config.sessionSecret || crypto.randomBytes(32).toString('hex'),
resave: true,
saveUninitialized: false,
cookie: {
httpOnly: true, sameSite: 'lax',
// HTTPS 模式下标记为 Secure;HTTP 开发模式为 false(cookie 必须始终下发)
secure: HAS_SSL,
maxAge: config.sessionMaxAge,
},
}));
// 全局审计中间件:注册在 session 之后,确保 req.sessionID 有效;
// 已在具体接口内精细记录(带错误码)的,通过 res._audited 标记避免重复
app.use('/api', (req, res, next) => {
res.on('finish', () => {
if (res._audited) return;
const ip = getClientIp(req);
audit({
ip, method: req.method, path: req.path, sessionId: req.sessionID,
code: res.statusCode < 400 ? 'OK' : 'ERR' + res.statusCode,
ok: res.statusCode < 400,
ua: req.headers['user-agent'] || '',
referer: req.headers['referer'] || '',
status: res.statusCode,
});
});
next();
});
// ---- 中间件 ----
function appAuth(req, res, next) {
if (req.session && req.session.appAuthed) return next();
return res.status(401).json({ error: '未登录' });
}
function unlocked(req, res, next) {
if (dbs.has(req.sessionID)) return next();
return res.status(401).json({ error: '未解锁' });
}
function writable(req, res, next) {
if (config.writable) return next();
return res.status(403).json({ error: '只读模式' });
}
// 解密前端用传输公钥加密的密文(提取 helper)
function decryptBody(req, field) {
const raw = (req.body && req.body[field]) || '';
if (!raw) throw new Error(ERR.ENC_FAIL);
return rsaDecrypt(raw);
}
// 动态数据密钥是否过期(1 小时)
function checkDataKey(req, res) {
const rec = dbs.get(req.sessionID);
if (!rec) return res.status(401).json({ error: '未解锁' });
if (Date.now() > rec.dataKeyExpire) {
return res.status(403).json({ error: '动态密钥已过期,请重新解锁', code: ERR.KEY_EXPIRED });
}
return null;
}
// 解密后端用会话 dataRSA 公钥加密的字段(密码)-> 这里只是转发密文,前端用私钥解
// 后端只存密文,明文不落内存
// ============ 路由 ============
const dbs = new Map(); // sessionID -> { db, items, groups, tree, name, dataKeyExpire }
// 状态探测
app.get('/api/status', (req, res) => {
const authed = !!(req.session && req.session.appAuthed);
const unlocked = dbs.has(req.sessionID);
res.json({ authed, unlocked, writable: config.writable, name: unlocked ? dbs.get(req.sessionID).name : null });
});
// 下发传输公钥
app.get('/api/pubkey', (req, res) => res.json({ pubkey: TRANSPORT_RSA.publicKey }));
// 验证码(SVG 图片 + 答案存内存,返回 captchaId 由前端在登录时回传)
app.get('/api/captcha', (req, res) => {
const { text, svg } = genCaptcha();
const cid = crypto.randomBytes(8).toString('hex');
captchaStore.set(cid, { text, expires: Date.now() + CAPTCHA_TTL });
// 仅测试环境回显明文,便于自动化;生产环境绝不返回 text
if (process.env.NODE_ENV === 'test') res.json({ cid, svg, text });
else res.json({ cid, svg });
});
// 登录:APP 门户口令 + 验证码
app.post('/api/login', (req, res) => {
const ip = getClientIp(req);
if (isIpBlocked(ip)) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.IP_BLOCKED, ok: false, detail: 'IP 已封锁' }));
return res.status(429).json({ error: '该 IP 已被封锁,请 30 分钟后再试', code: ERR.IP_BLOCKED });
}
let password, dataPubPem, captcha, captchaId;
try {
password = decryptBody(req, 'enc');
dataPubPem = (req.body && req.body.dataPubKey) || '';
captcha = (req.body && req.body.captcha) || '';
captchaId = (req.body && req.body.captchaId) || '';
} catch (e) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.ENC_FAIL, ok: false, detail: '请求密文解析失败' }));
return res.status(400).json({ error: '密文解析失败', code: ERR.ENC_FAIL });
}
// 验证码校验
const cap = captchaStore.get(captchaId);
captchaStore.delete(captchaId);
if (!cap || Date.now() > cap.expires || !captcha || captcha.toUpperCase() !== cap.text) {
recordIpFail(ip);
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.CAPTCHA_FAIL, ok: false, detail: '验证码错误' }));
return res.status(400).json({ error: '验证码错误', code: ERR.CAPTCHA_FAIL });
}
// 门户口令校验(含大小写+8位规则在 config 侧已由期望值约束;此处仅比对)
if (!password || password !== config.getExpectedAppPassword()) {
const blocked = recordIpFail(ip);
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.AUTH_APP_FAIL, ok: false, detail: blocked ? '尝试过多已封锁' : '门户口令错误' }));
if (blocked) return res.status(429).json({ error: '尝试次数过多,IP 已封锁', code: ERR.IP_BLOCKED });
return res.status(401).json({ error: '门户口令错误', code: ERR.AUTH_APP_FAIL });
}
// 门户口令合规检查:必须含大小写且长度>=8(期望值本身应满足,这里做额外策略校验)
if (!/^(?=.*[a-z])(?=.*[A-Z]).{8,}$/.test(password)) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.AUTH_APP_FAIL, ok: false, detail: '口令复杂度不满足' }));
return res.status(401).json({ error: '门户口令不符合复杂度要求', code: ERR.AUTH_APP_FAIL });
}
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: '会话错误' });
req.session.appAuthed = true;
// 暂存前端 dataRSA 公钥,unlock 时使用
if (dataPubPem) req.session.dataPubPem = dataPubPem;
// 协商会话 AES 通道密钥:前端用传输公钥加密上传,服务端用私钥解出 32 字节写入内存
sessionKeys.delete(req.sessionID); // 旧会话的通道密钥清理
try {
const skB64 = (req.body && req.body.sessionKey) ? rsaDecrypt(req.body.sessionKey) : '';
const skBuf = Buffer.from(skB64, 'base64');
if (skBuf.length === 32) sessionKeys.set(req.sessionID, skBuf);
} catch (e) { /* 通道密钥缺失不阻断登录,仅后续响应走明文兜底 */ }
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: 'OK', ok: true, detail: '登录成功' }));
res.json({ ok: true, writable: config.writable });
});
});
// 解锁:KeePass 主密码 -> 解密库 -> 用前端 dataRSA 公钥加密密码字段存密文(内存无明文)
app.post('/api/unlock', appAuth, async (req, res) => {
const ip = getClientIp(req);
let masterPassword;
try { masterPassword = decryptBody(req, 'enc'); }
catch (e) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.ENC_FAIL, ok: false, detail: '请求密文解析失败' }));
return res.status(400).json({ error: '密文解析失败', code: ERR.ENC_FAIL });
}
if (!masterPassword) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.MISSING, ok: false, detail: '缺少主密码' }));
return res.status(400).json({ error: '缺少主密码', code: ERR.MISSING });
}
const dataPubPem = req.session.dataPubPem;
if (!dataPubPem) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.MISSING, ok: false, detail: '缺少数据公钥' }));
return res.status(400).json({ error: '缺少数据公钥,请重新登录', code: ERR.MISSING });
}
try {
const rec = await loadDatabase(
config.kdbxPath,
config.keyfilePath || undefined,
masterPassword,
dataPubPem // 用于加密密码字段存密文
);
const prevKeys = sessionKeys.get(req.sessionID);
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: '会话错误' });
req.session.appAuthed = true;
req.session.dataPubPem = dataPubPem;
dbs.set(req.sessionID, rec);
if (prevKeys) sessionKeys.set(req.sessionID, prevKeys);
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: 'OK', ok: true, detail: '解锁成功' }));
res.json({ ok: true, name: rec.name, count: rec.items.length, writable: config.writable,
dataKeyExpire: rec.dataKeyExpire });
});
} catch (e) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.AUTH_MASTER_FAIL, ok: false, detail: '主密码或密钥文件错误' }));
return res.status(401).json({ error: '主密码或密钥文件错误', code: ERR.AUTH_MASTER_FAIL });
}
});
// 数据:分组树(无敏感字段,仍按会话加密返回)
app.get('/api/tree', appAuth, unlocked, (req, res) => {
const rec = dbs.get(req.sessionID);
const blocked = checkDataKey(req, res); if (blocked) return;
encRes(req, res, { name: rec.name, tree: rec.tree, groups: rec.groups });
});
// 数据:某分组下的密码列表(分页;列表不含密码明文,整体加密返回)
const PAGE_SIZE = 50;
app.get('/api/entries', appAuth, unlocked, (req, res) => {
const rec = dbs.get(req.sessionID);
const blocked = checkDataKey(req, res); if (blocked) return;
const group = req.query.group || null;
const q = (req.query.q || '').toString().trim().toLowerCase();
const page = Math.max(1, parseInt(req.query.page || '1', 10));
let list = rec.items;
if (group) list = list.filter((i) => i.groupId === group);
if (q) list = list.filter((i) =>
(i.title || '').toLowerCase().includes(q) ||
(i.username || '').toLowerCase().includes(q) ||
(i.url || '').toLowerCase().includes(q));
const total = list.length;
const start = (page - 1) * PAGE_SIZE;
const pageItems = list.slice(start, start + PAGE_SIZE).map((i) => ({
id: i.id, title: i.title, username: i.username, url: i.url, group: i.group,
}));
encRes(req, res, { items: pageItems, page, pageSize: PAGE_SIZE, total, group, q });
});
// 数据:单条详情(密码为密文,前端用 dataRSA 私钥解密;整体再经通道加密)
app.get('/api/entry/:id', appAuth, unlocked, (req, res) => {
const rec = dbs.get(req.sessionID);
const blocked = checkDataKey(req, res); if (blocked) return;
const item = rec.items.find((i) => i.id === req.params.id);
if (!item) return encRes(req, res, { error: '条目不存在', code: ERR.NOT_FOUND }, 404);
encRes(req, res, {
id: item.id, title: item.title, username: item.username,
passwordCrypt: item.passwordCrypt, // 密文!前端解密
url: item.url, notes: item.notes, group: item.group,
});
});
// 编辑(仅可写模式)
app.post('/api/entry/update', appAuth, unlocked, writable, async (req, res) => {
const rec = dbs.get(req.sessionID);
const blocked = checkDataKey(req, res); if (blocked) return;
const { id, fields } = req.body || {};
if (!id || !fields) return res.status(400).json({ error: '参数缺失', code: ERR.MISSING });
try {
const entry = rec.db.entries.find((e) => Buffer.from(e.uuid.toBytes()).toString('base64') === id);
if (!entry) return res.status(404).json({ error: '条目不存在', code: ERR.NOT_FOUND });
entry.fields.set('Title', fields.title || '');
entry.fields.set('UserName', fields.username || '');
entry.fields.set('Password', ProtectedValue.fromString(fields.password || ''));
entry.fields.set('URL', fields.url || '');
entry.fields.set('Notes', fields.notes || '');
rec.db.cleanup({ historyRules: true });
rec.db.save();
const buf = rec.db.save();
if (config.keyfilePath) {
fs.copyFileSync(config.kdbxPath, config.kdbxPath + '.bak');
}
fs.writeFileSync(config.kdbxPath, Buffer.from(buf));
// 重新抽取并加密密码字段
const ex = extract(rec.db, req.session.dataPubPem);
rec.groups = ex.groups; rec.items = ex.items; rec.tree = ex.tree; rec.name = ex.name;
encRes(req, res, { ok: true, item: ex.items.find((i) => i.id === id) });
} catch (e) {
res.status(500).json({ error: '保存失败' });
}
});
// 独立审计页(或新标签页)协商响应通道密钥:复用同一会话的服务端密钥槽
app.post('/api/session/key', appAuth, (req, res) => {
try {
const raw = (req.body && req.body.key) || '';
if (!raw) return res.status(400).json({ error: '缺少 key' });
const b64 = rsaDecrypt(raw);
const buf = Buffer.from(b64, 'base64');
if (buf.length !== 32) return res.status(400).json({ error: '密钥长度错误' });
sessionKeys.set(req.sessionID, buf);
res.json({ ok: true });
} catch (e) { res.status(400).json({ error: '密钥解析失败' }); }
});
// 审计索引:返回各日志文件分布(文件/起始序号/结束序号/条数/时间范围)与总条数
// 前端分页前先读此接口计算分布,再按需从对应文件提取,保证分页与文件一致
app.get('/api/audit/index', appAuth, (req, res) => {
encRes(req, res, { total: auditIndex.total, files: auditIndex.files });
});
// 按文件 + 序号区间读取审计片段(解密后返回),供前端精确分页
app.get('/api/audit/file', appAuth, (req, res) => {
const q = req.query || {};
const file = (q.file || 'audit.log').toString();
// 仅允许白名单文件名,防目录穿越
if (!/^audit(\.\d+)?\.log$/.test(file)) return encRes(req, res, { error: '非法文件名' }, 400);
const fp = path.join(logDir, file);
const fromSeq = parseInt(q.fromSeq || '0', 10) || 0;
const toSeq = parseInt(q.toSeq || '0', 10) || 0;
let lines = readAuditLines(fp);
if (fromSeq || toSeq) {
lines = lines.filter((e) => (!fromSeq || e.seq >= fromSeq) && (!toSeq || e.seq <= toSeq));
}
encRes(req, res, { file, count: lines.length, log: lines });
});
// 审计日志(登录后可访问):基于「索引 + 本地文件」读取,支持筛选 + 分页,条数与文件完全一致
app.get('/api/audit', appAuth, (req, res) => {
const q = req.query || {};
const ip = (q.ip || '').trim().toLowerCase();
const ipType = (q.iptype || '').trim().toLowerCase();
const code = (q.code || '').trim();
const method = (q.method || '').trim().toUpperCase();
const pathFilter = (q.path || '').trim().toLowerCase();
const onlyFail = q.fail === '1' || q.fail === 'true';
const from = q.from ? Date.parse(q.from) : 0;
const to = q.to ? Date.parse(q.to) : Date.now() + 1;
const pageSize = Math.min(parseInt(q.pageSize || '50', 10) || 50, 500);
const page = Math.max(parseInt(q.page || '1', 10) || 1, 1);
// 汇总所有日志文件(按索引顺序,旧文件在前、audit.log 在后),保证完整覆盖
let all = [];
for (const f of auditIndex.files) {
const fp = path.join(logDir, f.file);
all = all.concat(readAuditLines(fp));
}
// 按 seq 升序排列(seq 全局单调递增;文件顺序已保证时间序,这里保险)
all.sort((a, b) => (a.seq || 0) - (b.seq || 0));
let rows = all;
if (ip || code || method || pathFilter || onlyFail || from || to < Date.now() + 1) {
rows = rows.filter((e) => {
if (ip && !String(e.ip || '').toLowerCase().includes(ip)) return false;
if (ipType && (e.ipType || '').toLowerCase() !== ipType) return false;
if (code && e.code !== code) return false;
if (method && e.method !== method) return false;
if (pathFilter && !String(e.path || '').toLowerCase().includes(pathFilter)) return false;
if (onlyFail && e.ok) return false;
const ts = Date.parse(e.t);
if (from && ts < from) return false;
if (to && ts > to) return false;
return true;
});
}
const total = rows.length;
// 默认显示最近的数据:按 seq 倒序取第 page 页(前端"最新在前")
const sortedDesc = rows.slice().sort((a, b) => (b.seq || 0) - (a.seq || 0));
const start = (page - 1) * pageSize;
const list = sortedDesc.slice(start, start + pageSize);
encRes(req, res, {
log: list,
total,
page,
pageSize,
indexTotal: auditIndex.total,
filters: { ip, code, method, path: pathFilter, onlyFail, from: from || '', to: to < Date.now() + 1 ? q.to : '' },
});
});
// 当前被封锁的 IP 列表(便于审计页展示与排查)
app.get('/api/audit/blocks', appAuth, (req, res) => {
const now = Date.now();
const list = [];
for (const [ip, r] of ipFails) {
if (now - r.first > IP_WINDOW) continue;
if (r.count >= IP_MAX_FAIL) {
list.push({ ip, fails: r.count, expiresAt: new Date(r.first + IP_WINDOW).toISOString() });
}
}
encRes(req, res, { blocks: list });
});
// 手动解除某 IP 封锁(管理员操作,记入审计)
app.post('/api/audit/unblock', appAuth, (req, res) => {
const target = (req.body && req.body.ip) || '';
if (!target) return encRes(req, res, { error: '缺少 ip' }, 400);
ipFails.delete(target);
audit(auditFromReq(req, res, { method: 'POST', path: '/api/audit/unblock', code: 'OK', ok: true, detail: '手动解封 IP: ' + target }));
encRes(req, res, { ok: true });
});
// 退出:清空内存
app.post('/api/logout', (req, res) => {
if (req.sessionID) { dbs.delete(req.sessionID); sessionKeys.delete(req.sessionID); captchaStore.delete(req.sessionID); }
if (req.session) req.session.destroy(() => res.json({ ok: true }));
else res.json({ ok: true });
});
// ============ 启动 ============
// 会话销毁时联动清理内存中的明文/密文数据,避免孤儿数据常驻
const sessionMiddleware = app._router && app._router.stack;
// 通过监听 destroy:express-session 在 req.session.destroy 时触发,这里在 logout 已清理;
// 另加定时器兜底:扫描过期数据密钥并清理
setInterval(() => {
const now = Date.now();
for (const [sid, rec] of dbs) {
if (rec.dataKeyExpire && now > rec.dataKeyExpire + 60 * 1000) {
dbs.delete(sid); sessionKeys.delete(sid);
}
}
// 清理过期验证码
for (const [sid, cap] of captchaStore) {
if (now > cap.expires) captchaStore.delete(sid);
}
}, 60 * 1000).unref();
function startServer() {
// 启动前恢复/重建审计索引,使分页与本地文件一致
try { auditLoadIndex(); } catch (e) {}
const port = config.port;
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
const hasSsl = fs.existsSync(keyPath) && fs.existsSync(certPath);
if (hasSsl) {
// 生产模式:默认 HTTPS;同时起一个 HTTP 端口把所有请求 301 重定向到 HTTPS
const opt = { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) };
https.createServer(opt, app).listen(port, () => {
console.log(`[kdbx-viewer] HTTPS 已启动: https://localhost:${port}`);
if (!config.sessionSecret) console.warn('[config] 警告: SESSION_SECRET 未设置,已使用随机值(重启后会话失效)');
});
const redirectPort = port + 1;
http.createServer((req, res) => {
const host = req.headers.host ? req.headers.host.replace(/:\d+$/, '') : 'localhost';
res.writeHead(301, { Location: `https://${host}:${port}${req.url}` });
res.end();
}).listen(redirectPort, () => {
console.log(`[kdbx-viewer] HTTP(${redirectPort}) -> HTTPS(${port}) 重定向已启用`);
});
} else {
// 开发模式(无证书):仍允许 HTTP,但明确提示生产必须用 HTTPS
http.createServer(app).listen(port, () => {
console.log(`[kdbx-viewer] HTTP 已启动: http://localhost:${port}`);
console.warn('[安全] 未检测到 SSL 证书,已使用明文 HTTP。生产环境请配置 SSL_KEY/SSL_CERT 启用 HTTPS。');
});
}
}
startServer();
module.exports = app;