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