commit 02e83f5b73ef633b90633116cc52996ebed50620 Author: wangchuanli Date: Wed Aug 26 10:42:48 2026 +0800 feat: 初始化 kdbx-viewer 项目 实现服务端解密的 KeePass 网页查看器,包含登录门户口令与验证码、RSA+会话级 AES 加密通道、审计日志持久化、HTTPS 自动证书、Docker 部署配置及端到端测试。 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..14d1bcf --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..36b06d9 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c84683 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +ssl/ +logs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8997c8e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fde1ec1 --- /dev/null +++ b/README.md @@ -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. +``` diff --git a/config.js b/config.js new file mode 100644 index 0000000..45f25e5 --- /dev/null +++ b/config.js @@ -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; diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..c055e28 --- /dev/null +++ b/cookies.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..70fe544 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/e2e-test.js b/e2e-test.js new file mode 100644 index 0000000..135956d --- /dev/null +++ b/e2e-test.js @@ -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()]); +} diff --git a/gen-cert.js b/gen-cert.js new file mode 100644 index 0000000..ccbbd6c --- /dev/null +++ b/gen-cert.js @@ -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'); +} diff --git a/kdbxlib.js b/kdbxlib.js new file mode 100644 index 0000000..6b121a9 --- /dev/null +++ b/kdbxlib.js @@ -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 }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0b86e64 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..24344fd --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..b46d388 --- /dev/null +++ b/public/app.js @@ -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) {} +})(); diff --git a/public/audit.html b/public/audit.html new file mode 100644 index 0000000..52c8c63 --- /dev/null +++ b/public/audit.html @@ -0,0 +1,76 @@ + + + + + + 审计日志 - KeePass 查看器 + + + +
+
+

审计日志

+
+ ← 返回主界面 + +
+
+ +
+ + + + + + + + + + +
+ + + +
+ + + + + + + + +
时间来源 IP类型直连地址XFF方法路径状态码错误码UA详情会话
+
+ + 第 1 页 + + + +
+
+
+ + + diff --git a/public/audit.js b/public/audit.js new file mode 100644 index 0000000..4635565 --- /dev/null +++ b/public/audit.js @@ -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' ? '内网' + : e.ipType === 'external' ? '外网' : '-'; + tr.innerHTML = ` + ${fmtTime(e.t)} + ${esc(e.ip)} + ${ipTypeLabel} + ${esc(e.remote)} + ${esc(e.xff && e.xff !== '-' ? e.xff : '-')} + ${esc(e.method)} + ${esc(e.path)} + ${e.status || '-'} + ${esc(e.code)} + ${esc(e.ua)} + ${esc(e.detail || '')} + ${esc(e.sid)}`; + return tr; +} +function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +async function load() { + const params = new URLSearchParams(); + params.set('page', String(currentPage)); + params.set('pageSize', String(PAGE_SIZE)); + const ip = $('fIp').value.trim(); if (ip) params.set('ip', ip); + const ipType = $('fIpType').value; if (ipType) params.set('iptype', ipType); + const code = $('fCode').value; if (code) params.set('code', code); + const method = $('fMethod').value; if (method) params.set('method', method); + const pathF = $('fPath').value.trim(); if (pathF) params.set('path', pathF); + if ($('fFail').checked) params.set('fail', '1'); + const from = $('fFrom').value; if (from) params.set('from', new Date(from).toISOString()); + const to = $('fTo').value; if (to) params.set('to', new Date(to).toISOString()); + try { + const data = await apiEnc('/api/audit?' + params.toString()); + const body = $('auditBody'); + body.innerHTML = ''; + (data.log || []).forEach((e) => body.appendChild(renderRow(e))); + $('pageInfo').textContent = '第 ' + (data.page || 1) + ' 页(每页 ' + PAGE_SIZE + ')'; + $('totalInfo').textContent = ' 命中 ' + (data.total || 0) + ' 条'; + // 基于索引显示全量日志规模,便于核对分页正确性 + const idx = await apiEnc('/api/audit/index').catch(() => null); + if (idx) { + const files = (idx.files || []).map((f) => `${f.file}[${f.startSeq}-${f.endSeq},${f.count}条]`).join(' '); + $('indexInfo').textContent = ` 索引总条数 ${idx.total} | 文件分布: ${files}`; + } + } catch (e) { + alert('加载审计日志失败:' + (e && e.message ? e.message : e)); + } +} + +async function loadBlocks() { + try { + const data = await apiEnc('/api/audit/blocks'); + const box = $('blocksBox'); + const list = $('blocksList'); + list.innerHTML = ''; + if (data.blocks && data.blocks.length) { + box.style.display = ''; + data.blocks.forEach((b) => { + const li = document.createElement('li'); + li.innerHTML = `${esc(b.ip)} 失败 ${b.fails} 次,封锁至 ${fmtTime(b.expiresAt)} + `; + 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(); +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3eea79e --- /dev/null +++ b/public/index.html @@ -0,0 +1,68 @@ + + + + + + KeePass 查看器 + + + +
+ + +
+

KeePass 查看器

+

请输入访问口令

+ +
+ + +
+ +

+
+ + + + + + + + + + +
+ + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..f08b45a --- /dev/null +++ b/public/style.css @@ -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; } diff --git a/server.js b/server.js new file mode 100644 index 0000000..5e7a9e3 --- /dev/null +++ b/server.js @@ -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 += ``; + 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[i]}`; + } + // 干扰线 + for (let i = 0; i < 3; i++) { + 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; diff --git a/test-decrypt.js b/test-decrypt.js new file mode 100644 index 0000000..65354e8 --- /dev/null +++ b/test-decrypt.js @@ -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); +});