从零开发一款 Obsidian 密码管理器插件:MinePass 技术实践
摘要: 本文介绍了一款基于 Obsidian 生态开发的本地加密密码管理器插件 MinePass 的完整开发过程。从开发初衷、功能设计,到 AES-256-GCM 加密、Argon2id 密钥派生、CSPRNG 密码生成、TOTP 双因素验证等核心技术实现,全面展示如何在 Electron 环境下构建一个安全可靠的密码管理工具。
一、开发初衷与功能介绍
1.1 为什么要在 Obsidian 里做密码管理器?
作为一个重度 Obsidian 用户,我每天都在 Obsidian 里整理笔记、写文档、做知识管理。但有一个痛点始终困扰着我:密码管理和其他笔记工具是割裂的。
市面上有很多优秀的密码管理器——KeePass、Bitwarden、1Password 等,但它们都是独立应用。每次需要复制密码时,都要切换到另一个应用,解锁、搜索、复制,再切回来。对于追求"一个工具搞定一切"的 Obsidian 用户来说,这种体验割裂感太强了。
同时,我也对现有方案的安全模型有所顾虑:
- 云同步方案(Bitwarden、1Password):密码存储在第三方服务器上,即使加密,也存在信任问题
- 本地方案(KeePass):虽然安全,但功能相对陈旧,TOTP、密码健康检查等功能需要额外插件
- 浏览器扩展:容易被恶意扩展读取剪贴板和表单数据
于是我想:能不能在 Obsidian 里做一个类似 KeePass 的本地加密密码管理器? 数据完全本地存储,零网络请求,同时拥有现代化的功能体验。这就是 MinePass 的诞生背景。
1.2 功能概览
MinePass 是一款 KeePass 风格的加密密码管理器,以 Obsidian 插件的形式运行。所有密码存储在 Obsidian 保管库内的本地加密文件中——无需云服务、无需第三方 API、零网络请求。
核心功能包括:
| 功能模块 | 说明 |
|---|---|
| 🔐 AES-256-GCM 加密 | 认证加密,含头部完整性校验 |
| 🔑 双 KDF 支持 | PBKDF2-HMAC-SHA256(60万次)与 Argon2id(WASM)可切换 |
| 📁 分组管理 | 标签式条目/分组视图,搜索过滤 |
| 🎲 安全密码生成 | CSPRNG 拒绝采样,消除模偏差 |
| 🔐 密钥文件 | 可选第二因素,存储在用户主目录 |
| ⏱️ TOTP 验证码 | 内置 RFC 6238 实现,可视化倒计时 |
| 📊 密码健康检查 | 弱密码检测、重复密码检测 |
| 📋 JSON 导入 / 导出 | 统一格式,含分组、TOTP、时间戳 |
| 🔄 自动备份 | 每次写入前保留最多 3 份旋转备份 |
| ⏰ 自动锁定 | 闲置超时 + 窗口失焦立即锁定 |
| 🛡️ 解锁频率限制 | 指数退避防暴力破解 |
| 🌐 多语言 | 英文、中文、跟随 Obsidian 语言 |
1.3 与 KeePass 2 的对比
| 维度 | KeePass 2 | MinePass |
|---|---|---|
| 加密算法 | AES-256-CBC / ChaCha20 | AES-256-GCM(认证加密) |
| 密钥派生 | Argon2id(多档) | PBKDF2 60万次 + Argon2id(64MB) |
| 完整性校验 | SHA-256 哈希 | GCM AAD 认证头部 + 密文 |
| 备份 | 手动导出 | 自动轮换备份(最多 3 份) |
| TOTP | 需插件 | 内置,带可视化倒计时 |
| 密码健康 | 需插件 | 内置 |
| 内存保护 | 原生 C# + DPAPI | Uint8Array.fill(0) + WASM 堆 |
| 审计历史 | 20+ 年 | 新项目,无第三方审计 |
MinePass 的优势在于开箱即用的现代化体验——TOTP、密码健康检查、自动备份全部内置,无需额外插件。同时无缝集成在 Obsidian 生态中,密码与笔记同处一个平台。
⚠️ 坦诚说明: MinePass 是初期项目,未经过第三方安全审计。对于高敏感凭据,建议评估威胁模型后选择 KeePass 2 或 Bitwarden 等成熟方案。
二、技术架构与实现
2.1 整体架构
MinePass 采用纯 TypeScript 开发,基于 Obsidian Plugin API 构建,所有密码学操作依赖 Web Crypto API 和 WASM 模块。
graph TB
subgraph "插件层"
Main[main.ts<br/>生命周期<br/>主密码管理<br/>自动锁定<br/>解锁频率限制<br/>命令注册]
end
subgraph "视图层"
VV[vault-view.ts<br/>条目/分组视图<br/>TOTP 显示<br/>搜索<br/>右键菜单<br/>剪贴板]
ST[settings.ts<br/>KDF 切换<br/>导入/导出<br/>健康检查<br/>密钥文件管理]
end
subgraph "对话框"
UM[unlock-modal.ts<br/>解锁/创建保管库]
EEM[edit-entry-modal.ts<br/>新增/编辑条目]
CPM[change-pwd-modal.ts<br/>修改主密码]
HM[health-modal.ts<br/>健康报告]
RM[reset-vault-modal.ts<br/>重置保险库]
CM[confirm-modal.ts<br/>通用确认/输入]
end
subgraph "核心服务"
VT[vault.ts<br/>文件 I/O<br/>备份轮换<br/>密钥文件读写]
CR[crypto.ts<br/>AES-256-GCM 加解密<br/>头部编解码<br/>V1->V2 升级]
KF[kdf.ts<br/>PBKDF2<br/>Argon2id<br/>密钥文件绑定]
end
subgraph "功能模块"
GN[generator.ts<br/>CSPRNG 密码生成器]
TP[totp.ts<br/>TOTP RFC 6238]
HL[health.ts<br/>弱密码/重复检测]
end
subgraph "基础设施"
EN[encoding.ts<br/>Base64/Latin1 编解码]
I18[i18n.ts<br/>国际化 en/zh]
TY[types.ts<br/>类型定义与常量]
end
subgraph "运行时依赖"
WC[Web Crypto API<br/>AES-GCM<br/>PBKDF2<br/>HMAC<br/>SHA<br/>CSPRNG]
HW[hash-wasm<br/>Argon2id WASM]
end
Main --> VV
Main --> ST
Main --> UM
Main --> VT
Main --> CR
VV --> EEM
VV --> CPM
VV --> HM
VV --> CM
VV --> VT
VV --> TP
ST --> RM
ST --> HL
ST --> GN
ST --> VT
ST --> CR
VT --> CR
CR --> KF
CR --> EN
KF --> WC
KF --> HW
CR --> WC
TP --> WC
GN --> WC
项目目录结构:
src/
├── main.ts # 插件入口,生命周期管理
├── crypto.ts # AES-256-GCM 加解密
├── kdf.ts # PBKDF2 / Argon2id 密钥派生
├── vault.ts # 文件读写、备份轮换、密钥文件管理
├── types.ts # 类型定义与常量
├── generator.ts # CSPRNG 密码生成器(拒绝采样)
├── totp.ts # TOTP 验证码 (RFC 6238)
├── health.ts # 密码健康检查
├── encoding.ts # Base64/Latin1 编解码
├── i18n.ts # 国际化 (en/zh)
├── settings.ts # 设置面板
├── styles.css # 界面样式
├── views/
│ └── vault-view.ts # 主视图:条目/分组/TOTP/搜索
├── modals/
│ ├── unlock-modal.ts # 解锁/创建保管库
│ ├── edit-entry-modal.ts # 新增/编辑条目
│ ├── change-master-password-modal.ts # 修改主密码
│ ├── health-modal.ts # 健康报告
│ ├── confirm-modal.ts # 通用确认/输入框
│ └── reset-vault-modal.ts # 重置保险库
└── *.test.ts # 单元测试 (Vitest, 7 files)
2.2 加密体系设计
2.2.1 加密流程总览
sequenceDiagram
participant User as 用户
participant UI as 解锁/创建界面
participant Plugin as 插件主进程
participant KDF as 密钥派生模块
participant Crypto as AES-GCM 加密模块
participant FS as 文件系统
User->>UI: 输入主密码 (+ 密钥文件)
UI->>Plugin: 提交凭据
Plugin->>KDF: deriveKey(password, salt, keyFile)
alt PBKDF2
KDF->>KDF: PBKDF2-HMAC-SHA256 (60万次)
else Argon2id
KDF->>KDF: Argon2id (opslimit=3, 64MB)
end
alt 有密钥文件
KDF->>KDF: HMAC-SHA256(KDF输出, keyFile)
end
KDF-->>Plugin: 返回 256-bit 密钥
Plugin->>Crypto: encrypt(明文, 密钥, keyFile)
Crypto->>Crypto: 生成 32B 随机盐值
Crypto->>Crypto: 生成 12B 随机 IV
Crypto->>Crypto: 构建 85B 二进制头部
Crypto->>Crypto: AES-256-GCM 加密 (头部作为 AAD)
Crypto-->>Plugin: 返回 base64 编码密文
Plugin->>FS: 写入 .mine-pass-vault.mpvault
FS-->>Plugin: 写入成功
Plugin-->>UI: 解锁/创建完成
创建完成后,日常的增删改操作会触发整库重新加密保存流程:
sequenceDiagram
participant User as 用户
participant UI as 编辑界面
participant Plugin as 插件主进程
participant VaultMgr as VaultManager
participant Crypto as AES-GCM 加密模块
participant FS as 文件系统
User->>UI: 添加/编辑/删除条目
UI->>Plugin: saveVaultData()
Plugin->>Plugin: JSON.stringify(VaultData)<br/>序列化全部条目+分组+设置
Plugin->>VaultMgr: saveVault(json, password)
VaultMgr->>VaultMgr: 备份轮换<br/>(.bak.2→.bak.3, .bak.1→.bak.2, 原文件→.bak.1)
VaultMgr->>Crypto: encrypt(json, password)<br/>新盐值 + 新 IV + 新头部
Crypto-->>VaultMgr: 返回 base64 密文
VaultMgr->>FS: 写入 .tmp 临时文件
VaultMgr->>FS: 删除原文件
VaultMgr->>FS: 重命名 .tmp → .mpvault
FS-->>VaultMgr: 写入完成
VaultMgr-->>Plugin: 保存成功
Plugin-->>UI: 更新视图
每次保存都会生成全新的随机盐值和 IV,整个 VaultData(条目 + 分组 + 设置)以 JSON 序列化后整体重新 AES-256-GCM 加密,配合先写 .tmp 再替换、自动备份轮换,保证数据一致性和可恢复性。
2.2.2 保管库文件格式(V2)
MinePass 采用自定义二进制头部 + AES-GCM 密文的文件格式:
┌─────────────────────────────────────────────────────────────────┐
│ Vault File (V2) │
├──────────────┬──────────┬───────────────────────────────────────┤
│ Header │ IV │ Ciphertext + Auth Tag │
│ 85 bytes │ 12 bytes │ variable length │
└──────────────┴──────────┴───────────────────────────────────────┘
Header (85 bytes):
┌────────┬────────┬────────┬───────┬────────┬──────────┬──────────┐
│ Magic │Version │ Flags │ KDF │Created │ KDF P1 │ KDF P2 │
│ 2 bytes│ 1 byte │ 1 byte │1 byte │ 8 bytes│ 4 bytes │ 4 bytes │
│ 0x4D50 │ 0x01 │ 0x00/01│ 0/1 │Unix ms │iter/ops │0/memlimit│
├────────┴────────┴────────┴───────┴────────┴──────────┴──────────┤
│ KeyFile Hash (32 bytes) │
│ (SHA-256 of key file, or zeros) │
├─────────────────────────────────────────────────────────────────┤
│ Salt (32 bytes) │
│ (random, per encryption) │
└─────────────────────────────────────────────────────────────────┘
不同色块代表不同字段区域,括号内标注字节偏移量及大小:
Header (85 bytes) IV (12B) Ciphertext (variable)
┌─────────────────────────────────────┬─────────────┬──────────────────────────────┐
│ ████████████████████████████████████│ ░░░░░░░░░░░░│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │
│ ███ 魔数/版本/KDF/盐值/密钥文件hash ███│ ░░ Nonce ░░ │ ▒▒ GCM 密文 + 16B Auth Tag ▒▒ │
│ ████████████████████████████████████│ ░░░░░░░░░░░░│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │
└─────────────────────────────────────┴─────────────┴──────────────────────────────┘
Header 内部布局:
┌──────┬────┬────┬────┬──────────┬────────┬────────┬──────────────────┬────────────────────┐
│ Magic│ Ver│Flag│KDF │ Created │ KDF P1 │ KDF P2 │ KeyFile Hash │ Salt │
│ 2 B │1 B │1 B │1 B │ 8 B │ 4 B │ 4 B │ 32 B │ 32 B │
│0x4D50│0x01│bit0│0/1 │Unix ms │iter/ops│0/memlim│ SHA-256 of file │ random │
├──────┼────┼────┼────┼──────────┼────────┼────────┼──────────────────┼────────────────────┤
│[0..1]│ [2]│ [3]│ [4]│ [5..12] │[13..16]│[17..20]│ [21..52] │ [53..84] │
└──────┴────┴────┴────┴──────────┴────────┴────────┴──────────────────┴────────────────────┘
注:解密时
Header + IV作为 GCM AAD 传入,任何字节的篡改都会导致认证失败。
关键字段说明:
| 字段 | 大小 | 说明 |
|---|---|---|
| Magic | 2 bytes | 0x4D 0x50("MP"),用于识别 V2 格式 |
| Version | 1 byte | 文件格式版本号 |
| Flags | 1 byte | 位标志,bit 0 = 是否使用密钥文件 |
| KDF Type | 1 byte | 0 = PBKDF2, 1 = Argon2id |
| Created At | 8 bytes | 创建时间戳(uint64 LE) |
| KDF P1/P2 | 4+4 bytes | KDF 参数(迭代次数/操作次数/内存限制) |
| KeyFile Hash | 32 bytes | 密钥文件 SHA-256 哈希,用于快速校验 |
| Salt | 32 bytes | 随机盐值,每次加密独立生成 |
2.2.3 头部完整性保护
MinePass 使用 GCM 模式的 AAD(Additional Authenticated Data) 机制来保护头部完整性:
graph LR
A[Header 85B] -->|作为 AAD| C[AES-256-GCM]
B[Plaintext JSON] -->|加密数据| C
D[IV 12B] -->|Nonce| C
E[256-bit Key] -->|密钥| C
C --> F[Ciphertext + 16B Auth Tag]
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#e8f5e9
style F fill:#fce4ec
这意味着:任何对头部的篡改都会在解密时被检测到,GCM 认证标签会验证失败。这是相比 KeePass 2(使用单独的 SHA-256 哈希)更优雅的方案——加密和完整性保护在同一操作中完成。
2.2.4 密钥派生(Dual KDF)
graph TB
subgraph "输入"
PW["主密码<br/>string 或 Uint8Array"]
Salt["32B 随机盐值"]
KF["密钥文件<br/>32B 随机数(可选)"]
end
subgraph "KDF 选择"
Choice{KDF Type}
Choice -->|PBKDF2| PBKDF2["PBKDF2-HMAC-SHA256<br/>600,000 次迭代"]
Choice -->|Argon2id| Argon["Argon2id<br/>opslimit=3, 64MB"]
end
PBKDF2 --> KDF_Output["256-bit 派生密钥"]
Argon --> KDF_Output
subgraph "密钥文件绑定 (可选)"
KDF_Output -->|有密钥文件| HMAC["HMAC-SHA256<br/>key=KDF输出, msg=keyFile"]
HMAC --> FinalKey["最终 256-bit 密钥"]
KDF_Output -->|无密钥文件| FinalKey
end
FinalKey --> AES["AES-256-GCM 密钥"]
style PW fill:#e3f2fd
style Salt fill:#e3f2fd
style KF fill:#e3f2fd
style FinalKey fill:#c8e6c9
style AES fill:#c8e6c9
PBKDF2-HMAC-SHA256:600,000 次迭代,通过 Web Crypto API 原生实现,兼容性好。
Argon2id:通过 hash-wasm 库以 WASM 形式运行,内存硬函数,抗 GPU/ASIC 攻击。默认 opslimit=3、memory=64MB,派生时间约 3 秒。
密钥文件绑定:当启用密钥文件时,KDF 输出会通过 HMAC-SHA256 与密钥文件内容绑定,确保两者缺一不可。
KDF 并非只在创建时执行,而是每次加密保存和每次解密解锁时都会执行。每次保存会生成新的 32B 随机盐值重新派生,确保同一密码每次加密结果不同。
sequenceDiagram
participant Action as 操作
participant Encrypt as encrypt() / decrypt()
participant KDF as KDF 模块
participant File as .mine-pass-vault.mpvault
Note over Action: ── 创建 / 保存时 (加密) ──
Action->>Encrypt: saveVaultData()
Encrypt->>Encrypt: 生成 32B 新随机盐值
Encrypt->>KDF: deriveKey(pwd, 新盐值, keyFile)
KDF->>KDF: PBKDF2(60万次) 或 Argon2id(64MB)
KDF-->>Encrypt: 256-bit 密钥
Encrypt->>Encrypt: AES-256-GCM 加密
Encrypt->>File: 写入(头部含新盐值)
Note over Action: ── 解锁时 (解密) ──
Action->>File: 读取保管库文件
File->>Encrypt: 返回 encoded + 头部
Encrypt->>KDF: deriveKey(pwd, 头部.盐值, keyFile)
KDF->>KDF: PBKDF2(60万次) 或 Argon2id(64MB)
KDF-->>Encrypt: 256-bit 密钥
Encrypt->>Encrypt: AES-256-GCM 解密
Encrypt-->>Action: 返回明文 VaultData
2.3 内存安全设计
在 JavaScript 环境中管理敏感数据是一个挑战,因为 JavaScript 字符串是不可变的,无法主动清零。MinePass 采取了以下策略:
sequenceDiagram
participant User as 用户输入
participant Modal as 解锁界面
participant Plugin as 插件主进程
participant Memory as 内存管理
User->>Modal: 输入主密码 (string)
Modal->>Plugin: setMasterPassword(password)
Plugin->>Memory: TextEncoder.encode(password)<br/>→ Uint8Array
Plugin->>Memory: 旧 buffer.fill(0) 清零
Note over Plugin,Memory: 主密码以 Uint8Array 存储<br/>而非 string
loop 使用期间
Plugin->>Memory: 获取 buffer 引用<br/>用于加解密
end
Plugin->>Memory: lockVault()
Plugin->>Memory: buffer.fill(0) 归零
Plugin->>Memory: buffer = null 释放引用
Plugin->>Memory: keyFileContent.fill(0)
Plugin->>Memory: keyFileContent = null
Note over Memory: 敏感数据从内存中清除<br/>等待 GC 回收
关键措施:
- 主密码以
Uint8Array存储,而非string——Uint8Array.fill(0)可以直接覆写内存字节 - 修改密码时传递 buffer 而非解码字符串——避免在错误恢复路径中产生新的字符串副本
- 锁定时显式清零所有敏感 buffer——
masterPasswordBuffer.fill(0)+keyFileContent.fill(0) - Argon2id 在 WASM 堆中运行——密钥材料不经过 JavaScript 堆内存
- 窗口失焦立即锁定——减少敏感数据在内存中的暴露窗口
⚠️ 局限性: JavaScript 环境的攻击面仍然比原生应用大。
Uint8Array.fill(0)可以减少暴露,但无法完全阻止内存转储攻击。
2.4 密码生成器:消除模偏差
很多人不知道,用 Math.random() 或简单取模来生成随机密码会引入模偏差(Modulo Bias)——字符集中每个字符的出现概率不完全相等。
graph TB
A["crypto.getRandomValues<br/>CSPRNG"] --> B["Uint32Array 随机值"]
B --> C{"值 < maxValid?"}
C -->|是| D["charset (value % len)"]
C -->|否| E["丢弃,重新采样"]
E --> A
D --> F["确保每个字符<br/>概率完全相等"]
style A fill:#e8f5e9
style D fill:#c8e6c9
style E fill:#fff3e0
style F fill:#c8e6c9
MinePass 使用 crypto.getRandomValues()(CSPRNG)配合拒绝采样法:zai
function unbiasedRandomChar(chars: string): string {
const max = 0xFFFFFFFF - (0xFFFFFFFF % chars.length);
const buf = new Uint32Array(1);
let rand: number;
do {
crypto.getRandomValues(buf);
rand = buf[0];
} while (rand >= max); // 拒绝偏差值
return chars[rand % chars.length];
}
生成过程分三步:
- 确保每个字符集至少有一个字符被选中
- 从合并字符集中随机填充剩余位置
- Fisher-Yates 洗牌算法打乱顺序
2.5 TOTP 双因素验证
sequenceDiagram
participant UI as 条目卡片
participant Timer as 共享定时器<br/>(200ms tick)
participant TOTP as TOTP 模块
participant Crypto as Web Crypto API
UI->>TOTP: 注册条目 (secret, codeSpan, timeBar)
TOTP->>Timer: startSharedTOTPTimer()
loop 每 200ms
Timer->>Timer: 计算当前 30s 窗口
Timer->>Timer: 更新所有 timeBar 宽度
alt 进入新时间窗口
Timer->>TOTP: 触发重新计算
loop 每个条目
TOTP->>TOTP: base32Decode(secret)
TOTP->>TOTP: counter = floor(now / 30)
TOTP->>Crypto: HMAC-SHA1(key, counter_bytes)
Crypto-->>TOTP: 32B HMAC 结果
TOTP->>TOTP: 动态截断 (RFC 4226)
TOTP->>TOTP: code = value % 1000000
TOTP-->>UI: 更新 codeSpan.textContent
end
end
end
TOTP 实现严格遵循 RFC 6238:
- Base32 解码密钥
- 时间计数器 =
floor(当前时间 / 30) - HMAC-SHA1 签名(通过 Web Crypto API)
- 动态截断算法(RFC 4226 Section 5.4)
- 输出 6 位数字验证码
TOTP 密钥由服务端生成(如 GitHub、Google 等在启用 2FA 时提供 Base32 密钥或二维码),MinePass 仅作为客户端存储该密钥并计算验证码,不生成 TOTP 密钥。
所有条目的 TOTP 共享一个 200ms 的定时器,避免为每个条目创建独立的 setInterval。
2.6 自动锁定机制
stateDiagram-v2
[*] --> 未解锁
未解锁 --> 已解锁: 输入正确主密码
state 已解锁 {
[*] --> 活跃
活跃 --> 活跃: 用户活动<br/>(keydown/mousedown/touchstart)
活跃 --> 活跃: 重置定时器
活跃 --> 超时等待: 无操作达到设定时间
超时等待 --> 已锁定: 自动锁定
活跃 --> 已锁定: 窗口失焦 + 闲置定时器关闭
超时等待 --> 活跃: 用户活动
}
已锁定 --> 未解锁: 清除敏感数据<br/>重置失败计数
已解锁 --> 未解锁: 插件卸载 (onunload)
自动锁定触发条件:
- 闲置超时:可配置(默认 5 分钟),每次用户活动重置计时器
- 窗口失焦:仅在闲置超时设为 0(关闭定时器)时触发——将失焦作为唯一自动锁定机制
- 插件卸载:Obsidian 关闭或插件禁用时自动锁定
📷 [插图预留:自动锁定流程的时序图或状态机图]
2.7 备份与文件安全
graph TB
subgraph "保存流程"
A["内存中的 VaultData"] --> B["JSON.stringify"]
B --> C["crypto.encrypt"]
C --> D["写入 .tmp 临时文件"]
D --> E{"原文件存在?"}
E -->|是| F["删除原文件"]
E -->|否| G["跳过"]
F --> H["重命名 .tmp → 正式文件"]
G --> H
H --> I["清理 .tmp 文件"]
end
subgraph "备份轮换"
J["原文件内容"] --> K[".bak.3 ← .bak.2"]
K --> L[".bak.2 ← .bak.1"]
L --> M[".bak.1 ← 原文件"]
end
style D fill:#fff3e0
style H fill:#c8e6c9
style I fill:#e8f5e9
每次保存前自动执行备份轮换,最多保留 3 份历史备份,防止保管库损坏导致数据丢失。
2.8 解锁频率限制
graph LR
A[第 1 次失败] -->|延迟 1s| B[第 2 次失败]
B -->|延迟 2s| C[第 3 次失败]
C -->|延迟 4s| D[第 4 次失败]
D -->|延迟 8s| E[第 5 次失败]
E -->|延迟 16s| F[第 6 次失败]
F -->|延迟 30s| G[后续失败<br/>最大 30s]
H[成功解锁] -->|重置| A
I[锁定保管库] -->|重置| A
style A fill:#e3f2fd
style G fill:#ffcdd2
style H fill:#c8e6c9
style I fill:#c8e6c9
指数退避策略:delay = min(1000 * 2^(n-1), 30000),防止暴力破解。
2.9 技术栈与依赖
| 技术 | 用途 |
|---|---|
| TypeScript 5.3 | 类型安全的开发语言 |
| esbuild | 快速构建打包 |
| Web Crypto API | AES-GCM、PBKDF2、HMAC-SHA256、SHA-256 |
| hash-wasm | Argon2id WASM 实现 |
| Vitest | 单元测试框架 |
| Obsidian Plugin API | 插件生命周期、视图、设置面板 |
graph LR
subgraph "宿主环境"
OBS["Obsidian 桌面版<br/>Electron + Chromium"]
end
subgraph "开发语言"
TS["TypeScript 5.3"]
end
subgraph "构建与测试"
ES["esbuild 打包"]
VT["Vitest 单元测试"]
end
subgraph "密码学能力"
WC["Web Crypto API<br/>AES-GCM · PBKDF2 · HMAC · SHA · CSPRNG"]
HW["hash-wasm<br/>Argon2id"]
end
TS --> ES
TS --> VT
TS --> WC
TS --> HW
WC --> OBS
HW --> OBS
三、开发过程中的关键决策与踩坑记录
3.1 为什么选择 AES-256-GCM 而不是 CBC?
KeePass 2 默认使用 AES-256-CBC,需要单独的 HMAC 来验证完整性。GCM 模式将加密和认证合二为一,更简洁也更安全。在 Obsidian 的 Electron 环境中,Web Crypto API 原生支持 GCM,无需额外依赖。
3.2 密钥文件为什么放在用户主目录?
最初考虑放在 Obsidian 保管库内,但这样会导致云同步时密钥文件和加密文件一起被同步,失去"第二因素"的意义。放在用户主目录(~/mine-pass-{vault_name}/.mine-pass-key)可以:
- 避免云同步冲突
- 按保管库隔离,多保管库场景下互不干扰
- 用户可以在不同设备上使用不同的密钥文件
3.3 多设备同步注意事项
如果 Obsidian 使用 S3、WebDAV、Obsidian Sync 等方式将保管库同步到多台设备,需要注意以下几点:
保管库文件会自动同步:.mine-pass-mpvault/ 目录在保管库内,会随 Obsidian 同步自动复制到所有设备。
密钥文件不会自动同步:密钥文件存储在用户主目录(~/mine-pass-{vault_name}/.mine-pass-key),不在保管库内,因此不会随 Obsidian 同步。需要在每台设备上手动复制密钥文件到对应路径,或者重新生成。
检查同步范围:建议确认同步配置是否排除了 .mine-pass-mpvault/ 目录——如果不小心排除了,保管库文件不会同步到其他设备,导致在新设备上无法打开。
简单来说:加密文件靠云同步,钥匙(密钥文件)靠手动搬运。两者分开传输,恰好符合安全设计——即使云同步服务被攻破,攻击者拿到的也只有密文,没有密钥文件仍然无法解密。
3.4 为什么导入/导出统一使用 JSON?
最初导入使用 CSV 格式,导出使用 JSON 格式,两者不一致。统一为 JSON 的原因:
- 格式一致:导入和导出完全对称,导出的文件可以直接重新导入
- 更丰富的字段:JSON 天然支持嵌套结构,可以携带 group、TOTP Secret、createdAt、updatedAt 等 CSV 无法表达的字段
- 无需解析库:
JSON.parse/JSON.stringify是 JavaScript 原生 API,不引入额外依赖 - 安全边界:JSON 解析没有 CSV 的注入攻击风险(CSV 中的公式注入、脚本注入等)
3.4 导入时的去重策略
导入时如果目标保管库中已存在同名条目或相同凭据组合(用户名+密码),会跳过而非覆盖,避免意外丢失数据。跳过详情会在通知中显示(如"JSON 导入成功 (8 entries), 2 skipped")。
3.5 内存管理的教训
在修改主密码功能中,最初错误恢复路径使用了 new TextDecoder().decode(oldBuffer) 将 buffer 转回字符串——这会在 JavaScript 堆中创建新的不可变字符串副本,即使后续 fill(0) 也无法清除。修复方案是直接传递 Uint8Array 给 setMasterPassword。
flowchart LR
subgraph "❌ 错误做法"
direction TB
A1["修改密码失败<br/>oldBuffer(Uint8Array)"] --> A2["new TextDecoder().decode(oldBuffer)<br/>→ string(不可变)"]
A2 --> A3["setMasterPassword(string)<br/>→ TextEncoder 重新编码<br/>→ 堆中留下不可清除的 string"]
A3 --> A4["fill(0) 只清了 Uint8Array<br/>string 未被 GC,残留内存中"]
end
subgraph "✅ 正确做法"
direction TB
B1["修改密码失败<br/>oldBuffer(Uint8Array)"] --> B2["直接传递 oldBuffer<br/>保持 Uint8Array 不变"]
B2 --> B3["setMasterPassword(buffer)<br/>→ new Uint8Array(buffer)<br/>无 string 副本产生"]
B3 --> B4["lockVault() 时<br/>fill(0) 精准覆写所有字节"]
end
style A4 fill:#ffcdd2
style B4 fill:#c8e6c9
四、总结与展望
4.1 项目成果
MinePass 实现了以下目标:
- ✅ 纯本地存储,零网络请求
- ✅ 工业级加密(AES-256-GCM + Argon2id)
- ✅ 开箱即用的 TOTP 和密码健康检查
- ✅ 无缝集成 Obsidian 生态
- ✅ 54 个单元测试覆盖核心逻辑
4.2 已知局限
- 未经第三方安全审计——加密实现依赖 Web Crypto API 的正确性,但集成逻辑可能存在未发现的设计缺陷
- JavaScript 内存模型——无法像原生应用那样精确控制内存,
Uint8Array.fill(0)是最佳实践但不是银弹 - Electron 攻击面——Obsidian 基于 Electron,浏览器环境的攻击面比原生应用更大
4.3 未来规划
- 自动填充支持(通过 Obsidian 模板语法)
- 保管库迁移工具(从 KeePass XML 导入)
- 生物识别解锁(利用 Electron 原生模块)
- 更细粒度的权限控制(只读模式、共享模式)
附录
A. 项目结构
src/
├── main.ts # 插件入口,生命周期管理
├── crypto.ts # AES-256-GCM 加解密
├── kdf.ts # PBKDF2 / Argon2id 密钥派生
├── vault.ts # 文件读写、备份轮换
├── types.ts # 类型定义与常量
├── generator.ts # CSPRNG 密码生成器
├── totp.ts # TOTP 验证码 (RFC 6238)
├── health.ts # 密码健康检查
├── settings.ts # 设置面板
├── i18n.ts # 国际化 (en/zh)
├── encoding.ts # Base64/Latin1 编解码
├── views/
│ └── vault-view.ts # 主视图 (ItemView)
└── modals/
├── unlock-modal.ts
├── edit-entry-modal.ts
├── change-master-password-modal.ts
├── health-modal.ts
├── confirm-modal.ts
└── reset-vault-modal.ts
B. 参考资源
- Web Crypto API MDN 文档
- NIST SP 800-38D: GCM 模式规范
- RFC 6238: TOTP 算法
- RFC 4226: HOTP 算法(动态截断)
- Argon2 论文
- hash-wasm 库
- Obsidian Plugin API 文档
开源地址: Gitee
许可证: MIT
免责声明: 本项目处于初期阶段,未经过第三方安全审计。对于高敏感凭据,请评估您的威胁模型并考虑使用 KeePass 2 或 Bitwarden 等成熟方案。