实现服务端解密的 KeePass 网页查看器,包含登录门户口令与验证码、RSA+会话级 AES 加密通道、审计日志持久化、HTTPS 自动证书、Docker 部署配置及端到端测试。
198 行
9.3 KiB
JavaScript
198 行
9.3 KiB
JavaScript
'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()]);
|
||
}
|