# 暗格 - EncryptedBox **Repository Path**: lanyun417/encrypted-box ## Basic Information - **Project Name**: 暗格 - EncryptedBox - **Description**: 一个零知识加密的个人密码管理器。所有加解密都在浏览器本地完成,服务端只保存密文——它既看不到你的密码,也看不到你的主密码。 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-09-13 - **Last Updated**: 2026-09-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 暗格 · EncryptedBox 一个**零知识加密**的个人密码管理器。所有加解密都在客户端本地完成,服务端只保存密文——它既看不到你的条目内容,也看不到你的主密码。 | 端 | 技术栈 | | --- | --- | | 网页端 `web/` | Vue 3 + TypeScript + TailwindCSS v4 + Pinia | | 安卓端 `app/` | uni-app(Vue 3 + Vite + TS),纯 JS 加密(`@noble/hashes` + `@noble/ciphers`) | | 后端 `server/` | Express + TypeScript,数据以 JSON 文件存储(串行队列 + 原子写) | - **加密**:PBKDF2-SHA256(310,000 轮)→ HKDF → AES-256-GCM - **密文格式**:`Base64(IV[12B] + 密文 + GCM 认证标签)` - 三端共用同一套密钥派生与密文格式,**同一份数据可以跨端解锁** --- ## 核心特性 - **零知识架构**:主密码永不上传,服务端无法解密任何条目 - **双存储模式**:可选「本地」(浏览器 IndexedDB / App 本机)或「云端」(自建后端),可随时切换 - **冲突调和**:两端数据不一致时逐条对比,可**查看两端的完整内容**(用户名 / 密码 / 网址 / 备注 …)后再决定保留哪端;支持按端批量选择,以及本地覆盖云端 / 云端覆盖本地 / 两端一致 - **服务端访问口令(可选)**:给自建后端加一把「连接密码」,客户端每个请求携带;内置**测试连接**按钮,一次验证「地址可达 + 口令正确」 - **密码生成器**:长度 8–64、四类字符自由组合、可排除易混淆字符(`0/O`、`1/l/I`);拒绝采样避免取模偏差 + Fisher-Yates 洗牌 - **密码强度评估**:综合长度、字符种类、常见弱密码与重复 / 连续模式 - **分类与收藏**:按分类聚合,支持收藏置顶 - **搜索与排序**:本地解密后按名称 / 账号 / 网址 / 备注搜索,可按更新时间或名称排序 - **修改主密码**:生成全新 salt 与密钥,本地全量重加密后由服务端原子替换,其他设备的登录态同时失效 - **自动锁定**:默认 15 分钟无操作自动锁定(可选 5 / 15 / 30 分钟或永不) - **备份与恢复**:导出明文 JSON 备份,支持重新导入 - **单端口部署**:构建后由后端直接托管前端产物,一个端口跑完整应用 --- ## 效果图(Web 端) | 解锁 / 创建 | 保险库首页 | | ----------- | ---------- | | ![解锁](screenshot/解锁.png) | ![首页](screenshot/首页.png) | | 创建条目(含密码生成器) | 设置 | | ------------------------ | ---- | | ![创建条目](screenshot/创建条目.png) | ![设置](screenshot/设置.png) | --- ## 安全设计 ### 密钥派生流程 ``` 主密码(永不上传) │ │ PBKDF2-SHA256 · 310,000 轮 · salt(16B) │ salt 可由服务端公开获取,本身不是机密 ▼ 主密钥(256 bit,仅存于内存) │ ├─ HKDF(info = "encrypted-box:auth") ──► authKey │ 发送给服务端,服务端只保存 SHA-256(authKey) │ └─ HKDF(info = "encrypted-box:enc") ──► encKey AES-256-GCM,不可导出,用于加解密条目 ``` > ⚠️ 代码里的 `encrypted-box:v1` / `encrypted-box:auth` / `encrypted-box:enc` 是 HKDF 的 salt 与 info,**本身就是密钥派生算法的一部分**。改动它们会让已存条目永久无法解密。同理,备份文件的 `encrypted-box-backup` 格式标识、JWT issuer、各 storage key 也都不要为了「统一命名」而修改。 ### 服务端存了什么 `server/data/vault.json`: ```jsonc { "version": 1, "user": { "username": "...", "salt": "...", // PBKDF2 盐值,公开无害 "authKeyHash": "...", // SHA-256(authKey),用于比对身份 "tokenVersion": 1, // 自增后可使所有已签发 JWT 失效 }, "entries": [ { "id": "...", "payload": "...", // Base64 密文,服务端不解析、也无法解密 "createdAt": "...", "updatedAt": "...", }, ], } ``` 服务端**不存储**主密码、`authKey` 原文或任何明文条目。 ### 威胁模型 | 场景 | 结论 | 说明 | | --------------------------------- | ------------- | ------------------------------------------ | | JSON 数据文件泄露 | ✅ 安全 | 攻击者只拿到密文和 `authKey` 的 SHA-256 | | 服务端被入侵 | ✅ 安全 | 服务端不接触明文,也不接触主密码 | | 传输过程被窃听 | ⚠️ 视部署而定 | 本地访问无风险;公网部署**必须**启用 HTTPS | | 主密码强度不足 | ❌ 不安全 | 密文可被离线暴力破解,请使用强主密码 | | 本机感染恶意软件 / 恶意浏览器扩展 | ❌ 不安全 | 解锁期间内存中存在明文密钥与条目 | | 忘记主密码 | ❌ 不可恢复 | 无后门、无找回机制,数据永久无法解密 | **请务必自行备份主密码。** 它不会被保存到任何地方。 --- ## 快速开始 ### 环境要求 - Node.js **22+**(前端要求 `^22.18.0 || >=24.12.0`,后端要求 `>=20`) - npm 10+ ### 安装 项目包含四个 `package.json`(根 / `web` / `server` / `app`): ```bash npm install && npm --prefix server install && npm --prefix web install # 安卓端 app/ 是 uni-app 项目(可选,只在开发 App 时需要) npm --prefix app install ``` ### 开发 ```bash npm run dev ``` - 前端:http://localhost:5173(Vite,已配置 `/api` 代理到后端) - 后端:http://localhost:3001 也可以只启动其中一端: ```bash npm run dev:server npm run dev:web ``` 安卓端(`app/`,uni-app)单独开发:H5 预览走 vite 的 `/api` 代理;真机运行 / 打 APK 用 HBuilderX 打开 `app/` 目录。 ```bash npm --prefix app run dev:h5 ``` ### 生产构建与运行 ```bash npm run build # 前端产物 → server/public,随后编译后端 → server/dist npm start # 启动后端,单端口提供完整应用 ``` 访问 http://localhost:3001 即可。后端会自动托管 `server/public`;若该目录不存在,后端仍可单独提供 API。 ### 其他命令 ```bash npm run type-check # 前后端类型检查 # 生成一份测试保险库(会覆盖 server/data,仅供本地开发) node server/scripts/seed-test-vault.mjs [用户名] [主密码] ``` --- ## 项目结构 ``` encrypted-box/ ├── web/ 网页端 │ └── src/ │ ├── api/ 接口封装(fetch + 令牌管理 + 访问口令) │ ├── assets/ 全局样式(Tailwind 入口)与 logo │ ├── components/ UI 组件(密码生成器、强度条、连接密码字段…) │ ├── composables/ 自动锁定 │ ├── crypto/ ★ 零知识加密核心(密钥派生 / 加解密) │ ├── router/ 路由与解锁守卫 │ ├── stores/ Pinia:auth / vault / toast │ ├── storage/ 存储偏好与本地保险库(IndexedDB) │ ├── utils/ 密码生成、强度评估、格式化 │ └── views/ 解锁页、保险库主页、设置页、冲突调和页 ├── app/ 安卓端(uni-app:Vue3 + Vite + TS) │ └── src/ │ ├── api/ uni.request 封装(Bearer + 401 强制锁定 + 访问口令) │ ├── components/ UI 组件(内联 SVG 图标、连接密码字段…) │ ├── composables/ auth / vault 状态 │ ├── crypto/ ★ 零知识加密核心(@noble 纯 JS,与 web 逐字节兼容) │ ├── pages/ 解锁 / 保险库 / 条目 / 设置 / 冲突调和 │ ├── storage/ 存储偏好与本地保险库 │ ├── polyfills.ts ★ 补齐 App 端缺失的 TextEncoder / Object.hasOwn(缺了会白屏) │ └── utils/ 随机数 / 摘要 / 格式化 / 密码生成 ├── server/ 后端 │ └── src/ │ ├── middleware/ JWT 鉴权、服务端访问口令 │ ├── routes/ auth / entries │ ├── scripts/ 测试保险库生成脚本(仅本地开发用) │ ├── async.ts 异步路由异常捕获 │ ├── auth.ts JWT 签发与校验 │ ├── config.ts 配置与密钥加载 │ ├── store.ts JSON 读写(串行队列 + 原子写) │ └── utils.ts 校验与摘要 ├── server/public/ 前端构建产物(vite outDir,已 gitignore) ├── server/data/ 运行时数据(已 gitignore) └── server/接口文档.md ★ 完整的接口请求/响应示例、校验规则与错误码 ``` --- ## 配置 后端配置通过 `server/.env` 覆盖,模板见 `server/.env.example`: | 变量 | 默认值 | 说明 | | ----------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `PORT` | `3001` | 服务监听端口 | | `JWT_SECRET` | 自动生成 | JWT 签名密钥。留空则首次启动随机生成并持久化到 `data/server.json`;**改为自定义值会让已签发的令牌全部失效**,需重新解锁 | | `JWT_EXPIRES_IN` | `12h` | 登录态有效期,支持 `30m` / `2h` / `7d` 等写法 | | `CORS_ORIGIN` | `http://localhost:5173` | 允许的前端来源,多个用逗号分隔,`*` 表示不限制 | | `DATA_DIR` | `./data` | 数据文件目录 | | `CLIENT_DIR` | `./public` | 前端构建产物目录 | | `ACCESS_PASSWORD` | 空(不启用) | 服务端访问口令(详见下方) | 生成高强度密钥 / 口令: ```bash openssl rand -hex 32 ``` ### 服务端访问口令(可选) 把后端部署在公网时,建议设置 `ACCESS_PASSWORD`。启用后: - 除 `GET /api/health` 外的所有 `/api` 请求都必须带 `X-Access-Password` 头,否则返回 **403** - `GET /api/health` 是唯一免口令接口,响应带 `accessRequired`,客户端据此提示用户填写 - **刻意用 403 而不是 401**:客户端对任何 401 都会当作「会话失效」而清空本地状态;口令不对只是连不上这台后端,不该影响已有登录态 - 客户端在「云端连接」里填写同一个口令,随每个请求发送;口令不参与加解密 - 口令填好后输入框默认**只读**(遮罩显示 + 可显示/隐藏),点「修改」才可编辑 - web 与 App 都提供**测试连接**按钮,可先验证再保存 --- ## 存储模式与冲突调和 - **本地模式**:数据存浏览器 IndexedDB(web)/ 本机(App),完全离线;卸载或清缓存会丢失,请自行备份 - **云端模式**:数据存自建后端,条目仍是客户端加密后的密文,服务端无法解密 - 切换模式、或两端都存在数据且不一致时,进入**冲突调和页**: - 逐条展示「冲突项 / 单端条目 / 一致条目」 - 每条可点**查看详情**,看到该条在两端的完整字段(密码可显隐),对比后再选保留哪端 - 可一键「冲突项全选本地 / 全选云端 / 全部保留 / 全部删除」 - 结果可「应用到 两端 / 仅云端 / 仅本地」 --- ## API 所有接口前缀为 `/api`。除标注外均需 `Authorization: Bearer `。 > 完整的请求/响应示例、校验规则、错误码与时序图见 **[`server/接口文档.md`](server/接口文档.md)**。 ### 认证 | 方法 | 路径 | 鉴权 | 说明 | | ---- | ----------------------- | ---- | -------------------------------------------------------- | | GET | `/health` | — | 健康检查(免访问口令,返回 `accessRequired`) | | GET | `/auth/status` | — | 保险库是否已初始化 | | GET | `/auth/salt?username=` | — | 取回 PBKDF2 盐值 | | POST | `/auth/setup` | — | 创建主密码并初始化保险库 | | POST | `/auth/login` | — | 用 `authKey` 换取 JWT | | POST | `/auth/lock` | 是 | 自增 `tokenVersion`,使所有令牌失效 | | GET | `/auth/me` | 是 | 取回用户公开记录(供写入本地存储) | | POST | `/auth/sync` | 是 | 整体原子替换保险库(冲突调和用) | | POST | `/auth/change-password` | 是 | 修改主密码:提交用新密钥重加密的全部条目,服务端原子替换 | `POST /auth/change-password` 的 body: ```json { "oldAuthKey": "<旧主密码派生的认证密钥>", "newSalt": "<新生成的 16 字节 salt>", "newAuthKey": "<新主密码派生的认证密钥>", "entries": [{ "id": "…", "payload": "<用新 encKey 加密的密文>" }] } ``` 服务端会校验 `oldAuthKey`,并要求 `entries` 与现有条目**完全一致**(数量、ID 都要对上),否则返回 409 且不做任何写入——避免出现「新密钥 + 旧密文」这种谁都解不开的中间态。当前主密码填错返回 **403**。 ### 条目 | 方法 | 路径 | 说明 | | ------ | ---------------- | ------------------------------------------------------ | | GET | `/entries` | 列出全部条目(密文) | | POST | `/entries` | 新建条目,body: `{ payload }` | | POST | `/entries/batch` | 批量导入,body: `{ payloads: [...] }`,单次上限 500 条 | | PUT | `/entries/:id` | 更新条目密文 | | DELETE | `/entries/:id` | 删除条目 | 错误响应统一为 `{ "error": "描述" }`,并配合 400 / 401 / 403 / 404 / 409 / 413 状态码。 > **401 与 403 的分工**:401 表示「令牌失效」(客户端会强制锁定并退回解锁页);403 表示「凭证不对但会话没失效」,例如当前主密码填错或访问口令错误。 --- ## 数据存储 - 位置:`server/data/`(可用 `DATA_DIR` 修改) - 写入方式:**串行队列 + 临时文件 rename 原子写**,避免并发覆盖或进程中断导致 JSON 截断 - 单个条目密文上限约 512KB 明文 - ⚠️ 后端会把 vault **缓存在内存中**,直接改 `vault.json` 需**重启后端**才生效 --- ## 备份与恢复 在**设置页**可导出备份。 > ⚠️ **导出的 JSON 是明文**,包含所有密码。请妥善保管,用后及时删除。 - **web 端**:导出即下载 `.json`;导入选择文件 - **App 端**:uni-app 无系统文件选择器,导出为**复制到剪贴板**,导入为**粘贴 JSON**(或从剪贴板粘贴) 导入时客户端会逐条重新加密后批量提交,服务端仍然只接收密文。 --- ## 已知限制 以下功能尚未实现: - **回收站** —— 删除是永久性的 - **TOTP / 二次验证** 其他限制: - 由于服务端不解析密文,**搜索与排序只能在客户端进行**,条目量极大时会有性能开销 - 出于零知识设计,后端无法提供任何找回途径 - 修改主密码需要一次性提交全部条目密文,受 2MB 请求体限制,条目极多时可能触发 413 - App 端(`app/`)尚未打 APK;随机数依赖运行时的 `crypto.getRandomValues`,若缺失会退化并打印告警,需要时可接原生安全随机源 --- ## 常见问题 **为什么刷新页面就要重新解锁?** `encKey` 只存在于内存中且不可导出。持久化它等于把密钥明文写进浏览器存储,会彻底破坏安全模型。 **为什么搜索有点慢 / 必须先解锁?** 解密需要 `encKey`,搜索需要明文,两者都只在解锁后可用。 **忘记主密码怎么办?** 无法找回。只能删除 `server/data/` 后重新初始化(原有数据不可恢复)。 **修改主密码时数据是怎么处理的?** 全部重加密在客户端内存中完成:用当前 `encKey` 解出明文 → 生成新 salt 并派生新密钥 → 用新 `encKey` 加密每一条 → 一次性提交给服务端原子替换。服务端只做「密文整体换一批」,看不到任何明文。过程中请勿关闭页面,建议先导出备份。 **App 一打开就白屏?** App 的 app-service 引擎缺少 `TextEncoder` / `Object.hasOwn` 等 API,而加密库在**模块加载期**就会用到。项目已用 `app/src/polyfills.ts` 补齐——**它必须是相关模块的第一个 import**,不要调整顺序;新增第三方库若用到较新的 API 也需一并在那里补齐。