feat: 初始化 kdbx-viewer 项目
实现服务端解密的 KeePass 网页查看器,包含登录门户口令与验证码、RSA+会话级 AES 加密通道、审计日志持久化、HTTPS 自动证书、Docker 部署配置及端到端测试。
这个提交包含在:
+113
@@ -0,0 +1,113 @@
|
||||
'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 };
|
||||
在新工单中引用
屏蔽一个用户