feat: 初始化 kdbx-viewer 项目
实现服务端解密的 KeePass 网页查看器,包含登录门户口令与验证码、RSA+会话级 AES 加密通道、审计日志持久化、HTTPS 自动证书、Docker 部署配置及端到端测试。
这个提交包含在:
+177
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
// 审计日志独立页:登录后可访问,支持筛选/分页,数据来自服务端加密响应(会话通道解密)
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ===== 通道密钥(与主页共享会话)=====
|
||||
let _SESSION_AES = null;
|
||||
let _SESSION_AES_B64 = null;
|
||||
|
||||
function bufFromB64(b64) {
|
||||
const bin = atob(b64);
|
||||
const u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
|
||||
async function ensureChannel() {
|
||||
// 优先复用主页已协商并缓存的密钥(同会话,避免覆盖服务端密钥槽)
|
||||
try { _SESSION_AES_B64 = localStorage.getItem('sessAes'); } catch (e) {}
|
||||
if (_SESSION_AES_B64) {
|
||||
try {
|
||||
_SESSION_AES = await crypto.subtle.importKey('raw', bufFromB64(_SESSION_AES_B64), { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
// 否则自行协商:取传输公钥,生成 AES 密钥并 RSA 上传
|
||||
const r = await fetch('/api/pubkey').then((x) => x.json());
|
||||
const pubPem = r.pubkey;
|
||||
const b64 = pubPem.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, '').replace(/\s+/g, '');
|
||||
const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const pubKey = await crypto.subtle.importKey('spki', der, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
|
||||
const raw = crypto.getRandomValues(new Uint8Array(32));
|
||||
_SESSION_AES_B64 = btoa(String.fromCharCode(...raw));
|
||||
const enc = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, pubKey, new TextEncoder().encode(_SESSION_AES_B64));
|
||||
const encB64 = btoa(String.fromCharCode(...new Uint8Array(enc)));
|
||||
await fetch('/api/session/key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: encB64 }) });
|
||||
_SESSION_AES = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
try { localStorage.setItem('sessAes', _SESSION_AES_B64); } catch (e) {}
|
||||
}
|
||||
|
||||
// AES-256-GCM 解密 {iv, ct, tag}
|
||||
async function decryptPayload(p) {
|
||||
if (!p || typeof p !== 'object' || !p.ct) return p; // 明文兜底
|
||||
const iv = bufFromB64(p.iv);
|
||||
const tag = bufFromB64(p.tag);
|
||||
const ct = bufFromB64(p.ct);
|
||||
const key = await crypto.subtle.importKey('raw', bufFromB64(_SESSION_AES_B64), { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
// Web Crypto 约定:密文在前、认证标签在后(iv 已通过算法参数单独传入)
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, concat(ct, tag));
|
||||
return JSON.parse(new TextDecoder().decode(plain));
|
||||
}
|
||||
function concat(...arrs) {
|
||||
let len = 0; for (const a of arrs) len += a.length;
|
||||
const out = new Uint8Array(len); let o = 0;
|
||||
for (const a of arrs) { out.set(a, o); o += a.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
async function apiEnc(path) {
|
||||
const res = await fetch(path, { credentials: 'same-origin' });
|
||||
const p = await res.json();
|
||||
return decryptPayload(p);
|
||||
}
|
||||
|
||||
// ===== 渲染 =====
|
||||
let currentPage = 1;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function fmtTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return iso;
|
||||
return d.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function renderRow(e) {
|
||||
const tr = document.createElement('tr');
|
||||
if (!e.ok) tr.classList.add('row-fail');
|
||||
const ipTypeLabel = e.ipType === 'internal' ? '<span class="tag tag-internal">内网</span>'
|
||||
: e.ipType === 'external' ? '<span class="tag tag-external">外网</span>' : '-';
|
||||
tr.innerHTML = `
|
||||
<td>${fmtTime(e.t)}</td>
|
||||
<td class="mono">${esc(e.ip)}</td>
|
||||
<td>${ipTypeLabel}</td>
|
||||
<td class="mono" title="${esc(e.remote)}">${esc(e.remote)}</td>
|
||||
<td class="mono" title="${esc(e.xff)}">${esc(e.xff && e.xff !== '-' ? e.xff : '-')}</td>
|
||||
<td>${esc(e.method)}</td>
|
||||
<td class="mono">${esc(e.path)}</td>
|
||||
<td>${e.status || '-'}</td>
|
||||
<td class="code">${esc(e.code)}</td>
|
||||
<td class="ua" title="${esc(e.ua)}">${esc(e.ua)}</td>
|
||||
<td>${esc(e.detail || '')}</td>
|
||||
<td class="mono">${esc(e.sid)}</td>`;
|
||||
return tr;
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('pageSize', String(PAGE_SIZE));
|
||||
const ip = $('fIp').value.trim(); if (ip) params.set('ip', ip);
|
||||
const ipType = $('fIpType').value; if (ipType) params.set('iptype', ipType);
|
||||
const code = $('fCode').value; if (code) params.set('code', code);
|
||||
const method = $('fMethod').value; if (method) params.set('method', method);
|
||||
const pathF = $('fPath').value.trim(); if (pathF) params.set('path', pathF);
|
||||
if ($('fFail').checked) params.set('fail', '1');
|
||||
const from = $('fFrom').value; if (from) params.set('from', new Date(from).toISOString());
|
||||
const to = $('fTo').value; if (to) params.set('to', new Date(to).toISOString());
|
||||
try {
|
||||
const data = await apiEnc('/api/audit?' + params.toString());
|
||||
const body = $('auditBody');
|
||||
body.innerHTML = '';
|
||||
(data.log || []).forEach((e) => body.appendChild(renderRow(e)));
|
||||
$('pageInfo').textContent = '第 ' + (data.page || 1) + ' 页(每页 ' + PAGE_SIZE + ')';
|
||||
$('totalInfo').textContent = ' 命中 ' + (data.total || 0) + ' 条';
|
||||
// 基于索引显示全量日志规模,便于核对分页正确性
|
||||
const idx = await apiEnc('/api/audit/index').catch(() => null);
|
||||
if (idx) {
|
||||
const files = (idx.files || []).map((f) => `${f.file}[${f.startSeq}-${f.endSeq},${f.count}条]`).join(' ');
|
||||
$('indexInfo').textContent = ` 索引总条数 ${idx.total} | 文件分布: ${files}`;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('加载审计日志失败:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBlocks() {
|
||||
try {
|
||||
const data = await apiEnc('/api/audit/blocks');
|
||||
const box = $('blocksBox');
|
||||
const list = $('blocksList');
|
||||
list.innerHTML = '';
|
||||
if (data.blocks && data.blocks.length) {
|
||||
box.style.display = '';
|
||||
data.blocks.forEach((b) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span class="mono">${esc(b.ip)}</span> 失败 ${b.fails} 次,封锁至 ${fmtTime(b.expiresAt)}
|
||||
<button class="btn-ghost unblock" data-ip="${esc(b.ip)}">解封</button>`;
|
||||
list.appendChild(li);
|
||||
});
|
||||
list.querySelectorAll('.unblock').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
await fetch('/api/audit/unblock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip: btn.dataset.ip }) });
|
||||
loadBlocks();
|
||||
};
|
||||
});
|
||||
} else { box.style.display = 'none'; }
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ===== 事件 =====
|
||||
$('searchBtn').onclick = () => { currentPage = 1; load(); };
|
||||
$('resetBtn').onclick = () => {
|
||||
$('fIp').value = ''; $('fCode').value = ''; $('fMethod').value = ''; $('fPath').value = '';
|
||||
$('fFail').checked = false; $('fFrom').value = ''; $('fTo').value = '';
|
||||
currentPage = 1; load();
|
||||
};
|
||||
$('prevPage').onclick = () => { if (currentPage > 1) { currentPage--; load(); } };
|
||||
$('nextPage').onclick = () => { currentPage++; load(); };
|
||||
$('logoutBtn').onclick = async () => {
|
||||
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
location.href = '/';
|
||||
};
|
||||
|
||||
// ===== 启动 =====
|
||||
(async () => {
|
||||
// 鉴权检查
|
||||
try {
|
||||
const st = await fetch('/api/status', { credentials: 'same-origin' }).then((r) => r.json());
|
||||
if (!st.authed) { location.href = '/'; return; }
|
||||
} catch (e) { location.href = '/'; return; }
|
||||
await ensureChannel();
|
||||
await load();
|
||||
await loadBlocks();
|
||||
})();
|
||||
在新工单中引用
屏蔽一个用户