实现服务端解密的 KeePass 网页查看器,包含登录门户口令与验证码、RSA+会话级 AES 加密通道、审计日志持久化、HTTPS 自动证书、Docker 部署配置及端到端测试。
391 行
16 KiB
JavaScript
391 行
16 KiB
JavaScript
'use strict';
|
|
// ===== 工具 =====
|
|
const $ = (id) => document.getElementById(id);
|
|
const show = (el) => el.classList.remove('hidden');
|
|
const hide = (el) => el.classList.add('hidden');
|
|
|
|
// ===== 加密体系 =====
|
|
// 1) 非对称:用服务端 RSA 公钥加密口令 / 会话密钥
|
|
let _PUBKEY = null;
|
|
|
|
async function importRsaPublicKey(pem) {
|
|
const b64 = pem.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, '').replace(/\s+/g, '');
|
|
const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
return crypto.subtle.importKey('spki', der, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
|
|
}
|
|
|
|
async function getPubKey() {
|
|
if (_PUBKEY) return _PUBKEY;
|
|
const r = await api('/api/pubkey');
|
|
_PUBKEY = await importRsaPublicKey(r.pubkey);
|
|
return _PUBKEY;
|
|
}
|
|
|
|
async function rsaEncrypt(text) {
|
|
const key = await getPubKey();
|
|
const data = new TextEncoder().encode(text);
|
|
const buf = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data);
|
|
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
|
}
|
|
|
|
// 2) 会话级对称密钥(AES-256-GCM):登录时生成,用于加密响应通道,RSA 上传给后端
|
|
let _SESSION_AES = null; // CryptoKey,仅存内存
|
|
let _SESSION_AES_B64 = null; // raw base64,缓存到 localStorage 供审计页(同会话)复用,避免重复协商冲突
|
|
|
|
async function sessionKeyForUpload() {
|
|
const raw = crypto.getRandomValues(new Uint8Array(32));
|
|
_SESSION_AES = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
_SESSION_AES_B64 = btoa(String.fromCharCode(...raw));
|
|
try { localStorage.setItem('sessAes', _SESSION_AES_B64); } catch (e) {}
|
|
return rsaEncrypt(_SESSION_AES_B64); // RSA 加密后的 base64
|
|
}
|
|
|
|
// 3) 动态数据密钥(dataRSA):前端生成密钥对,私钥仅留浏览器内存,公钥上传后端加密密码字段
|
|
let _DATARSA_PRIV = null; // CryptoKey 私钥,仅存内存
|
|
let _DATARSA_PUB_PEM = null;
|
|
|
|
async function ensureDataRsa() {
|
|
if (_DATARSA_PRIV) return _DATARSA_PRIV;
|
|
const pair = await crypto.subtle.generateKey(
|
|
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
|
false, ['encrypt', 'decrypt']
|
|
);
|
|
_DATARSA_PRIV = pair.privateKey;
|
|
// 导出公钥为 PEM(SPKI)
|
|
const spki = await crypto.subtle.exportKey('spki', pair.publicKey);
|
|
const b64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
|
_DATARSA_PUB_PEM = '-----BEGIN PUBLIC KEY-----\n' + b64.match(/.{1,64}/g).join('\n') + '\n-----END PUBLIC KEY-----';
|
|
return _DATARSA_PRIV;
|
|
}
|
|
|
|
// 用会话 AES 密钥解密后端响应 { iv, ct, tag } -> object
|
|
async function aesDecrypt(payload) {
|
|
if (!payload || typeof payload !== 'object' || !('ct' in payload)) return payload; // 明文兜底
|
|
const key = _SESSION_AES;
|
|
if (!key) throw new Error('会话密钥缺失');
|
|
const iv = Uint8Array.from(atob(payload.iv), (c) => c.charCodeAt(0));
|
|
const ct = Uint8Array.from(atob(payload.ct), (c) => c.charCodeAt(0));
|
|
const tag = Uint8Array.from(atob(payload.tag), (c) => c.charCodeAt(0));
|
|
const buf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, concatBytes(ct, tag));
|
|
return JSON.parse(new TextDecoder().decode(buf));
|
|
}
|
|
function concatBytes(a, b) {
|
|
const o = new Uint8Array(a.length + b.length);
|
|
o.set(a, 0); o.set(b, a.length); return o;
|
|
}
|
|
|
|
// 用 dataRSA 私钥解密后端返回的密码密文
|
|
async function dataRsaDecrypt(b64) {
|
|
if (!b64) return '';
|
|
if (!_DATARSA_PRIV) throw new Error('数据密钥缺失');
|
|
const ct = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
const buf = await crypto.subtle.decrypt({ name: 'RSA-OAEP', hash: 'SHA-256' }, _DATARSA_PRIV, ct);
|
|
return new TextDecoder().decode(buf);
|
|
}
|
|
|
|
async function api(url, opts) {
|
|
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts));
|
|
const raw = await res.json().catch(() => ({}));
|
|
let data = (raw && raw.ct) ? await aesDecrypt(raw) : raw; // 自动解密通道加密响应
|
|
if (!res.ok) {
|
|
const err = new Error(data.error || '请求失败');
|
|
err.code = data.code;
|
|
throw err;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// 复制(不把内容写进日志)
|
|
async function copy(text) {
|
|
try { await navigator.clipboard.writeText(text); } catch (e) {}
|
|
}
|
|
|
|
// ===== 状态 =====
|
|
let GROUPS = []; // 扁平分组 [{ id, name, path, parent }]
|
|
let WRITABLE = false;
|
|
let activeGroup = null; // 分组 id;null = 全部
|
|
let activeId = null;
|
|
let currentPage = 1;
|
|
let dataKeyExpire = 0; // 动态数据密钥过期时间戳
|
|
let _CAPTCHA_ID = ''; // 当前验证码 id(登录时回传服务端校验)
|
|
|
|
// ===== 登录 =====
|
|
$('loginBtn').onclick = async () => {
|
|
$('loginErr').textContent = '';
|
|
try {
|
|
await ensureDataRsa(); // 生成本次会话的 dataRSA 密钥对
|
|
const enc = await rsaEncrypt($('appPw').value); // 门户口令用传输公钥加密
|
|
const sessionKey = await sessionKeyForUpload(); // 会话 AES 密钥 RSA 加密上传
|
|
const captcha = $('captchaInput') ? $('captchaInput').value : '';
|
|
const r = await api('/api/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enc, sessionKey, dataPubKey: _DATARSA_PUB_PEM, captcha, captchaId: _CAPTCHA_ID }),
|
|
});
|
|
WRITABLE = r.writable;
|
|
hide($('loginView')); show($('unlockView')); $('masterPw').focus();
|
|
} catch (e) { $('loginErr').textContent = e.message; refreshCaptcha(); }
|
|
};
|
|
|
|
// 验证码刷新:服务端返回 cid + svg
|
|
async function refreshCaptcha() {
|
|
const el = $('captchaImg');
|
|
if (!el) return;
|
|
try {
|
|
const res = await fetch('/api/captcha', { credentials: 'same-origin' });
|
|
const j = await res.json();
|
|
_CAPTCHA_ID = j.cid || '';
|
|
el.innerHTML = j.svg || '';
|
|
} catch (e) { el.innerHTML = ''; }
|
|
}
|
|
$('captchaImg') && ($('captchaImg').onclick = refreshCaptcha);
|
|
|
|
// ===== 解锁 =====
|
|
$('unlockBtn').onclick = async () => {
|
|
$('unlockErr').textContent = '';
|
|
try {
|
|
const enc = await rsaEncrypt($('masterPw').value); // 主密码用公钥加密后传输
|
|
const r = await api('/api/unlock', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enc }),
|
|
});
|
|
WRITABLE = r.writable;
|
|
dataKeyExpire = r.dataKeyExpire || 0;
|
|
await loadTree();
|
|
hide($('unlockView')); show($('mainView'));
|
|
$('dbName').textContent = (await api('/api/tree')).name || '密码库';
|
|
if (WRITABLE) show($('writableBadge'));
|
|
$('search').focus();
|
|
} catch (e) { $('unlockErr').textContent = e.message; }
|
|
};
|
|
|
|
// ===== 退出 =====
|
|
$('logoutBtn').onclick = async () => {
|
|
await api('/api/logout', { method: 'POST' }).catch(() => {});
|
|
location.reload();
|
|
};
|
|
|
|
// ===== 加载分组树 =====
|
|
async function loadTree() {
|
|
const r = await api('/api/tree');
|
|
GROUPS = r.groups || [];
|
|
renderGroups();
|
|
currentPage = 1;
|
|
await loadEntries();
|
|
}
|
|
|
|
// ===== 加载条目列表(分页,列表不含密码明文)=====
|
|
async function loadEntries() {
|
|
const q = $('search').value.trim();
|
|
const r = await api('/api/entries?group=' + encodeURIComponent(activeGroup || '') +
|
|
'&page=' + currentPage + '&q=' + encodeURIComponent(q));
|
|
const list = $('entryList');
|
|
list.innerHTML = '';
|
|
if (!r.items || r.items.length === 0) {
|
|
const li = document.createElement('li');
|
|
li.className = 'empty';
|
|
li.textContent = q ? '无匹配条目' : '该分组暂无条目';
|
|
list.appendChild(li);
|
|
} else {
|
|
r.items.forEach((i) => {
|
|
const li = document.createElement('li');
|
|
li.className = 'entry' + (i.id === activeId ? ' active' : '');
|
|
const t = document.createElement('span'); t.className = 't'; t.textContent = i.title || '(无标题)';
|
|
const u = document.createElement('span'); u.className = 'u'; u.textContent = i.username || '';
|
|
li.appendChild(t); li.appendChild(u);
|
|
li.onclick = () => openDetail(i.id); // 点条目 -> 拉详情(加密)弹窗
|
|
list.appendChild(li);
|
|
});
|
|
}
|
|
renderPager(r.page, r.total, r.pageSize);
|
|
}
|
|
|
|
// 分页条
|
|
function renderPager(page, total, pageSize) {
|
|
const pager = $('pager');
|
|
pager.innerHTML = '';
|
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
const prev = document.createElement('button'); prev.textContent = '上一页';
|
|
prev.disabled = page <= 1; prev.onclick = () => { currentPage = page - 1; loadEntries(); };
|
|
const next = document.createElement('button'); next.textContent = '下一页';
|
|
next.disabled = page >= totalPages; next.onclick = () => { currentPage = page + 1; loadEntries(); };
|
|
const info = document.createElement('span'); info.className = 'pinfo';
|
|
info.textContent = `第 ${page}/${totalPages} 页 · 共 ${total} 条`;
|
|
pager.appendChild(prev); pager.appendChild(info); pager.appendChild(next);
|
|
}
|
|
|
|
// ===== 搜索 =====
|
|
$('search').oninput = () => { currentPage = 1; loadEntries(); };
|
|
|
|
// ===== 渲染分组树 =====
|
|
function renderGroups() {
|
|
const tree = $('groupTree');
|
|
tree.innerHTML = '';
|
|
const all = document.createElement('div');
|
|
all.className = 'node' + (activeGroup === null ? ' active' : '');
|
|
all.textContent = '全部';
|
|
all.onclick = () => { activeGroup = null; renderGroups(); currentPage = 1; loadEntries(); };
|
|
tree.appendChild(all);
|
|
|
|
GROUPS.forEach((g) => {
|
|
const node = document.createElement('div');
|
|
node.className = 'node' + (activeGroup === g.id ? ' active' : '');
|
|
node.textContent = g.path;
|
|
node.onclick = () => { activeGroup = g.id; renderGroups(); currentPage = 1; loadEntries(); };
|
|
tree.appendChild(node);
|
|
});
|
|
}
|
|
|
|
// ===== 详情 / 编辑(弹窗,详情从服务端加密拉取)=====
|
|
async function openDetail(id) {
|
|
// 动态数据密钥过期检测:过期后需重新解锁
|
|
if (dataKeyExpire && Date.now() > dataKeyExpire) {
|
|
alert('动态数据密钥已过期,请重新解锁');
|
|
hide($('mainView')); show($('unlockView')); $('masterPw').focus();
|
|
return;
|
|
}
|
|
activeId = id;
|
|
let item;
|
|
try {
|
|
item = await api('/api/entry/' + encodeURIComponent(id)); // 含密码,加密返回
|
|
} catch (e) { $('detailErr') && ($('detailErr').textContent = e.message); return; }
|
|
const body = $('modalBody');
|
|
body.innerHTML = '';
|
|
|
|
const head = document.createElement('h2'); head.textContent = item.title || '(无标题)';
|
|
body.appendChild(head);
|
|
|
|
const rows = [
|
|
['分组', item.group],
|
|
['账号', item.username],
|
|
['密码', item.passwordCrypt, true], // 密文,前端 dataRSA 私钥解密后显示
|
|
['网址', item.url],
|
|
['备注', item.notes],
|
|
];
|
|
rows.forEach(([label, val, secret]) => {
|
|
const row = document.createElement('div'); row.className = 'row';
|
|
const l = document.createElement('span'); l.className = 'label'; l.textContent = label;
|
|
const v = document.createElement('span'); v.className = 'value';
|
|
if (secret && val) {
|
|
v.textContent = '•'.repeat(Math.min(val.length, 12)) || '';
|
|
let plain = null;
|
|
const toggle = document.createElement('button'); toggle.className = 'copy'; toggle.textContent = '显示';
|
|
toggle.onclick = async () => {
|
|
try {
|
|
if (v.dataset.shown === '1') { v.textContent = '•'.repeat(Math.min(val.length, 12)); toggle.textContent = '显示'; v.dataset.shown = '0'; }
|
|
else { plain = plain || await dataRsaDecrypt(val); v.textContent = plain; toggle.textContent = '隐藏'; v.dataset.shown = '1'; }
|
|
} catch (e) { v.textContent = '(解密失败)'; }
|
|
};
|
|
const c = document.createElement('button'); c.className = 'copy'; c.textContent = '复制';
|
|
c.onclick = async () => { try { const p = plain || await dataRsaDecrypt(val); copy(p || ''); } catch (e) {} };
|
|
row.appendChild(l); row.appendChild(v); row.appendChild(toggle); row.appendChild(c);
|
|
} else {
|
|
v.textContent = val || '';
|
|
const c = document.createElement('button'); c.className = 'copy'; c.textContent = '复制';
|
|
c.onclick = () => copy(val || '');
|
|
row.appendChild(l); row.appendChild(v); if (val) row.appendChild(c);
|
|
}
|
|
body.appendChild(row);
|
|
if (label === '网址' && val) {
|
|
const a = document.createElement('a'); a.href = val; a.target = '_blank'; a.rel = 'noopener';
|
|
a.textContent = '打开'; a.className = 'open';
|
|
v.appendChild(document.createTextNode(' ')); v.appendChild(a);
|
|
}
|
|
});
|
|
|
|
if (WRITABLE) {
|
|
const edit = document.createElement('button');
|
|
edit.textContent = '编辑此条目';
|
|
edit.className = 'ghost';
|
|
edit.onclick = () => showEditor(item);
|
|
body.appendChild(edit);
|
|
}
|
|
|
|
show($('modal'));
|
|
}
|
|
|
|
// ===== 编辑表单(仅可写模式,渲染于弹窗内)=====
|
|
function showEditor(item) {
|
|
const d = $('modalBody');
|
|
d.innerHTML = '';
|
|
const fields = ['title', 'username', 'password', 'url', 'notes'];
|
|
const labels = { title: '标题', username: '账号', password: '密码', url: '网址', notes: '备注' };
|
|
const inputs = {};
|
|
fields.forEach((f) => {
|
|
const wrap = document.createElement('div'); wrap.className = 'row';
|
|
const l = document.createElement('span'); l.className = 'label'; l.textContent = labels[f];
|
|
const inp = document.createElement(f === 'notes' ? 'textarea' : 'input');
|
|
if (f !== 'notes') inp.type = 'text';
|
|
inp.value = item[f] || '';
|
|
inputs[f] = inp;
|
|
wrap.appendChild(l); wrap.appendChild(inp); d.appendChild(wrap);
|
|
});
|
|
const save = document.createElement('button');
|
|
save.textContent = '保存';
|
|
save.onclick = async () => {
|
|
try {
|
|
const upd = {}; fields.forEach((f) => (upd[f] = inputs[f].value));
|
|
const r = await api('/api/entry/update', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: item.id, fields: upd }),
|
|
});
|
|
openDetail(item.id);
|
|
} catch (e) { alert(e.message); }
|
|
};
|
|
const cancel = document.createElement('button');
|
|
cancel.textContent = '取消'; cancel.className = 'ghost';
|
|
cancel.onclick = () => openDetail(item.id);
|
|
d.appendChild(save); d.appendChild(cancel);
|
|
}
|
|
|
|
// 回车提交
|
|
$('appPw').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('loginBtn').click(); });
|
|
$('masterPw').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('unlockBtn').click(); });
|
|
|
|
// ===== 审计日志 =====
|
|
// 打开独立审计页(支持筛选/分页/封堵查询),新标签页打开以保留当前会话
|
|
$('auditBtn').onclick = () => { window.open('/audit.html', '_blank'); };
|
|
// 主界面内也保留一个快速概览面板
|
|
$('auditBtn').addEventListener('contextmenu', async (ev) => {
|
|
ev.preventDefault();
|
|
try {
|
|
const r = await api('/api/audit?pageSize=50');
|
|
const list = $('auditList');
|
|
list.innerHTML = '';
|
|
(r.log || []).slice().reverse().forEach((e) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'row ' + (e.ok ? 'ok' : 'fail');
|
|
row.textContent = `${e.t} ${e.ip} ${e.method} ${e.path} ${e.code}`;
|
|
list.appendChild(row);
|
|
});
|
|
show($('auditPanel'));
|
|
} catch (e) { alert(e.message); }
|
|
});
|
|
$('auditClose').onclick = () => hide($('auditPanel'));
|
|
|
|
// ===== 详情弹窗关闭 =====
|
|
function closeModal() { hide($('modal')); $('modalBody').innerHTML = ''; }
|
|
$('modalClose').onclick = closeModal;
|
|
$('modal').addEventListener('click', (e) => { if (e.target === $('modal')) closeModal(); });
|
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('modal').classList.contains('hidden')) closeModal(); });
|
|
|
|
// 初始化:拉取首张验证码
|
|
refreshCaptcha();
|
|
|
|
// 启动时探测状态,已解锁则直接进主界面
|
|
(async () => {
|
|
try {
|
|
const s = await api('/api/status');
|
|
if (s.unlocked) {
|
|
WRITABLE = s.writable;
|
|
hide($('loginView')); hide($('unlockView')); show($('mainView'));
|
|
await loadTree();
|
|
$('dbName').textContent = s.name || '密码库';
|
|
if (WRITABLE) show($('writableBadge'));
|
|
} else if (s.authed) {
|
|
hide($('loginView')); show($('unlockView'));
|
|
}
|
|
} catch (e) {}
|
|
})();
|