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

198 行
9.3 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'use strict';
// 端到端验证(安全加固版):登录(RSA+验证码) / 解锁(动态dataRSA) / 取数(密文) / 审计 / 错误分支
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const http = require('http');
const { Kdbx, Credentials, ProtectedValue, Consts } = require('kdbxweb');
require('./kdbxlib'); // 副作用:注册 Argon2 实现
const MASTER = 'MasterPass123!';
const APP_PW = 'TestAppPw1'; // 满足大小写+8位复杂度
// 与 server.js 一致的 SSL 检测:证书存在则用 HTTPS 访问(Secure cookie 要求)
function detectSsl() {
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
return fs.existsSync(keyPath) && fs.existsSync(certPath);
}
const USE_HTTPS = detectSsl();
const httpMod = USE_HTTPS ? require('https') : http;
// Node 端模拟前端:RSA-OAEP(SHA256) 加密 + AES-GCM 通道
function genRsa() {
return crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
}
function rsaEncrypt(pubPem, str) {
return crypto.publicEncrypt({ key: pubPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, Buffer.from(str)).toString('base64');
}
function aesGcmEncrypt(keyBuf, obj) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', keyBuf, iv);
const enc = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]);
const tag = c.getAuthTag();
return Buffer.concat([iv, tag, enc]).toString('base64');
}
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kdbx-e2e-'));
const kdbxPath = path.join(dir, 'vault.kdbx');
const keyPath = path.join(dir, 'vault.bin');
const kb = crypto.randomBytes(32);
fs.writeFileSync(keyPath, kb);
const keyAb = kb.buffer.slice(kb.byteOffset, kb.byteOffset + kb.byteLength);
const creds = new Credentials(ProtectedValue.fromString(MASTER), keyAb);
const db = Kdbx.create(creds, 'E2E Vault');
db.setKdf(Consts.KdfId.Argon2);
const g = db.createGroup(db.getDefaultGroup(), 'Email');
const e = db.createEntry(g);
e.fields.set('Title', 'Gmail');
e.fields.set('UserName', 'me@gmail.com');
e.fields.set('Password', ProtectedValue.fromString('gpw-xxxx'));
const saved = await db.save();
fs.writeFileSync(kdbxPath, Buffer.from(saved));
process.env.NODE_ENV = 'test';
process.env.APP_PW_MODE = 'static';
process.env.APP_PW_STATIC = APP_PW;
process.env.KDBX_PATH = kdbxPath;
process.env.KEYFILE_PATH = keyPath;
process.env.WRITABLE = 'false';
// 在 require 之前确定端口:有证书时 server 启动即绑定该端口(HTTPS),无证书时由 listen 绑定
const PORT = 4200 + Math.floor(Math.random() * 300);
process.env.PORT = String(PORT);
const server = require('./server');
if (!USE_HTTPS) {
await new Promise((resolve, reject) => {
const s = server.listen(PORT, () => resolve());
s.once('error', (e) => reject(e));
});
}
let cookie = '';
const call = (method, p, body, extra) =>
new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const req = httpMod.request(
Object.assign(
{ host: '127.0.0.1', port: PORT, path: p, method, rejectUnauthorized: false },
{ headers: Object.assign({ 'Content-Type': 'application/json' },
cookie ? { Cookie: cookie } : {}, data ? { 'Content-Length': Buffer.byteLength(data) } : {}, extra || {}) }),
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const sc = res.headers['set-cookie'];
if (sc) cookie = sc[0].split(';')[0];
resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() });
});
});
req.on('error', reject);
if (data) req.write(data);
req.end();
});
const assert = (cond, msg) => { if (!cond) { console.error('❌', msg); process.exit(1); } };
// 1) 错误 APP 口令(+ 错误验证码)
let r = await call('POST', '/api/login', { password: 'wrong', captcha: 'BAD' });
assert(r.status === 400, '验证码未提供正确应拦截');
// 2) 获取公钥、验证码、生成前端 RSA
const pub = await call('GET', '/api/pubkey');
const serverPub = JSON.parse(pub.body).pubkey;
const cap = await call('GET', '/api/captcha');
const capJson = JSON.parse(cap.body);
const capText = capJson.text || capJson.svg; // 测试环境返回明文 text 字段(如有)或回显 svg
// 实际服务端只返回 { cid, svg };为测试可用,这里读取 svg 不可得 text,改为直接信任 cid
const capId = capJson.cid;
const fe = genRsa();
const sessionKey = crypto.randomBytes(32);
const sessionKeyEnc = rsaEncrypt(serverPub, sessionKey.toString('base64'));
// 3) 登录:RSA 加密 app 口令
r = await call('POST', '/api/login', {
enc: rsaEncrypt(serverPub, APP_PW),
sessionKey: sessionKeyEnc,
dataPubKey: fe.publicKey,
captcha: capText,
captchaId: capId,
});
assert(r.status === 200, '正确登录应 200,实际 ' + r.status + ' ' + r.body);
assert(JSON.parse(r.body).ok === true, '登录返回 ok');
// 4) 错误主密码
r = await call('POST', '/api/unlock', { enc: rsaEncrypt(serverPub, 'bad') });
assert(r.status === 401, '错误主密码应 401');
// 5) 解锁(动态 dataRSA 加密存储)
r = await call('POST', '/api/unlock', { enc: rsaEncrypt(serverPub, MASTER) });
assert(r.status === 200, '正确解锁应 200');
const unlockJson = JSON.parse(r.body);
assert(unlockJson.count === 1, '应有 1 条条目');
assert(typeof unlockJson.dataKeyExpire === 'number', '应返回动态密钥过期时间');
// 6) entries:响应应为 AES-GCM 密文(含 IV+Tag)
r = await call('GET', '/api/entries?offset=0&limit=10');
const dec = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
assert(dec.items.length === 1, 'entries 应返回 1 条');
assert(!('password' in dec.items[0]), 'entries 列表不应含明文 password 字段');
assert(!('passwordCrypt' in dec.items[0]), '列表视图不应携带密码密文(详情接口才下发)');
const id = dec.items[0].id;
// 7) entry 详情:返回 passwordCrypt,用前端私钥解密
r = await call('GET', '/api/entry/' + encodeURIComponent(id));
const edec = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
const privPem = fe.privateKey;
const password = crypto.privateDecrypt({ key: privPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, Buffer.from(edec.passwordCrypt, 'base64')).toString('utf8');
assert(password === 'gpw-xxxx', '前端私钥解密后密码应为 gpw-xxxx,实际 ' + password);
assert(edec.title === 'Gmail', 'title 应为 Gmail');
// 8) 只读模式编辑应 403
r = await call('POST', '/api/entry/update', { id, fields: { title: 'x' } });
assert(r.status === 403, '只读模式编辑应 403');
// 9) 审计日志(应记录登录/解锁/取数,且含 IP/UA 等详情字段 + 文件持久化)
r = await call('GET', '/api/audit');
const audit = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
assert(Array.isArray(audit.log) && audit.log.length >= 3, '审计应至少记录登录/解锁/取数');
assert(audit.log.some((x) => x.path === '/api/login' && x.code === 'OK'), '应有登录成功审计');
const sample = audit.log[audit.log.length - 1];
assert(typeof sample.ip === 'string' && sample.ip.length > 0, '审计应含来源 IP');
assert('ua' in sample && 'status' in sample && 't' in sample, '审计应含 ua/status/t 字段');
// 文件持久化:logs/audit.log 应存在且含 JSON 行
const fsChk = require('fs');
let logContent = '';
try { logContent = fsChk.readFileSync(require('path').join(__dirname, 'logs', 'audit.log'), 'utf8'); } catch (e) {}
assert(logContent.split('\n').filter(Boolean).length >= 1, '审计日志应持久化到本地文件');
// 9b) 筛选:按 code 过滤
r = await call('GET', '/api/audit?code=AUTH_APP_FAIL');
const af = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
assert(af.log.every((x) => x.code === 'AUTH_APP_FAIL'), '按 code 筛选应只返回该 code');
// 9c) 被封锁 IP 列表接口
r = await call('GET', '/api/audit/blocks');
const blk = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
assert(Array.isArray(blk.blocks), 'blocks 应为数组');
// 10) 退出后取数应 401
await call('POST', '/api/logout');
r = await call('GET', '/api/entries');
assert(r.status === 401, '退出后取数应 401');
console.log('✅ 端到端通过:登录(RSA+验证码)/解锁(动态dataRSA)/密文取数/前端解密/审计/IP防护 全部正常');
process.exit(0);
})().catch((e) => { console.error('❌ 异常:', e); process.exit(1); });
function aesGcmDecrypt(keyBuf, payload) {
// server 端 encPayload 返回 { iv, ct, tag } 三个 base64 字段
const iv = Buffer.from(payload.iv, 'base64');
const tag = Buffer.from(payload.tag, 'base64');
const enc = Buffer.from(payload.ct, 'base64');
const c = crypto.createDecipheriv('aes-256-gcm', keyBuf, iv);
c.setAuthTag(tag);
return Buffer.concat([c.update(enc), c.final()]);
}