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

114 行
4.5 KiB
JavaScript

'use strict';
const fs = require('fs');
const { Kdbx, Credentials, ProtectedValue, CryptoEngine } = require('kdbxweb');
const { argon2d, argon2i, argon2id } = require('hash-wasm');
// ===== Argon2 胶水层(纯 WASM,无需原生编译,alpine 可直接跑)=====
// kdbxweb 的 KDBX4 需要 Argon2 实现,hash-wasm 提供纯 WebAssembly 版本。
// 签名:setArgon2Impl(password, salt, memory, iterations, length, parallelism, type, version) => ArrayBuffer
CryptoEngine.setArgon2Impl(async (password, salt, memory, iterations, length, parallelism, type, version) => {
const fn = type === 2 ? argon2id : type === 1 ? argon2i : argon2d;
const pwd = password instanceof Uint8Array ? password : new Uint8Array(password);
const slt = salt instanceof Uint8Array ? salt : new Uint8Array(salt);
const hash = await fn({
password: pwd,
salt: slt,
parallelism,
iterations,
memorySize: memory, // kdbxweb 传入的单位就是 KB,与 hash-wasm 一致
hashLength: length,
version: version === 0x10 ? 0x10 : 0x13,
outputType: 'binary', // 返回 Uint8Array,而非默认 hex 字符串
});
return hash.buffer.slice(hash.byteOffset, hash.byteOffset + hash.byteLength);
});
// Buffer -> 精确的 ArrayBuffer(避免 .buffer 偏大)
function abFromBuf(buf) {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
}
// 兼容字段可能是 string 或 ProtectedValue
function fieldText(field) {
if (field === undefined || field === null) return '';
if (typeof field === 'string') return field;
if (typeof field.getText === 'function') return field.getText();
return String(field);
}
const crypto = require('crypto');
// 用前端 dataRSA 公钥加密密码字段 -> base64 密文(明文不保存)
function encryptField(plain, pubPem) {
if (!pubPem || plain === undefined || plain === null) return '';
const buf = Buffer.from(String(plain), 'utf8');
return crypto.publicEncrypt(
{ key: pubPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
buf
).toString('base64');
}
// 把一条 entry 抽取为前端友好的扁平对象(密码仅存密文,明文不驻内存)
function entryToItem(e, pubPem) {
const f = e.fields;
const password = fieldText(f.get('Password'));
return {
id: Buffer.from(e.uuid.toBytes()).toString('base64'),
title: fieldText(f.get('Title')),
username: fieldText(f.get('UserName')),
// 用前端 dataRSA 公钥加密后存密文;明文立即丢弃
passwordCrypt: encryptField(password, pubPem),
url: fieldText(f.get('URL')),
notes: fieldText(f.get('Notes')),
group: '',
};
}
// 遍历分组树,返回 { tree, items, entryMap, groups }
function extract(db, pubPem) {
const groups = []; // 扁平分组列表 [{ id, name, path, parent }]
const items = [];
const entryMap = new Map();
const tree = []; // 嵌套树 [{ id, name, path, children: [] }]
const idOf = (path) => Buffer.from(path || '', 'utf8').toString('base64').replace(/=+$/, '');
const walk = (g, parentPath) => {
const name = g.name || '';
const p = parentPath ? `${parentPath}/${name}` : name;
const id = idOf(p);
groups.push({ id, name, path: p, parent: parentPath ? idOf(parentPath) : null });
const node = { id, name, path: p, children: [] };
for (const e of g.entries) {
const item = entryToItem(e, pubPem);
item.group = p;
item.groupId = id;
entryMap.set(item.id, e);
items.push(item);
}
for (const c of g.groups) node.children.push(walk(c, p));
return node;
};
const root = walk(db.getDefaultGroup(), '');
tree.push(root);
return { groups, items, entryMap, tree, name: db.meta.name };
}
// 服务端加载并解密:kdbx + keyfile 都在磁盘读取,keyfile 永不离开服务器
// pubPem: 前端动态生成的 dataRSA 公钥,用于把密码字段加密成密文后存内存
async function loadDatabase(kdbxPath, keyfilePath, masterPassword, pubPem) {
const data = fs.readFileSync(kdbxPath);
const keyBuf = keyfilePath ? fs.readFileSync(keyfilePath) : undefined;
const creds = new Credentials(
ProtectedValue.fromString(masterPassword),
keyBuf ? abFromBuf(keyBuf) : undefined
);
const db = await Kdbx.load(abFromBuf(data), creds); // 内存解密,不落盘
const ex = extract(db, pubPem);
// 动态数据密钥 1 小时过期
const dataKeyExpire = Date.now() + 60 * 60 * 1000;
return { db, creds, ...ex, dataKeyExpire };
}
module.exports = { loadDatabase, extract, ProtectedValue };