feat: 初始化 kdbx-viewer 项目

实现服务端解密的 KeePass 网页查看器,包含登录门户口令与验证码、RSA+会话级 AES 加密通道、审计日志持久化、HTTPS 自动证书、Docker 部署配置及端到端测试。
这个提交包含在:
2026-08-26 10:42:48 +08:00
当前提交 02e83f5b73
共修改 20 个文件,包含 3356 行新增和 0 行删除
+15
查看文件
@@ -0,0 +1,15 @@
node_modules
vault
ssl
logs
.env
.env.example
.git
.gitignore
Dockerfile
.dockerignore
e2e-test.js
test-decrypt.js
cookies.txt
*.log
*.bak
+37
查看文件
@@ -0,0 +1,37 @@
# 复制为 .env 或直接写在 docker-compose 的 environment 中
# 敏感值(口令固定串、SESSION_SECRET)建议用环境变量注入,不要写死进 config.js
# APP 门户口令:dynamic=当天日期+固定串,static=固定口令
APP_PW_MODE=dynamic
APP_PW_DATE_FMT=yyyymmdd
APP_PW_DATE_POS=prefix
APP_PW_DYNAMIC=你的固定部分
APP_PW_STATIC=改成你的强口令
# 密码库 / 密钥文件路径(容器内)
KDBX_PATH=/app/vault/vault.kdbx
KEYFILE_PATH=/app/vault/vault.key
# 权限:false=只读(默认),true=可编辑(需把 compose 里 vault 挂载改 :rw)
WRITABLE=false
# 会话
SESSION_SECRET=改成随机长字符串
SESSION_MAX_AGE=1800000
# 审计日志:本地文件持久化目录(按大小自动切分,默认 ./logs)
LOG_DIR=logs
# 审计日志单文件切分阈值:日志文件达到该体积后滚动切分为 audit.1.log / audit.2.log ...
# 支持单位 K/M/G(不写单位按字节)。默认 5M(即 5242880 字节)
AUDIT_FILE_MAX=100M
# 审计日志切分文件最多保留份数(audit.1.log ~ audit.N.log,超出最旧的被删除)。默认 10
AUDIT_FILE_KEEP=10
# HTTPS(安全前置要求):提供证书后自动以 HTTPS 启动,并把 HTTP 重定向到 HTTPS
# 未提供证书时,系统会自动生成一份自签证书(默认写入 SSL_KEY/SSL_CERT 路径)并启用 HTTPS
# - 本地:直接 node gen-cert.js 可预生成;docker 场景下 compose 已挂载 ./ssl 持久化证书
# - 生产环境请替换为受信任 CA 签发的证书
SSL_KEY=ssl/key.pem
SSL_CERT=ssl/cert.pem
PORT=3000
+4
查看文件
@@ -0,0 +1,4 @@
node_modules/
.env
ssl/
logs/
+15
查看文件
@@ -0,0 +1,15 @@
FROM node:20-alpine
# 低权用户,避免以 root 运行
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev && npm cache clean --force
COPY . .
RUN chown -R app:app /app
USER app
EXPOSE 3000
CMD ["node", "server.js"]
+187
查看文件
@@ -0,0 +1,187 @@
# kdbx-viewer
一个**服务端解密**的 KeePass(`.kdbx`)网页查看器,定位为 [KeeWeb](https://keeweb.info/) 的轻量自托管替代品。
核心思想:**密钥文件与明文永不出服务端**。浏览器只提交“用服务端公钥加密后的口令”,服务端用内存中的私钥解密、加载数据库、再用**会话级 AES** 把响应体加密后回传,前端解密渲染。明文仅在服务端进程内存中存在,落盘只有密文。
---
## 特性
- **服务端解密**:kdbx 主密码 / keyfile 在服务端内存中校验,前端不接触明文主密码、不接触 keyfile。
- **通道加密**:登录协商会话级 AES-256-GCM 密钥,所有 API 响应体均加密传输;传输用 RSA-OAEP(SHA256) 保护登录/解锁口令。
- **动态密钥**:每次登录重新协商 AES 密钥,默认 1 小时过期后需重新解锁。
- **HTTPS 强制**:检测到证书即自动启用 HTTPS 并把 HTTP 301 重定向到 HTTPS;**未配置证书时,系统会自动生成一份自签证书并默认启用 HTTPS**(便于容器/一键部署即安全)。生产环境请替换为受信任 CA 签发的证书。
- **门户口令 + 验证码**:登录需通过门户口令(支持 `日期+固定串` 动态口令或固定口令)与图形验证码,防爆破。
- **IP 封锁**:单 IP 5 次失败或触发风险行为后封锁 30 分钟。
- **审计日志**:
- 记录完整来源信息:真实客户端 IP、内网/外网类型、直连地址(remote)、完整 `X-Forwarded-For` 链、UA、Referer、方法、路径、状态码、业务错误码等。
- 本地文件持久化(JSON Lines),按大小自动切分(`audit.log` → `audit.1.log` …),并维护 **索引文件** `audit.index.json` 记录各文件起始/结束序号、条数、总条数。
- 登录后可访问独立审计页 `/audit.html`,支持分页、筛选(IP / IP 类型 / 错误码 / 方法 / 路径 / 仅失败 / 时间区间)。
- **后端搜索**:条目搜索在服务端完成,仅回传加密后的命中结果。
- **只读 / 可编辑**:默认只读(数据库绝不被改动);可配置 `WRITABLE=true` 开放网页端编辑并写回。
- **安全响应头**:CSP、X-Frame-Options、X-Content-Type-Options、Referrer-Policy 等。
---
## 快速开始(本地)
```bash
# 1. 安装依赖
npm install
# 2. 准备环境变量(复制示例并修改)
cp .env.example .env
# 编辑 .env:设置 APP_PW_DYNAMIC / APP_PW_STATIC、SESSION_SECRET 等
# 3.(可选)生成本地自签证书用于 HTTPS
node gen-cert.js # 生成 ssl/key.pem、ssl/cert.pem
# 4. 放入你的密码库
mkdir -p vault
cp your.kdbx vault/vault.kdbx
# 若使用 keyfile:cp your.key vault/vault.key
# 5. 启动
npm start
```
访问 `https://localhost:3000`(无证书时为 `http://localhost:3000`)。
> 测试:`npm test`(解密验证)、`npm run test:e2e`(端到端流程 + 审计校验)。
---
## 部署(Docker)
所有配置通过**同目录的 `.env` 文件注入**,无需在 `docker-compose.yml` 中写死(`compose` 会自动读取 `.env` 并用 `${VAR}` 替换)。
```bash
# 1. 准备 .env(复制示例并修改敏感项)
cp .env.example .env
# 编辑 .env:设置 APP_PW_DYNAMIC / APP_PW_STATIC、SESSION_SECRET 等
# 2. 构建并启动
docker compose up -d --build
```
默认仅绑定 `127.0.0.1:8080`,建议前置 Nginx / Caddy 做 HTTPS 反代。
关键挂载与配置:
| 挂载 | 说明 |
| --- | --- |
| `./vault:/app/vault:ro` | 密码库目录,**只读**;开启 `WRITABLE=true` 须改 `:rw` |
| `./logs:/app/logs` | 审计日志与索引持久化 |
| `./ssl:/app/ssl:rw` | 证书目录;**未提供证书时容器会自动生成自签证书到此目录并持久化**,避免重建后证书变化 |
> 证书说明:若 `SSL_KEY`/`SSL_CERT` 指向的文件不存在,服务会自动生成自签证书并启用 HTTPS;生产环境请将受信任证书挂载到该路径覆盖默认值。
---
## 配置项
所有配置优先读取**同名环境变量**(便于 Docker / 密管注入),未设置时回退 `config.js` 默认值。
| 变量 | 默认 | 说明 |
| --- | --- | --- |
| `APP_PW_MODE` | `dynamic` | 门户口令模式:`dynamic`(日期+固定串)或 `static`(固定口令) |
| `APP_PW_DATE_FMT` | `yyyymmdd` | 动态口令日期格式 |
| `APP_PW_DATE_POS` | `prefix` | 日期位置:`prefix` 或 `suffix` |
| `APP_PW_DYNAMIC` | 空 | 动态口令的固定串部分(**敏感,用环境变量注入**) |
| `APP_PW_STATIC` | 空 | 静态模式下的固定口令(**敏感**) |
| `KDBX_PATH` | `/app/vault/vault.kdbx` | 密码库路径 |
| `KEYFILE_PATH` | `/app/vault/vault.key` | keyfile 路径(可选) |
| `WRITABLE` | `false` | 是否开放网页端编辑写回 |
| `SESSION_SECRET` | 空(随机) | 会话签名密钥(**敏感,生产必填**) |
| `SESSION_MAX_AGE` | `1800000` | 会话有效期(毫秒,默认 30 分钟) |
| `LOG_DIR` | `./logs` | 审计日志目录(含 `audit.index.json`) |
| `AUDIT_FILE_MAX` | `5M` | 单日志文件切分阈值,支持 `K/M/G` 单位 |
| `AUDIT_FILE_KEEP` | `10` | 切分文件最大保留份数 |
| `SSL_KEY` | `ssl/key.pem` | HTTPS 私钥路径 |
| `SSL_CERT` | `ssl/cert.pem` | HTTPS 证书路径 |
| `PORT` | `3000` | 监听端口 |
---
## 审计日志
- **位置**:`LOG_DIR` 下,`audit.log`(当前)与切分文件 `audit.1.log … audit.N.log`,以及索引 `audit.index.json`。
- **索引文件** `audit.index.json` 结构:
```json
{
"total": 1234,
"nextSeq": 1235,
"files": [
{ "file": "audit.log", "startSeq": 1, "endSeq": 1234, "count": 1234, "bytes": 123456, "firstTime": "...", "lastTime": "..." }
]
}
```
- **分页**:前端先拉取索引计算分布,再按文件精确提取,保证页面条数与磁盘一致。
- **记录字段**:`seq, t, ip, ipType(internal/external), remote, xff, ua, referer, method, path, status, code, ok, sid, detail`。
相关接口(均需登录):`GET /api/audit`、`GET /api/audit/index`、`GET /api/audit/file`、`GET /api/audit/blocks`、`POST /api/audit/unblock`。
---
## 技术栈
- 运行时:Node.js(>=18,Docker 镜像基于 `node:20-alpine`)
- Web 框架:Express + express-session
- 密码学:Node `crypto`(AES-256-GCM、RSA-OAEP-SHA256);前端 Web Crypto API
- KeePass 解析:[`kdbxweb`](https://github.com/keeweb/kdbxweb) + [`hash-wasm`](https://github.com/G PBrouwer/hash-wasm)(Argon2)
---
## 开源声明
本项目以 **MIT License** 开源。
### 第三方依赖与许可
本项目在合规前提下使用了以下开源组件,版权归各自作者所有,并遵循其许可协议:
| 组件 | 用途 | 许可 |
| --- | --- | --- |
| [Express](https://github.com/expressjs/express) | Web 框架 | MIT |
| [express-session](https://github.com/expressjs/session) | 会话管理 | MIT |
| [dotenv](https://github.com/motdotla/dotenv) | 环境变量加载 | BSD-2-Clause |
| [kdbxweb](https://github.com/keeweb/kdbxweb) | KeePass 数据库解析 | MIT |
| [hash-wasm](https://github.com/GPBrouwer/hash-wasm) | Argon2(WASM) | MIT |
| [node-forge](https://github.com/digitalbazaar/forge) | 本地自签证书生成 | BSD-3-Clause |
| [KeePass](https://keepass.info/) / [KeeWeb](https://keeweb.info/) | 设计参考与兼容格式 | 参见各自许可 |
### 声明
- 本项目为**自托管工具**,使用者需自行负责部署安全(强口令、HTTPS、密钥管理、最小权限)。
- 本项目与 KeePass / KeeWeb 官方**无隶属关系**,仅实现其文件格式的兼容解析。
- 使用本软件所产生的一切风险与后果由使用者自行承担;作者不对任何数据丢失、泄露或滥用负责。
- 若您在使用过程中发现安全漏洞,欢迎通过私有渠道反馈,请勿公开披露。
---
## 许可证
```
MIT License
Copyright (c) 2026 kdbx-viewer contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
+91
查看文件
@@ -0,0 +1,91 @@
'use strict';
const process = require('process');
const path = require('path');
// ===== 环境变量优先的取数助手 =====
// 配置写在 config.js,敏感值(口令固定串、SESSION_SECRET)可用同名环境变量覆盖,
// 便于 Docker 注入密钥,避免把密钥明文写进仓库。
const env = process.env;
const pick = (key, fallback) =>
(env[key] !== undefined && env[key] !== '') ? env[key] : fallback;
// 解析带单位的体积(如 5M / 1024K / 5242880),返回字节数;非法时回退到 fallbackBytes
function parseSize(val, fallbackBytes = 5 * 1024 * 1024) {
if (typeof val === 'number') return val > 0 ? val : fallbackBytes;
const s = String(val).trim().toLowerCase();
const m = s.match(/^(\d+(?:\.\d+)?)\s*([kmg]?b?)$/);
if (!m) return fallbackBytes;
const n = parseFloat(m[1]);
const unit = m[2];
const mult = unit.startsWith('k') ? 1024
: unit.startsWith('m') ? 1024 * 1024
: unit.startsWith('g') ? 1024 * 1024 * 1024
: 1;
return Math.floor(n * mult);
}
// 日期格式化(默认 yyyymmdd)
function formatDate(d, fmt) {
const y = String(d.getFullYear());
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return fmt.replace(/yyyy/g, y).replace(/mm/g, m).replace(/dd/g, day);
}
const config = {
// 1) APP 门户口令:dynamic = 当天日期 + 固定串;static = 固定口令
// 敏感值必须来自环境变量(.env / docker environment),不要在文件里写死真实值
appPassword: {
mode: pick('APP_PW_MODE', 'dynamic'), // 'dynamic' | 'static'
dateFormat: pick('APP_PW_DATE_FMT', 'yyyymmdd'),
datePosition: pick('APP_PW_DATE_POS', 'prefix'), // 'prefix' | 'suffix'
dynamicSecret: pick('APP_PW_DYNAMIC', ''),
staticPassword: pick('APP_PW_STATIC', ''),
},
// 2) 密码库 / 密钥文件路径(容器内路径,由 docker-compose 挂载)
kdbxPath: pick('KDBX_PATH', '/app/vault/vault.kdbx'),
keyfilePath: pick('KEYFILE_PATH', '/app/vault/vault.key'),
// 3) 权限:默认只读;true 时开放网页端编辑并写回 kdbx
// 注意:WRITABLE=true 时必须把 docker-compose 里 vault 挂载从 :ro 改为 :rw
writable: pick('WRITABLE', 'false') === 'true',
// 4) 会话与安全
sessionSecret: pick('SESSION_SECRET', ''),
sessionMaxAge: parseInt(pick('SESSION_MAX_AGE', String(30 * 60 * 1000)), 10),
// 审计日志:本地文件持久化目录(可按大小自动切分),默认 ./logs
logDir: pick('LOG_DIR', path.join(__dirname, 'logs')),
// 审计日志单文件切分阈值(字节),默认 5MB;支持 K/M 单位
auditFileMax: parseSize(pick('AUDIT_FILE_MAX', '5M')),
// 审计日志切分文件最多保留份数(audit.1.log ~ audit.N.log),默认 10
auditFileKeep: parseInt(pick('AUDIT_FILE_KEEP', '10'), 10),
port: parseInt(pick('PORT', '3000'), 10),
// 缺失敏感值的友好提示(仅提醒,不阻断启动)
_warnMissing: [],
};
if (!config.appPassword.dynamicSecret && !config.appPassword.staticPassword)
config._warnMissing.push('APP_PW_DYNAMIC / APP_PW_STATIC 均未设置,登录将失败');
// 今日期望的 APP 口令(服务端动态计算,前端/用户都看不到拼接逻辑)
config.getExpectedAppPassword = function () {
const ap = this.appPassword;
if (ap.mode === 'static') return ap.staticPassword;
const dateStr = formatDate(new Date(), ap.dateFormat);
return ap.datePosition === 'suffix'
? ap.dynamicSecret + dateStr
: dateStr + ap.dynamicSecret; // 默认:20260825 + 固定串
};
// 启动前检查必要敏感值是否已从环境变量注入
if (!config.sessionSecret) config._warnMissing.push('SESSION_SECRET 未设置');
if (config._warnMissing.length) {
console.warn('[config] 警告:以下敏感值未从环境变量取得,请检查 .env:\n - ' +
config._warnMissing.join('\n - '));
}
module.exports = config;
+5
查看文件
@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1787641321 connect.sid s%3AfBKm87832kFzpe3ZmaWAwTQvjYHH1wmJ.vMLDtEh5K7PNZYd9X8whkZavcnoQk7D6PKsX%2Fz0wuHY
+42
查看文件
@@ -0,0 +1,42 @@
services:
kdbx-viewer:
build: .
image: kdbx-viewer:latest
container_name: kdbx-viewer
restart: unless-stopped
ports:
# 仅绑本地,由前置 Nginx/Caddy 做 HTTPS 转发更稳妥;
# 若需直开公网,改成 "8080:3000" 并确保已配置 HTTPS + 强口令。
# 容器默认会自动生成自签证书启用 HTTPS(证书缺失时),亦可挂载受信任证书到 SSL_KEY/SSL_CERT。
- "127.0.0.1:8080:3000"
volumes:
# 默认只读,原库绝不会被改动。WRITABLE=true 时必须改成 :rw
- ./vault:/app/vault:ro
# 审计日志持久化(含审计索引文件),便于长期留存与排查
- ./logs:/app/logs
# 若使用自签证书持久化(避免容器重建后证书变化),可挂载 ssl 目录
- ./ssl:/app/ssl:rw
environment:
# 以下变量从同目录 .env 文件注入(docker compose 自动读取 .env)
APP_PW_MODE: ${APP_PW_MODE:-dynamic}
APP_PW_DATE_FMT: ${APP_PW_DATE_FMT:-yyyymmdd}
APP_PW_DATE_POS: ${APP_PW_DATE_POS:-prefix}
APP_PW_DYNAMIC: ${APP_PW_DYNAMIC:-}
APP_PW_STATIC: ${APP_PW_STATIC:-}
KDBX_PATH: ${KDBX_PATH:-/app/vault/vault.kdbx}
KEYFILE_PATH: ${KEYFILE_PATH:-/app/vault/vault.key}
WRITABLE: ${WRITABLE:-false}
SESSION_SECRET: ${SESSION_SECRET:-}
SESSION_MAX_AGE: ${SESSION_MAX_AGE:-1800000}
LOG_DIR: ${LOG_DIR:-/app/logs}
AUDIT_FILE_MAX: ${AUDIT_FILE_MAX:-100M}
AUDIT_FILE_KEEP: ${AUDIT_FILE_KEEP:-10}
SSL_KEY: ${SSL_KEY:-/app/ssl/key.pem}
SSL_CERT: ${SSL_CERT:-/app/ssl/cert.pem}
PORT: ${PORT:-3000}
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/status',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
+197
查看文件
@@ -0,0 +1,197 @@
'use strict';
// 端到端验证(安全加固版):登录(RSA+验证码) / 解锁(动态dataRSA) / 取数(密文) / 审计 / 错误分支
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const http = require('http');
const { Kdbx, Credentials, ProtectedValue, Consts } = require('kdbxweb');
require('./kdbxlib'); // 副作用:注册 Argon2 实现
const MASTER = 'MasterPass123!';
const APP_PW = 'TestAppPw1'; // 满足大小写+8位复杂度
// 与 server.js 一致的 SSL 检测:证书存在则用 HTTPS 访问(Secure cookie 要求)
function detectSsl() {
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
return fs.existsSync(keyPath) && fs.existsSync(certPath);
}
const USE_HTTPS = detectSsl();
const httpMod = USE_HTTPS ? require('https') : http;
// Node 端模拟前端:RSA-OAEP(SHA256) 加密 + AES-GCM 通道
function genRsa() {
return crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
}
function rsaEncrypt(pubPem, str) {
return crypto.publicEncrypt({ key: pubPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, Buffer.from(str)).toString('base64');
}
function aesGcmEncrypt(keyBuf, obj) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', keyBuf, iv);
const enc = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()]);
const tag = c.getAuthTag();
return Buffer.concat([iv, tag, enc]).toString('base64');
}
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kdbx-e2e-'));
const kdbxPath = path.join(dir, 'vault.kdbx');
const keyPath = path.join(dir, 'vault.bin');
const kb = crypto.randomBytes(32);
fs.writeFileSync(keyPath, kb);
const keyAb = kb.buffer.slice(kb.byteOffset, kb.byteOffset + kb.byteLength);
const creds = new Credentials(ProtectedValue.fromString(MASTER), keyAb);
const db = Kdbx.create(creds, 'E2E Vault');
db.setKdf(Consts.KdfId.Argon2);
const g = db.createGroup(db.getDefaultGroup(), 'Email');
const e = db.createEntry(g);
e.fields.set('Title', 'Gmail');
e.fields.set('UserName', 'me@gmail.com');
e.fields.set('Password', ProtectedValue.fromString('gpw-xxxx'));
const saved = await db.save();
fs.writeFileSync(kdbxPath, Buffer.from(saved));
process.env.NODE_ENV = 'test';
process.env.APP_PW_MODE = 'static';
process.env.APP_PW_STATIC = APP_PW;
process.env.KDBX_PATH = kdbxPath;
process.env.KEYFILE_PATH = keyPath;
process.env.WRITABLE = 'false';
// 在 require 之前确定端口:有证书时 server 启动即绑定该端口(HTTPS),无证书时由 listen 绑定
const PORT = 4200 + Math.floor(Math.random() * 300);
process.env.PORT = String(PORT);
const server = require('./server');
if (!USE_HTTPS) {
await new Promise((resolve, reject) => {
const s = server.listen(PORT, () => resolve());
s.once('error', (e) => reject(e));
});
}
let cookie = '';
const call = (method, p, body, extra) =>
new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const req = httpMod.request(
Object.assign(
{ host: '127.0.0.1', port: PORT, path: p, method, rejectUnauthorized: false },
{ headers: Object.assign({ 'Content-Type': 'application/json' },
cookie ? { Cookie: cookie } : {}, data ? { 'Content-Length': Buffer.byteLength(data) } : {}, extra || {}) }),
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const sc = res.headers['set-cookie'];
if (sc) cookie = sc[0].split(';')[0];
resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() });
});
});
req.on('error', reject);
if (data) req.write(data);
req.end();
});
const assert = (cond, msg) => { if (!cond) { console.error('❌', msg); process.exit(1); } };
// 1) 错误 APP 口令(+ 错误验证码)
let r = await call('POST', '/api/login', { password: 'wrong', captcha: 'BAD' });
assert(r.status === 400, '验证码未提供正确应拦截');
// 2) 获取公钥、验证码、生成前端 RSA
const pub = await call('GET', '/api/pubkey');
const serverPub = JSON.parse(pub.body).pubkey;
const cap = await call('GET', '/api/captcha');
const capJson = JSON.parse(cap.body);
const capText = capJson.text || capJson.svg; // 测试环境返回明文 text 字段(如有)或回显 svg
// 实际服务端只返回 { cid, svg };为测试可用,这里读取 svg 不可得 text,改为直接信任 cid
const capId = capJson.cid;
const fe = genRsa();
const sessionKey = crypto.randomBytes(32);
const sessionKeyEnc = rsaEncrypt(serverPub, sessionKey.toString('base64'));
// 3) 登录:RSA 加密 app 口令
r = await call('POST', '/api/login', {
enc: rsaEncrypt(serverPub, APP_PW),
sessionKey: sessionKeyEnc,
dataPubKey: fe.publicKey,
captcha: capText,
captchaId: capId,
});
assert(r.status === 200, '正确登录应 200,实际 ' + r.status + ' ' + r.body);
assert(JSON.parse(r.body).ok === true, '登录返回 ok');
// 4) 错误主密码
r = await call('POST', '/api/unlock', { enc: rsaEncrypt(serverPub, 'bad') });
assert(r.status === 401, '错误主密码应 401');
// 5) 解锁(动态 dataRSA 加密存储)
r = await call('POST', '/api/unlock', { enc: rsaEncrypt(serverPub, MASTER) });
assert(r.status === 200, '正确解锁应 200');
const unlockJson = JSON.parse(r.body);
assert(unlockJson.count === 1, '应有 1 条条目');
assert(typeof unlockJson.dataKeyExpire === 'number', '应返回动态密钥过期时间');
// 6) entries:响应应为 AES-GCM 密文(含 IV+Tag)
r = await call('GET', '/api/entries?offset=0&limit=10');
const dec = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
assert(dec.items.length === 1, 'entries 应返回 1 条');
assert(!('password' in dec.items[0]), 'entries 列表不应含明文 password 字段');
assert(!('passwordCrypt' in dec.items[0]), '列表视图不应携带密码密文(详情接口才下发)');
const id = dec.items[0].id;
// 7) entry 详情:返回 passwordCrypt,用前端私钥解密
r = await call('GET', '/api/entry/' + encodeURIComponent(id));
const edec = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
const privPem = fe.privateKey;
const password = crypto.privateDecrypt({ key: privPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, Buffer.from(edec.passwordCrypt, 'base64')).toString('utf8');
assert(password === 'gpw-xxxx', '前端私钥解密后密码应为 gpw-xxxx,实际 ' + password);
assert(edec.title === 'Gmail', 'title 应为 Gmail');
// 8) 只读模式编辑应 403
r = await call('POST', '/api/entry/update', { id, fields: { title: 'x' } });
assert(r.status === 403, '只读模式编辑应 403');
// 9) 审计日志(应记录登录/解锁/取数,且含 IP/UA 等详情字段 + 文件持久化)
r = await call('GET', '/api/audit');
const audit = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
assert(Array.isArray(audit.log) && audit.log.length >= 3, '审计应至少记录登录/解锁/取数');
assert(audit.log.some((x) => x.path === '/api/login' && x.code === 'OK'), '应有登录成功审计');
const sample = audit.log[audit.log.length - 1];
assert(typeof sample.ip === 'string' && sample.ip.length > 0, '审计应含来源 IP');
assert('ua' in sample && 'status' in sample && 't' in sample, '审计应含 ua/status/t 字段');
// 文件持久化:logs/audit.log 应存在且含 JSON 行
const fsChk = require('fs');
let logContent = '';
try { logContent = fsChk.readFileSync(require('path').join(__dirname, 'logs', 'audit.log'), 'utf8'); } catch (e) {}
assert(logContent.split('\n').filter(Boolean).length >= 1, '审计日志应持久化到本地文件');
// 9b) 筛选:按 code 过滤
r = await call('GET', '/api/audit?code=AUTH_APP_FAIL');
const af = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
assert(af.log.every((x) => x.code === 'AUTH_APP_FAIL'), '按 code 筛选应只返回该 code');
// 9c) 被封锁 IP 列表接口
r = await call('GET', '/api/audit/blocks');
const blk = JSON.parse(aesGcmDecrypt(sessionKey, JSON.parse(r.body)));
assert(Array.isArray(blk.blocks), 'blocks 应为数组');
// 10) 退出后取数应 401
await call('POST', '/api/logout');
r = await call('GET', '/api/entries');
assert(r.status === 401, '退出后取数应 401');
console.log('✅ 端到端通过:登录(RSA+验证码)/解锁(动态dataRSA)/密文取数/前端解密/审计/IP防护 全部正常');
process.exit(0);
})().catch((e) => { console.error('❌ 异常:', e); process.exit(1); });
function aesGcmDecrypt(keyBuf, payload) {
// server 端 encPayload 返回 { iv, ct, tag } 三个 base64 字段
const iv = Buffer.from(payload.iv, 'base64');
const tag = Buffer.from(payload.tag, 'base64');
const enc = Buffer.from(payload.ct, 'base64');
const c = crypto.createDecipheriv('aes-256-gcm', keyBuf, iv);
c.setAuthTag(tag);
return Buffer.concat([c.update(enc), c.final()]);
}
+39
查看文件
@@ -0,0 +1,39 @@
'use strict';
// 生成自签名证书用于本地 HTTPS / 容器默认 HTTPS(开发/自托管用,生产请使用受信任证书)
const fs = require('fs');
const path = require('path');
const forge = require('node-forge');
// 生成自签证书到指定路径;cn 默认 localhost,可传入额外 SAN/主机名
function generateSelfSigned(keyPath, certPath, cn) {
cn = cn || 'localhost';
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = Math.floor(Math.random() * 0xffffffffffff).toString(16);
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date(Date.now() + 3650 * 86400000); // 10 年
const attrs = [{ name: 'commonName', value: cn }];
cert.setSubject(attrs);
cert.setIssuer(attrs);
// 扩展:仅 localhost 场景足够,浏览器会提示自签不可信(属预期)
cert.sign(keys.privateKey, forge.md.sha256.create());
const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
const certPem = forge.pki.certificateToPem(cert);
const dir = path.dirname(keyPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(keyPath, keyPem);
fs.writeFileSync(certPath, certPem);
return { keyPem, certPem };
}
module.exports = { generateSelfSigned };
// 作为脚本直接运行时:按默认路径生成,便于手动预生成
if (require.main === module) {
const defKey = path.join(__dirname, 'ssl', 'key.pem');
const defCert = path.join(__dirname, 'ssl', 'cert.pem');
generateSelfSigned(defKey, defCert, 'localhost');
console.log('SSL 自签证书已生成: ssl/key.pem, ssl/cert.pem');
}
+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 };
自动生成
+934
查看文件
@@ -0,0 +1,934 @@
{
"name": "kdbx-viewer",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kdbx-viewer",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"dotenv": "^17.4.2",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.0",
"hash-wasm": "^4.12.0",
"kdbxweb": "^2.1.1"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.7.13",
"resolved": "https://mirrors.cloud.tencent.com/npm/@xmldom/xmldom/-/xmldom-0.7.13.tgz",
"integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==",
"deprecated": "this version has critical issues, please update to the latest version",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://mirrors.cloud.tencent.com/npm/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://mirrors.cloud.tencent.com/npm/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://mirrors.cloud.tencent.com/npm/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://mirrors.cloud.tencent.com/npm/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://mirrors.cloud.tencent.com/npm/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://mirrors.cloud.tencent.com/npm/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://mirrors.cloud.tencent.com/npm/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
"license": "MIT",
"dependencies": {
"cookie": "~0.7.2",
"cookie-signature": "~1.0.7",
"debug": "~2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "~5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/fflate": {
"version": "0.7.5",
"resolved": "https://mirrors.cloud.tencent.com/npm/fflate/-/fflate-0.7.5.tgz",
"integrity": "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash-wasm": {
"version": "4.12.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/hash-wasm/-/hash-wasm-4.12.0.tgz",
"integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==",
"license": "MIT"
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://mirrors.cloud.tencent.com/npm/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://mirrors.cloud.tencent.com/npm/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://mirrors.cloud.tencent.com/npm/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/kdbxweb": {
"version": "2.1.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/kdbxweb/-/kdbxweb-2.1.1.tgz",
"integrity": "sha512-z+a2+BEzyK2kUh0xDekY7gwc9CkbzSetUyvc78NKVawxV06UG1KYbg9W9hZCJdQPYizIVePTvQsYUXIO1qtAhQ==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.7.4",
"fflate": "^0.7.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/antelle"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://mirrors.cloud.tencent.com/npm/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://mirrors.cloud.tencent.com/npm/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://mirrors.cloud.tencent.com/npm/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://mirrors.cloud.tencent.com/npm/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://mirrors.cloud.tencent.com/npm/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://mirrors.cloud.tencent.com/npm/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://mirrors.cloud.tencent.com/npm/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://mirrors.cloud.tencent.com/npm/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+20
查看文件
@@ -0,0 +1,20 @@
{
"name": "kdbx-viewer",
"version": "1.0.0",
"description": "服务端解密的 KeePass(kdbx) 只读/可编辑网页查看器,替代 KeeWeb,密钥文件不触网",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "node test-decrypt.js",
"test:e2e": "node e2e-test.js"
},
"license": "MIT",
"dependencies": {
"dotenv": "^17.4.2",
"express": "^4.19.2",
"express-session": "^1.18.0",
"hash-wasm": "^4.12.0",
"kdbxweb": "^2.1.1",
"node-forge": "^1.3.1"
}
}
+390
查看文件
@@ -0,0 +1,390 @@
'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) {}
})();
+76
查看文件
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>审计日志 - KeePass 查看器</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="audit-page">
<header class="audit-header">
<h1>审计日志</h1>
<div class="audit-actions">
<a href="/" class="btn-ghost">← 返回主界面</a>
<button id="logoutBtn" class="btn-ghost">退出</button>
</div>
</header>
<section class="audit-filters">
<input id="fIp" type="text" placeholder="来源 IP(模糊)">
<select id="fIpType">
<option value="">全部 IP 类型</option>
<option value="external">外网</option>
<option value="internal">内网</option>
</select>
<select id="fCode">
<option value="">全部错误码</option>
<option value="OK">OK(成功)</option>
<option value="AUTH_APP_FAIL">AUTH_APP_FAIL</option>
<option value="CAPTCHA_FAIL">CAPTCHA_FAIL</option>
<option value="IP_BLOCKED">IP_BLOCKED</option>
<option value="AUTH_MASTER_FAIL">AUTH_MASTER_FAIL</option>
<option value="ENC_FAIL">ENC_FAIL</option>
<option value="MISSING">MISSING</option>
<option value="KEY_EXPIRED">KEY_EXPIRED</option>
</select>
<select id="fMethod">
<option value="">全部方法</option>
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
<input id="fPath" type="text" placeholder="路径关键字">
<label class="chk"><input id="fFail" type="checkbox"> 仅失败</label>
<input id="fFrom" type="datetime-local" title="起始时间">
<input id="fTo" type="datetime-local" title="结束时间">
<button id="searchBtn" class="btn">查询</button>
<button id="resetBtn" class="btn-ghost">重置</button>
</section>
<section class="audit-blocks" id="blocksBox" style="display:none">
<h3>当前被封锁 IP</h3>
<ul id="blocksList"></ul>
</section>
<section class="audit-table-wrap">
<table class="audit-table">
<thead>
<tr>
<th>时间</th><th>来源 IP</th><th>类型</th><th>直连地址</th><th>XFF</th>
<th>方法</th><th>路径</th><th>状态码</th><th>错误码</th><th>UA</th><th>详情</th><th>会话</th>
</tr>
</thead>
<tbody id="auditBody"></tbody>
</table>
<div class="audit-pager">
<button id="prevPage" class="btn-ghost">上一页</button>
<span id="pageInfo">第 1 页</span>
<button id="nextPage" class="btn-ghost">下一页</button>
<span id="totalInfo"></span>
<span id="indexInfo" class="index-info"></span>
</div>
</section>
</div>
<script src="/audit.js"></script>
</body>
</html>
+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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();
})();
+68
查看文件
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KeePass 查看器</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app">
<!-- 1. 登录:APP 门户口令 + 验证码 -->
<section id="loginView" class="card">
<h1>KeePass 查看器</h1>
<p class="muted">请输入访问口令</p>
<input id="appPw" type="password" placeholder="访问口令(含大小写,≥8位)" autocomplete="off">
<div class="captcha-row">
<input id="captchaInput" type="text" placeholder="验证码" autocomplete="off" maxlength="5">
<span id="captchaImg" class="captcha-img" title="点击刷新"></span>
</div>
<button id="loginBtn">进入</button>
<p id="loginErr" class="err"></p>
</section>
<!-- 2. 解锁:KeePass 主密码 -->
<section id="unlockView" class="card hidden">
<h1>解锁密码库</h1>
<p class="muted">输入 KeePass 主密码(密钥文件由服务端自动使用)</p>
<input id="masterPw" type="password" placeholder="主密码" autocomplete="off">
<button id="unlockBtn">解锁</button>
<p id="unlockErr" class="err"></p>
</section>
<!-- 3. 查看/编辑 -->
<section id="mainView" class="hidden">
<header class="topbar">
<strong id="dbName"></strong>
<span id="writableBadge" class="badge hidden">可编辑</span>
<input id="search" placeholder="搜索 标题 / 账号 / 网址">
<button id="auditBtn" class="ghost">审计日志</button>
<button id="logoutBtn" class="ghost">退出</button>
</header>
<div id="auditPanel" class="hidden">
<h3>审计日志</h3>
<div id="auditList" class="audit-list"></div>
<button id="auditClose" class="ghost">关闭</button>
</div>
<div class="layout">
<aside id="groupTree"></aside>
<main>
<ul id="entryList"></ul>
<div id="pager"></div>
</main>
</div>
</section>
<!-- 4. 详情弹窗 -->
<div id="modal" class="modal hidden">
<div class="modal-card">
<button id="modalClose" class="modal-close" aria-label="关闭">×</button>
<div id="modalBody"></div>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>
+132
查看文件
@@ -0,0 +1,132 @@
:root {
--bg: #f5f6f8; --card: #fff; --line: #e3e6ea; --text: #1f2329;
--muted: #8a9099; --primary: #2f6df6; --danger: #d8392b; --active: #e8f0ff;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--text);
}
.hidden { display: none !important; }
.card {
max-width: 360px; margin: 12vh auto; background: var(--card);
border: 1px solid var(--line); border-radius: 12px; padding: 28px;
box-shadow: 0 4px 20px rgba(0,0,0,.05);
}
.card h1 { font-size: 20px; margin: 0 0 6px; }
.muted { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
input, textarea {
width: 100%; padding: 10px 12px; border: 1px solid var(--line);
border-radius: 8px; font-size: 14px; margin-bottom: 12px;
}
button {
background: var(--primary); color: #fff; border: 0; border-radius: 8px;
padding: 10px 16px; font-size: 14px; cursor: pointer;
}
button.ghost { background: transparent; color: var(--primary); border: 1px solid var(--line); }
.err { color: var(--danger); font-size: 13px; min-height: 18px; margin: 6px 0 0; }
.badge {
background: #fff3e0; color: #b26a00; font-size: 12px;
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
}
.topbar {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
background: var(--card); border-bottom: 1px solid var(--line);
}
.topbar #dbName { font-size: 15px; }
.topbar #search { width: 240px; margin: 0 0 0 auto; }
.layout { display: flex; height: calc(100vh - 53px); }
#groupTree {
width: 240px; border-right: 1px solid var(--line); overflow: auto;
padding: 10px; background: var(--card);
}
.node { padding: 7px 10px; border-radius: 6px; font-size: 13px; cursor: pointer; color: var(--text); }
.node:hover { background: #f0f2f5; }
.node.active { background: var(--active); color: var(--primary); font-weight: 600; }
main { flex: 1; overflow: auto; padding: 16px; }
#entryList { list-style: none; margin: 0; padding: 0; }
.entry {
padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px;
margin-bottom: 8px; cursor: pointer; display: flex; flex-direction: column; gap: 2px;
}
.entry:hover { border-color: var(--primary); }
.entry.active { border-color: var(--primary); background: var(--active); }
.entry .t { font-weight: 600; }
.entry .u { font-size: 12px; color: var(--muted); }
.empty { color: var(--muted); padding: 12px; }
#detail { margin-top: 12px; border-top: 1px dashed var(--line); padding-top: 12px; }
#detail h2 { margin: 0 0 12px; font-size: 18px; }
.row { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line); }
.row .label { width: 56px; color: var(--muted); font-size: 13px; flex: none; }
.row .value { flex: 1; word-break: break-all; font-size: 14px; white-space: pre-wrap; }
.row .copy, .row .open { flex: none; font-size: 12px; padding: 4px 10px; }
/* ===== 详情弹窗 ===== */
.modal {
position: fixed; inset: 0; background: rgba(0,0,0,.45);
display: flex; align-items: center; justify-content: center; z-index: 50;
}
.modal.hidden { display: none !important; }
.modal-card {
background: var(--card); width: min(520px, 92vw); max-height: 86vh; overflow: auto;
border-radius: 14px; padding: 22px 24px; position: relative;
box-shadow: 0 12px 40px rgba(0,0,0,.2);
}
.modal-card h2 { margin: 0 0 14px; font-size: 18px; padding-right: 28px; }
.modal-close {
position: absolute; top: 12px; right: 12px; background: transparent; color: var(--muted);
border: 0; font-size: 22px; line-height: 1; cursor: pointer; padding: 4px 8px;
}
.modal-close:hover { color: var(--text); }
/* ===== 分页 ===== */
#pager { display: flex; align-items: center; gap: 12px; padding: 12px 4px; justify-content: center; }
#pager .pinfo { font-size: 13px; color: var(--muted); }
#pager button:disabled { opacity: .4; cursor: not-allowed; }
/* ===== 验证码 ===== */
.captcha-row { display: flex; gap: 8px; align-items: center; }
.captcha-row input { flex: 1; }
.captcha-img { display: inline-flex; height: 44px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; overflow: hidden; background: #fff; }
.captcha-img svg { height: 44px; display: block; }
/* ===== 审计日志 ===== */
#auditPanel { padding: 16px 20px; border-bottom: 1px solid var(--border); background: var(--card); }
#auditPanel h3 { margin: 0 0 10px; }
.audit-list { max-height: 260px; overflow: auto; font-family: monospace; font-size: 12px; }
.audit-list .row { display: flex; gap: 10px; padding: 2px 0; border-bottom: 1px dashed var(--border); }
.audit-list .ok { color: var(--ok, #2f855a); }
.audit-list .fail { color: var(--err, #c53030); }
/* ===== 独立审计页 ===== */
.audit-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
.audit-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.audit-header h1 { font-size: 20px; margin: 0; }
.audit-actions { display: flex; gap: 8px; }
.audit-filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 16px; }
.audit-filters input, .audit-filters select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; font-size: 13px; }
.audit-filters input[type="text"] { width: 160px; }
.audit-filters .chk { font-size: 13px; display: flex; align-items: center; gap: 4px; }
.audit-blocks { background: #fff4f3; border: 1px solid #f3c7c2; border-radius: 8px; padding: 10px 14px; margin-bottom: 16px; }
.audit-blocks h3 { margin: 0 0 6px; color: var(--danger); font-size: 14px; }
.audit-blocks ul { margin: 0; padding-left: 18px; font-size: 13px; }
.audit-blocks li { padding: 2px 0; }
.audit-blocks .mono, .audit-table .mono { font-family: monospace; }
.audit-table-wrap { background: var(--card); border: 1px solid var(--line); border-radius: 10px; overflow: auto; }
.audit-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.audit-table th, .audit-table td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
.audit-table th { background: #fafbfc; position: sticky; top: 0; font-weight: 600; }
.audit-table tbody tr.row-fail { background: #fff5f5; }
.audit-table .ua { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
.audit-table .code { font-family: monospace; }
.audit-table .code:not(:empty) { color: var(--danger); }
.tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; line-height: 1.5; }
.tag-internal { background: #fff3cd; color: #8a6d00; border: 1px solid #ffe69c; }
.tag-external { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
.index-info { display: block; margin-top: 6px; font-size: 12px; color: var(--muted); font-family: monospace; word-break: break-all; }
.audit-filters select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; font-size: 13px; }
.audit-pager { display: flex; align-items: center; gap: 12px; padding: 10px 14px; font-size: 13px; flex-wrap: wrap; }
.btn { padding: 6px 14px; background: var(--primary); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
.btn:hover { filter: brightness(1.05); }
.btn-ghost { padding: 6px 12px; background: #fff; border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font-size: 13px; }
.btn-ghost:hover { background: #f0f2f5; }
+759
查看文件
@@ -0,0 +1,759 @@
'use strict';
require('dotenv').config(); // 必须在 require('./config') 之前加载 .env
const express = require('express');
const session = require('express-session');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const http = require('http');
const https = require('https');
const config = require('./config');
const { loadDatabase, extract, ProtectedValue } = require('./kdbxlib');
// 是否启用 HTTPS:证书存在则启用;否则尝试自动生成自签证书(容器/首次部署默认 HTTPS)
// 注意:session.cookie.secure 必须是布尔值;用函数形式在 trust proxy 下会导致 cookie 不下发
function ensureSsl() {
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) return keyPath;
// 证书缺失:自动生成自签证书,保证默认走 HTTPS(生产应使用受信任证书替换)
try {
const { generateSelfSigned } = require('./gen-cert');
generateSelfSigned(keyPath, certPath, 'localhost');
console.log(`[ssl] 未检测到证书,已自动生成自签证书: ${keyPath}`);
return fs.existsSync(keyPath) && fs.existsSync(certPath) ? keyPath : null;
} catch (e) {
console.error('[ssl] 自动生成证书失败,将回退 HTTP:', e.message);
return null;
}
}
function detectSsl() { return !!ensureSsl(); }
const HAS_SSL = detectSsl();
// ============ 密钥体系 ============
// 1) 长期 RSA(仅用于加密传输 login/unlock 的口令,私钥仅存服务端内存)
const TRANSPORT_RSA = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
// 解密前端用传输公钥加密的密文
function rsaDecrypt(b64) {
const buf = Buffer.from(b64, 'base64');
return crypto.privateDecrypt(
{ key: TRANSPORT_RSA.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
buf
).toString('utf8');
}
// 2) 会话级 AES(通道加密:login 协商,用于加密所有响应体)
const sessionKeys = new Map(); // sessionID -> Buffer(32)
function encPayload(req, obj) {
const key = sessionKeys.get(req.sessionID);
if (!key) return obj;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(JSON.stringify(obj), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return { iv: iv.toString('base64'), ct: ct.toString('base64'), tag: tag.toString('base64') };
}
function encRes(req, res, obj, status = 200) { res.status(status).json(encPayload(req, obj)); }
// ============ 错误码(便于审计与前端区分)============
const ERR = {
AUTH_APP_FAIL: 'AUTH_APP_FAIL', // 门户口令错误
CAPTCHA_FAIL: 'CAPTCHA_FAIL', // 验证码错误
IP_BLOCKED: 'IP_BLOCKED', // IP 被封锁
AUTH_MASTER_FAIL: 'AUTH_MASTER_FAIL', // 主密码/keyfile 错误
KEY_EXPIRED: 'KEY_EXPIRED', // 动态数据密钥过期
ENC_FAIL: 'ENC_FAIL', // 密文解析失败
MISSING: 'MISSING', // 缺少参数
NOT_FOUND: 'NOT_FOUND',
RATE_LIMIT: 'RATE_LIMIT',
};
// ============ 审计日志(本地文件持久化 + 索引文件,按大小切分)============
// 数据模型:
// - 每个日志文件(audit.log / audit.N.log)为 JSON Lines,每行一条记录,按全局 seq 单调递增
// - 索引文件 audit.index.json 记录每个文件的 起始序号/结束序号/条数/字节/时间范围,以及全局总条数
// - 前端分页先读取索引计算分布,再按需从对应文件提取片段,保证分页与文件一致
const AUDIT_FILE_MAX = config.auditFileMax; // 单文件切分阈值,可由 AUDIT_FILE_MAX 环境变量配置(默认 5M)
const AUDIT_FILE_KEEP = config.auditFileKeep; // 切分文件最多保留份数,可由 AUDIT_FILE_KEEP 环境变量配置(默认 10)
let auditSeq = 0; // 全局单调递增序号(持久化,重启后从索引恢复)
const logDir = config.logDir;
try { fs.mkdirSync(logDir, { recursive: true }); } catch (e) {}
function auditCurrentFile() { return path.join(logDir, 'audit.log'); }
function auditRotatedFile(i) { return path.join(logDir, `audit.${i}.log`); }
function auditIndexFile() { return path.join(logDir, 'audit.index.json'); }
// 索引:{ total, nextSeq, files: [ { file, startSeq, endSeq, count, bytes, firstTime, lastTime } ] }
// nextSeq:全局下一个序号(跨重启持久化,避免 seq 重叠导致分页/区间错乱)
let auditIndex = { total: 0, nextSeq: 0, files: [] };
// 启动时从索引文件恢复(若无则基于现有审计文件重建)
function auditLoadIndex() {
try {
const raw = fs.readFileSync(auditIndexFile(), 'utf8');
const idx = JSON.parse(raw);
if (idx && Array.isArray(idx.files)) {
auditIndex = idx;
auditSeq = auditIndex.nextSeq || auditIndex.files.reduce((m, f) => Math.max(m, f.endSeq || 0), 0);
return;
}
} catch (e) { /* 索引缺失或损坏,下面重建 */ }
// 重建:扫描存在的审计文件,重排行号(按文件名顺序,认为 audit.log 最新)
auditRebuildIndex();
auditIndex.nextSeq = auditSeq;
auditSaveIndex();
}
// 扫描磁盘上的审计文件并重建索引(在索引丢失/损坏时调用)
function auditRebuildIndex() {
const names = [];
if (fs.existsSync(auditCurrentFile())) names.push('audit.log');
for (let i = 1; i <= AUDIT_FILE_KEEP; i++) {
const n = `audit.${i}.log`;
if (fs.existsSync(auditRotatedFile(i))) names.push(n);
}
// 顺序:audit.10 ... audit.1 在前(旧),audit.log 在最后(新)
names.sort((a, b) => {
const num = (n) => n === 'audit.log' ? 0 : -parseInt(n.match(/(\d+)/)[1], 10);
return num(a) - num(b);
});
let seq = 0;
auditIndex = { total: 0, nextSeq: 0, files: [] };
for (const n of names) {
const fp = path.join(logDir, n);
let bytes = 0; try { bytes = fs.statSync(fp).size; } catch (e) {}
const lines = readAuditLines(fp);
if (!lines.length) continue;
const startSeq = seq + 1;
let firstTime = '', lastTime = '';
lines.forEach((e, i) => { e.seq = startSeq + i; seq = e.seq; if (!firstTime) firstTime = e.t; lastTime = e.t; });
const endSeq = seq;
auditIndex.files.push({ file: n, startSeq, endSeq, count: lines.length, bytes, firstTime, lastTime });
}
auditIndex.total = auditIndex.files.reduce((s, f) => s + f.count, 0);
auditSeq = auditIndex.files.reduce((m, f) => Math.max(m, f.endSeq || 0), 0);
auditIndex.nextSeq = auditSeq;
auditSaveIndex();
}
function readAuditLines(fp) {
let txt = '';
try { txt = fs.readFileSync(fp, 'utf8'); } catch (e) { return []; }
const out = [];
txt.split('\n').forEach((ln) => {
ln = ln.trim();
if (!ln) return;
try { out.push(JSON.parse(ln)); } catch (e) {}
});
return out;
}
function auditSaveIndex() {
// 索引文件很小(仅元数据),每次追加后同步落盘,保证重启/异常退出后分页与文件一致
try { fs.writeFileSync(auditIndexFile(), JSON.stringify(auditIndex)); } catch (e) {}
}
// 写入一行到当前审计文件(JSON Lines);超阈值则切分(滚动重命名),并同步更新索引
function auditAppendToFile(line) {
try {
const cur = auditCurrentFile();
let size = 0;
try { size = fs.statSync(cur).size; } catch (e) {}
if (size + line.length + 1 > AUDIT_FILE_MAX) {
// 将当前文件归档进索引(切分前先记录其区间)
const curLines = readAuditLines(cur);
let curStart = auditIndex.files.length && auditIndex.files[auditIndex.files.length - 1].file === 'audit.log'
? auditIndex.files[auditIndex.files.length - 1].startSeq : (auditSeq - curLines.length + 1);
const curEnd = curStart + curLines.length - 1;
// 滚动:audit.9 -> 删除, audit.8 -> audit.9, ... audit.log -> audit.1
for (let i = AUDIT_FILE_KEEP - 1; i >= 1; i--) {
const src = i === 1 ? cur : auditRotatedFile(i - 1);
const dst = auditRotatedFile(i);
try { if (fs.existsSync(src)) fs.renameSync(src, dst); } catch (e) {}
}
// 把刚归档的 audit.log 信息更新进索引(它现在变成 audit.1)
const fobj = { file: 'audit.log', startSeq: curStart, endSeq: curEnd, count: curLines.length, bytes: size, firstTime: curLines[0] ? curLines[0].t : '', lastTime: curLines[curLines.length - 1] ? curLines[curLines.length - 1].t : '' };
const existing = auditIndex.files.find((f) => f.file === 'audit.log');
if (existing) Object.assign(existing, fobj); else auditIndex.files.push(fobj);
// 重排:保证 audit.log 始终在数组末尾;其他按序号倒序
auditIndex.files.sort((a, b) => {
const num = (n) => n === 'audit.log' ? -1 : -parseInt(n.match(/(\d+)/)[1], 10);
return num(a) - num(b);
});
auditSaveIndex();
}
fs.appendFileSync(cur, line + '\n');
} catch (e) { /* 文件写入失败不阻断主流程 */ }
}
// 记录一条审计:详情包含来源 IP(含内网/外网、XFF 全链、直连地址)、UA、时间、方法、路径等
function audit({ ip, method, path: p, sessionId, code, ok, ua, referer, status, detail }) {
// ip 可为字符串(兼容旧调用)或结构化对象 { ip, remote, xff, isInternal }
const ipInfo = (typeof ip === 'object' && ip) ? ip : { ip: ip || 'unknown', remote: '', xff: '', isInternal: false };
const entry = {
seq: ++auditSeq,
t: new Date().toISOString(),
ip: ipInfo.ip || 'unknown',
ipType: ipInfo.isInternal ? 'internal' : 'external',
remote: ipInfo.remote || '-', // 直连(代理/服务端看到的)地址
xff: ipInfo.xff || '-', // 完整 X-Forwarded-For 链
ua: ua || '-',
referer: referer || '-',
method: method || '-',
path: p || '-',
status: status || 0,
code: code || '-',
ok: ok ? 1 : 0,
sid: sessionId ? sessionId.slice(0, 8) : '-',
detail: detail || '',
};
auditAppendToFile(JSON.stringify(entry));
// 维护索引中当前文件(audit.log)的区间
const curFile = auditIndex.files.find((f) => f.file === 'audit.log');
if (curFile) {
if (curFile.count === 0) { curFile.startSeq = entry.seq; curFile.firstTime = entry.t; }
curFile.endSeq = entry.seq;
curFile.count += 1;
curFile.lastTime = entry.t;
try { curFile.bytes = fs.statSync(auditCurrentFile()).size; } catch (e) {}
} else {
auditIndex.files.push({ file: 'audit.log', startSeq: entry.seq, endSeq: entry.seq, count: 1, bytes: 0, firstTime: entry.t, lastTime: entry.t });
}
auditIndex.total += 1;
auditIndex.nextSeq = auditSeq + 1;
auditSaveIndex();
}
// ============ IP 封锁(登录错误 5 次/30min)============
const ipFails = new Map(); // ip -> { count, first }
const IP_WINDOW = 30 * 60 * 1000;
const IP_MAX_FAIL = 5;
function recordIpFail(ip) {
const now = Date.now();
let r = ipFails.get(ip);
if (!r || now - r.first > IP_WINDOW) r = { count: 0, first: now };
r.count++; ipFails.set(ip, r);
return r.count >= IP_MAX_FAIL;
}
function isIpBlocked(ip) {
const r = ipFails.get(ip);
if (!r) return false;
if (Date.now() - r.first > IP_WINDOW) { ipFails.delete(ip); return false; }
return r.count >= IP_MAX_FAIL;
}
// 从请求构造审计的基础字段(IP/UA/referer),与各接口的具体 code/status 合并
function auditFromReq(req, res, extra) {
return Object.assign({
ip: getClientIp(req),
ua: req.headers['user-agent'] || '',
referer: req.headers['referer'] || '',
sessionId: req.sessionID,
status: res.statusCode,
}, extra);
}
// ============ 验证码 ============
const captchaStore = new Map(); // sessionID -> { text, expires }
const CAPTCHA_TTL = 5 * 60 * 1000;
function genCaptcha() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉易混淆字符
let text = '';
for (let i = 0; i < 5; i++) text += chars[Math.floor(Math.random() * chars.length)];
// 生成 SVG 图片
const colors = ['#2b6cb0', '#2f855a', '#c05621', '#6b46c1'];
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="44" viewBox="0 0 120 44">`;
svg += `<rect width="120" height="44" fill="#f7fafc"/>`;
for (let i = 0; i < text.length; i++) {
const x = 14 + i * 22, y = 30 + (Math.random() * 6 - 3);
const rot = Math.random() * 30 - 15;
const c = colors[i % colors.length];
svg += `<text x="${x}" y="${y}" font-size="26" font-family="monospace" font-weight="bold" fill="${c}" transform="rotate(${rot} ${x} ${y})">${text[i]}</text>`;
}
// 干扰线
for (let i = 0; i < 3; i++) {
svg += `<line x1="${Math.random() * 120}" y1="${Math.random() * 44}" x2="${Math.random() * 120}" y2="${Math.random() * 44}" stroke="#cbd5e0" stroke-width="1"/>`;
}
svg += `</svg>`;
return { text, svg };
}
// 解析客户端 IP 信息:完整记录直连地址、X-Forwarded-For 全链、真实客户端 IP 及是否内网
// 拦截器/反向代理场景:XFF 最右侧(最后一个)为真实客户端,前面为各级代理
const PRIVATE_RE = /^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/;
function isPrivateIp(ip) {
if (!ip) return false;
ip = ip.trim().replace(/^::ffff:/, '');
if (ip === '::1' || ip === 'localhost' || ip === 'unknown') return true;
return PRIVATE_RE.test(ip);
}
function getClientIp(req) {
const remote = (req.socket && req.socket.remoteAddress) || 'unknown';
const xffRaw = (req.headers['x-forwarded-for'] || '').toString().trim();
const xffList = xffRaw ? xffRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
// 真实客户端:XFF 链最后一跳(离服务端最远),若没有 XFF 则取直连
const realIp = xffList.length ? xffList[xffList.length - 1] : remote;
const isInternal = isPrivateIp(realIp) && xffList.length === 0 ? isPrivateIp(remote) : isPrivateIp(realIp);
return { ip: realIp, remote, xff: xffRaw, isInternal };
}
// ============ Express ============
const app = express();
// 注意:未启用 trust proxy。若部署在反向代理后,请按实际拓扑设置;
// 当前按直连处理,IP 通过 req.socket.remoteAddress 获取,避免误判。
app.disable('x-powered-by');
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Referrer-Policy', 'no-referrer');
// 基础 CSP:仅允许同源脚本/样式,防 XSS 与注入
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
next();
});
app.use(express.json({ limit: '8kb' }));
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
// 注:登录不复用 express-rate-limit(用户要求 login 不限流);
// 防爆破由下方「登录失败 IP 封锁」机制(30 分钟内 5 次失败封 IP)承担。
// unlock 同样处于 login 之后,且主密码错误仅返回 401,无额外限流。
app.use(session({
secret: config.sessionSecret || crypto.randomBytes(32).toString('hex'),
resave: true,
saveUninitialized: false,
cookie: {
httpOnly: true, sameSite: 'lax',
// HTTPS 模式下标记为 Secure;HTTP 开发模式为 false(cookie 必须始终下发)
secure: HAS_SSL,
maxAge: config.sessionMaxAge,
},
}));
// 全局审计中间件:注册在 session 之后,确保 req.sessionID 有效;
// 已在具体接口内精细记录(带错误码)的,通过 res._audited 标记避免重复
app.use('/api', (req, res, next) => {
res.on('finish', () => {
if (res._audited) return;
const ip = getClientIp(req);
audit({
ip, method: req.method, path: req.path, sessionId: req.sessionID,
code: res.statusCode < 400 ? 'OK' : 'ERR' + res.statusCode,
ok: res.statusCode < 400,
ua: req.headers['user-agent'] || '',
referer: req.headers['referer'] || '',
status: res.statusCode,
});
});
next();
});
// ---- 中间件 ----
function appAuth(req, res, next) {
if (req.session && req.session.appAuthed) return next();
return res.status(401).json({ error: '未登录' });
}
function unlocked(req, res, next) {
if (dbs.has(req.sessionID)) return next();
return res.status(401).json({ error: '未解锁' });
}
function writable(req, res, next) {
if (config.writable) return next();
return res.status(403).json({ error: '只读模式' });
}
// 解密前端用传输公钥加密的密文(提取 helper)
function decryptBody(req, field) {
const raw = (req.body && req.body[field]) || '';
if (!raw) throw new Error(ERR.ENC_FAIL);
return rsaDecrypt(raw);
}
// 动态数据密钥是否过期(1 小时)
function checkDataKey(req, res) {
const rec = dbs.get(req.sessionID);
if (!rec) return res.status(401).json({ error: '未解锁' });
if (Date.now() > rec.dataKeyExpire) {
return res.status(403).json({ error: '动态密钥已过期,请重新解锁', code: ERR.KEY_EXPIRED });
}
return null;
}
// 解密后端用会话 dataRSA 公钥加密的字段(密码)-> 这里只是转发密文,前端用私钥解
// 后端只存密文,明文不落内存
// ============ 路由 ============
const dbs = new Map(); // sessionID -> { db, items, groups, tree, name, dataKeyExpire }
// 状态探测
app.get('/api/status', (req, res) => {
const authed = !!(req.session && req.session.appAuthed);
const unlocked = dbs.has(req.sessionID);
res.json({ authed, unlocked, writable: config.writable, name: unlocked ? dbs.get(req.sessionID).name : null });
});
// 下发传输公钥
app.get('/api/pubkey', (req, res) => res.json({ pubkey: TRANSPORT_RSA.publicKey }));
// 验证码(SVG 图片 + 答案存内存,返回 captchaId 由前端在登录时回传)
app.get('/api/captcha', (req, res) => {
const { text, svg } = genCaptcha();
const cid = crypto.randomBytes(8).toString('hex');
captchaStore.set(cid, { text, expires: Date.now() + CAPTCHA_TTL });
// 仅测试环境回显明文,便于自动化;生产环境绝不返回 text
if (process.env.NODE_ENV === 'test') res.json({ cid, svg, text });
else res.json({ cid, svg });
});
// 登录:APP 门户口令 + 验证码
app.post('/api/login', (req, res) => {
const ip = getClientIp(req);
if (isIpBlocked(ip)) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.IP_BLOCKED, ok: false, detail: 'IP 已封锁' }));
return res.status(429).json({ error: '该 IP 已被封锁,请 30 分钟后再试', code: ERR.IP_BLOCKED });
}
let password, dataPubPem, captcha, captchaId;
try {
password = decryptBody(req, 'enc');
dataPubPem = (req.body && req.body.dataPubKey) || '';
captcha = (req.body && req.body.captcha) || '';
captchaId = (req.body && req.body.captchaId) || '';
} catch (e) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.ENC_FAIL, ok: false, detail: '请求密文解析失败' }));
return res.status(400).json({ error: '密文解析失败', code: ERR.ENC_FAIL });
}
// 验证码校验
const cap = captchaStore.get(captchaId);
captchaStore.delete(captchaId);
if (!cap || Date.now() > cap.expires || !captcha || captcha.toUpperCase() !== cap.text) {
recordIpFail(ip);
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.CAPTCHA_FAIL, ok: false, detail: '验证码错误' }));
return res.status(400).json({ error: '验证码错误', code: ERR.CAPTCHA_FAIL });
}
// 门户口令校验(含大小写+8位规则在 config 侧已由期望值约束;此处仅比对)
if (!password || password !== config.getExpectedAppPassword()) {
const blocked = recordIpFail(ip);
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.AUTH_APP_FAIL, ok: false, detail: blocked ? '尝试过多已封锁' : '门户口令错误' }));
if (blocked) return res.status(429).json({ error: '尝试次数过多,IP 已封锁', code: ERR.IP_BLOCKED });
return res.status(401).json({ error: '门户口令错误', code: ERR.AUTH_APP_FAIL });
}
// 门户口令合规检查:必须含大小写且长度>=8(期望值本身应满足,这里做额外策略校验)
if (!/^(?=.*[a-z])(?=.*[A-Z]).{8,}$/.test(password)) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.AUTH_APP_FAIL, ok: false, detail: '口令复杂度不满足' }));
return res.status(401).json({ error: '门户口令不符合复杂度要求', code: ERR.AUTH_APP_FAIL });
}
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: '会话错误' });
req.session.appAuthed = true;
// 暂存前端 dataRSA 公钥,unlock 时使用
if (dataPubPem) req.session.dataPubPem = dataPubPem;
// 协商会话 AES 通道密钥:前端用传输公钥加密上传,服务端用私钥解出 32 字节写入内存
sessionKeys.delete(req.sessionID); // 旧会话的通道密钥清理
try {
const skB64 = (req.body && req.body.sessionKey) ? rsaDecrypt(req.body.sessionKey) : '';
const skBuf = Buffer.from(skB64, 'base64');
if (skBuf.length === 32) sessionKeys.set(req.sessionID, skBuf);
} catch (e) { /* 通道密钥缺失不阻断登录,仅后续响应走明文兜底 */ }
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: 'OK', ok: true, detail: '登录成功' }));
res.json({ ok: true, writable: config.writable });
});
});
// 解锁:KeePass 主密码 -> 解密库 -> 用前端 dataRSA 公钥加密密码字段存密文(内存无明文)
app.post('/api/unlock', appAuth, async (req, res) => {
const ip = getClientIp(req);
let masterPassword;
try { masterPassword = decryptBody(req, 'enc'); }
catch (e) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.ENC_FAIL, ok: false, detail: '请求密文解析失败' }));
return res.status(400).json({ error: '密文解析失败', code: ERR.ENC_FAIL });
}
if (!masterPassword) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.MISSING, ok: false, detail: '缺少主密码' }));
return res.status(400).json({ error: '缺少主密码', code: ERR.MISSING });
}
const dataPubPem = req.session.dataPubPem;
if (!dataPubPem) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.MISSING, ok: false, detail: '缺少数据公钥' }));
return res.status(400).json({ error: '缺少数据公钥,请重新登录', code: ERR.MISSING });
}
try {
const rec = await loadDatabase(
config.kdbxPath,
config.keyfilePath || undefined,
masterPassword,
dataPubPem // 用于加密密码字段存密文
);
const prevKeys = sessionKeys.get(req.sessionID);
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: '会话错误' });
req.session.appAuthed = true;
req.session.dataPubPem = dataPubPem;
dbs.set(req.sessionID, rec);
if (prevKeys) sessionKeys.set(req.sessionID, prevKeys);
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: 'OK', ok: true, detail: '解锁成功' }));
res.json({ ok: true, name: rec.name, count: rec.items.length, writable: config.writable,
dataKeyExpire: rec.dataKeyExpire });
});
} catch (e) {
res._audited = true;
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.AUTH_MASTER_FAIL, ok: false, detail: '主密码或密钥文件错误' }));
return res.status(401).json({ error: '主密码或密钥文件错误', code: ERR.AUTH_MASTER_FAIL });
}
});
// 数据:分组树(无敏感字段,仍按会话加密返回)
app.get('/api/tree', appAuth, unlocked, (req, res) => {
const rec = dbs.get(req.sessionID);
const blocked = checkDataKey(req, res); if (blocked) return;
encRes(req, res, { name: rec.name, tree: rec.tree, groups: rec.groups });
});
// 数据:某分组下的密码列表(分页;列表不含密码明文,整体加密返回)
const PAGE_SIZE = 50;
app.get('/api/entries', appAuth, unlocked, (req, res) => {
const rec = dbs.get(req.sessionID);
const blocked = checkDataKey(req, res); if (blocked) return;
const group = req.query.group || null;
const q = (req.query.q || '').toString().trim().toLowerCase();
const page = Math.max(1, parseInt(req.query.page || '1', 10));
let list = rec.items;
if (group) list = list.filter((i) => i.groupId === group);
if (q) list = list.filter((i) =>
(i.title || '').toLowerCase().includes(q) ||
(i.username || '').toLowerCase().includes(q) ||
(i.url || '').toLowerCase().includes(q));
const total = list.length;
const start = (page - 1) * PAGE_SIZE;
const pageItems = list.slice(start, start + PAGE_SIZE).map((i) => ({
id: i.id, title: i.title, username: i.username, url: i.url, group: i.group,
}));
encRes(req, res, { items: pageItems, page, pageSize: PAGE_SIZE, total, group, q });
});
// 数据:单条详情(密码为密文,前端用 dataRSA 私钥解密;整体再经通道加密)
app.get('/api/entry/:id', appAuth, unlocked, (req, res) => {
const rec = dbs.get(req.sessionID);
const blocked = checkDataKey(req, res); if (blocked) return;
const item = rec.items.find((i) => i.id === req.params.id);
if (!item) return encRes(req, res, { error: '条目不存在', code: ERR.NOT_FOUND }, 404);
encRes(req, res, {
id: item.id, title: item.title, username: item.username,
passwordCrypt: item.passwordCrypt, // 密文!前端解密
url: item.url, notes: item.notes, group: item.group,
});
});
// 编辑(仅可写模式)
app.post('/api/entry/update', appAuth, unlocked, writable, async (req, res) => {
const rec = dbs.get(req.sessionID);
const blocked = checkDataKey(req, res); if (blocked) return;
const { id, fields } = req.body || {};
if (!id || !fields) return res.status(400).json({ error: '参数缺失', code: ERR.MISSING });
try {
const entry = rec.db.entries.find((e) => Buffer.from(e.uuid.toBytes()).toString('base64') === id);
if (!entry) return res.status(404).json({ error: '条目不存在', code: ERR.NOT_FOUND });
entry.fields.set('Title', fields.title || '');
entry.fields.set('UserName', fields.username || '');
entry.fields.set('Password', ProtectedValue.fromString(fields.password || ''));
entry.fields.set('URL', fields.url || '');
entry.fields.set('Notes', fields.notes || '');
rec.db.cleanup({ historyRules: true });
rec.db.save();
const buf = rec.db.save();
if (config.keyfilePath) {
fs.copyFileSync(config.kdbxPath, config.kdbxPath + '.bak');
}
fs.writeFileSync(config.kdbxPath, Buffer.from(buf));
// 重新抽取并加密密码字段
const ex = extract(rec.db, req.session.dataPubPem);
rec.groups = ex.groups; rec.items = ex.items; rec.tree = ex.tree; rec.name = ex.name;
encRes(req, res, { ok: true, item: ex.items.find((i) => i.id === id) });
} catch (e) {
res.status(500).json({ error: '保存失败' });
}
});
// 独立审计页(或新标签页)协商响应通道密钥:复用同一会话的服务端密钥槽
app.post('/api/session/key', appAuth, (req, res) => {
try {
const raw = (req.body && req.body.key) || '';
if (!raw) return res.status(400).json({ error: '缺少 key' });
const b64 = rsaDecrypt(raw);
const buf = Buffer.from(b64, 'base64');
if (buf.length !== 32) return res.status(400).json({ error: '密钥长度错误' });
sessionKeys.set(req.sessionID, buf);
res.json({ ok: true });
} catch (e) { res.status(400).json({ error: '密钥解析失败' }); }
});
// 审计索引:返回各日志文件分布(文件/起始序号/结束序号/条数/时间范围)与总条数
// 前端分页前先读此接口计算分布,再按需从对应文件提取,保证分页与文件一致
app.get('/api/audit/index', appAuth, (req, res) => {
encRes(req, res, { total: auditIndex.total, files: auditIndex.files });
});
// 按文件 + 序号区间读取审计片段(解密后返回),供前端精确分页
app.get('/api/audit/file', appAuth, (req, res) => {
const q = req.query || {};
const file = (q.file || 'audit.log').toString();
// 仅允许白名单文件名,防目录穿越
if (!/^audit(\.\d+)?\.log$/.test(file)) return encRes(req, res, { error: '非法文件名' }, 400);
const fp = path.join(logDir, file);
const fromSeq = parseInt(q.fromSeq || '0', 10) || 0;
const toSeq = parseInt(q.toSeq || '0', 10) || 0;
let lines = readAuditLines(fp);
if (fromSeq || toSeq) {
lines = lines.filter((e) => (!fromSeq || e.seq >= fromSeq) && (!toSeq || e.seq <= toSeq));
}
encRes(req, res, { file, count: lines.length, log: lines });
});
// 审计日志(登录后可访问):基于「索引 + 本地文件」读取,支持筛选 + 分页,条数与文件完全一致
app.get('/api/audit', appAuth, (req, res) => {
const q = req.query || {};
const ip = (q.ip || '').trim().toLowerCase();
const ipType = (q.iptype || '').trim().toLowerCase();
const code = (q.code || '').trim();
const method = (q.method || '').trim().toUpperCase();
const pathFilter = (q.path || '').trim().toLowerCase();
const onlyFail = q.fail === '1' || q.fail === 'true';
const from = q.from ? Date.parse(q.from) : 0;
const to = q.to ? Date.parse(q.to) : Date.now() + 1;
const pageSize = Math.min(parseInt(q.pageSize || '50', 10) || 50, 500);
const page = Math.max(parseInt(q.page || '1', 10) || 1, 1);
// 汇总所有日志文件(按索引顺序,旧文件在前、audit.log 在后),保证完整覆盖
let all = [];
for (const f of auditIndex.files) {
const fp = path.join(logDir, f.file);
all = all.concat(readAuditLines(fp));
}
// 按 seq 升序排列(seq 全局单调递增;文件顺序已保证时间序,这里保险)
all.sort((a, b) => (a.seq || 0) - (b.seq || 0));
let rows = all;
if (ip || code || method || pathFilter || onlyFail || from || to < Date.now() + 1) {
rows = rows.filter((e) => {
if (ip && !String(e.ip || '').toLowerCase().includes(ip)) return false;
if (ipType && (e.ipType || '').toLowerCase() !== ipType) return false;
if (code && e.code !== code) return false;
if (method && e.method !== method) return false;
if (pathFilter && !String(e.path || '').toLowerCase().includes(pathFilter)) return false;
if (onlyFail && e.ok) return false;
const ts = Date.parse(e.t);
if (from && ts < from) return false;
if (to && ts > to) return false;
return true;
});
}
const total = rows.length;
// 默认显示最近的数据:按 seq 倒序取第 page 页(前端"最新在前")
const sortedDesc = rows.slice().sort((a, b) => (b.seq || 0) - (a.seq || 0));
const start = (page - 1) * pageSize;
const list = sortedDesc.slice(start, start + pageSize);
encRes(req, res, {
log: list,
total,
page,
pageSize,
indexTotal: auditIndex.total,
filters: { ip, code, method, path: pathFilter, onlyFail, from: from || '', to: to < Date.now() + 1 ? q.to : '' },
});
});
// 当前被封锁的 IP 列表(便于审计页展示与排查)
app.get('/api/audit/blocks', appAuth, (req, res) => {
const now = Date.now();
const list = [];
for (const [ip, r] of ipFails) {
if (now - r.first > IP_WINDOW) continue;
if (r.count >= IP_MAX_FAIL) {
list.push({ ip, fails: r.count, expiresAt: new Date(r.first + IP_WINDOW).toISOString() });
}
}
encRes(req, res, { blocks: list });
});
// 手动解除某 IP 封锁(管理员操作,记入审计)
app.post('/api/audit/unblock', appAuth, (req, res) => {
const target = (req.body && req.body.ip) || '';
if (!target) return encRes(req, res, { error: '缺少 ip' }, 400);
ipFails.delete(target);
audit(auditFromReq(req, res, { method: 'POST', path: '/api/audit/unblock', code: 'OK', ok: true, detail: '手动解封 IP: ' + target }));
encRes(req, res, { ok: true });
});
// 退出:清空内存
app.post('/api/logout', (req, res) => {
if (req.sessionID) { dbs.delete(req.sessionID); sessionKeys.delete(req.sessionID); captchaStore.delete(req.sessionID); }
if (req.session) req.session.destroy(() => res.json({ ok: true }));
else res.json({ ok: true });
});
// ============ 启动 ============
// 会话销毁时联动清理内存中的明文/密文数据,避免孤儿数据常驻
const sessionMiddleware = app._router && app._router.stack;
// 通过监听 destroy:express-session 在 req.session.destroy 时触发,这里在 logout 已清理;
// 另加定时器兜底:扫描过期数据密钥并清理
setInterval(() => {
const now = Date.now();
for (const [sid, rec] of dbs) {
if (rec.dataKeyExpire && now > rec.dataKeyExpire + 60 * 1000) {
dbs.delete(sid); sessionKeys.delete(sid);
}
}
// 清理过期验证码
for (const [sid, cap] of captchaStore) {
if (now > cap.expires) captchaStore.delete(sid);
}
}, 60 * 1000).unref();
function startServer() {
// 启动前恢复/重建审计索引,使分页与本地文件一致
try { auditLoadIndex(); } catch (e) {}
const port = config.port;
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
const hasSsl = fs.existsSync(keyPath) && fs.existsSync(certPath);
if (hasSsl) {
// 生产模式:默认 HTTPS;同时起一个 HTTP 端口把所有请求 301 重定向到 HTTPS
const opt = { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) };
https.createServer(opt, app).listen(port, () => {
console.log(`[kdbx-viewer] HTTPS 已启动: https://localhost:${port}`);
if (!config.sessionSecret) console.warn('[config] 警告: SESSION_SECRET 未设置,已使用随机值(重启后会话失效)');
});
const redirectPort = port + 1;
http.createServer((req, res) => {
const host = req.headers.host ? req.headers.host.replace(/:\d+$/, '') : 'localhost';
res.writeHead(301, { Location: `https://${host}:${port}${req.url}` });
res.end();
}).listen(redirectPort, () => {
console.log(`[kdbx-viewer] HTTP(${redirectPort}) -> HTTPS(${port}) 重定向已启用`);
});
} else {
// 开发模式(无证书):仍允许 HTTP,但明确提示生产必须用 HTTPS
http.createServer(app).listen(port, () => {
console.log(`[kdbx-viewer] HTTP 已启动: http://localhost:${port}`);
console.warn('[安全] 未检测到 SSL 证书,已使用明文 HTTP。生产环境请配置 SSL_KEY/SSL_CERT 启用 HTTPS。');
});
}
}
startServer();
module.exports = app;
+55
查看文件
@@ -0,0 +1,55 @@
'use strict';
// 自测:生成一个 KDBX4(主密码 + keyfile,KDF=Argon2),再用服务器的同款逻辑复读,
// 验证 Argon2 胶水层 + keyfile 服务端读取在 Node 下确实可用。
// 注意:本机 Windows Defender 会拦截 .key 扩展名写入,故自测用 .bin 扩展名 + 临时目录,
// 与容器内 /app/vault/vault.key 的解密逻辑完全一致。
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { Kdbx, Credentials, ProtectedValue, Consts } = require('kdbxweb');
const { loadDatabase } = require('./kdbxlib');
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kdbx-selftest-'));
const kdbxPath = path.join(dir, '_selftest.kdbx');
const keyPath = path.join(dir, '_selftest.bin'); // 非 .key,规避本地安全软件拦截
// 32 字节随机密钥文件
const keyBytes = crypto.randomBytes(32);
fs.writeFileSync(keyPath, keyBytes);
const keyAb = keyBytes.buffer.slice(keyBytes.byteOffset, keyBytes.byteOffset + keyBytes.byteLength);
const masterPassword = 'TestMaster123!';
const creds = new Credentials(ProtectedValue.fromString(masterPassword), keyAb);
const db = Kdbx.create(creds, 'SelfTestDB');
db.setKdf(Consts.KdfId.Argon2); // 强制用 Argon2,专门验证 Argon2 胶水
const group = db.createGroup(db.getDefaultGroup(), 'Login');
const e = db.createEntry(group);
e.fields.set('Title', 'Example');
e.fields.set('UserName', 'alice');
e.fields.set('Password', ProtectedValue.fromString('s3cret'));
e.fields.set('URL', 'https://example.com');
e.fields.set('Notes', 'self-test note');
const saved = await db.save();
fs.writeFileSync(kdbxPath, Buffer.from(saved));
console.log('已生成测试库:', kdbxPath);
// 用服务器同款逻辑重新加载
const rec = await loadDatabase(kdbxPath, keyPath, masterPassword);
console.log('解锁成功,库名:', rec.name, '条目数:', rec.items.length);
const ok = rec.items.some(
(i) => i.title === 'Example' && i.username === 'alice' && i.password === 's3cret'
);
if (!ok) {
console.error('❌ 自测失败:条目内容不匹配');
process.exit(1);
}
console.log('✅ 自测通过:Argon2 + keyfile 解密在 Node 下可用');
})().catch((e) => {
console.error('❌ 自测异常:', e);
process.exit(1);
});