从零开发一款 Obsidian 密码管理器插件:MinePass 技术实践

2 阅读13分钟

从零开发一款 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、零网络请求

image-20260517161814173.png

image-20260517161841066.png

PixPin_2026-05-17_16-56-01.gif 核心功能包括:

功能模块说明
🔐 AES-256-GCM 加密认证加密,含头部完整性校验
🔑 双 KDF 支持PBKDF2-HMAC-SHA256(60万次)与 Argon2id(WASM)可切换
📁 分组管理标签式条目/分组视图,搜索过滤
🎲 安全密码生成CSPRNG 拒绝采样,消除模偏差
🔐 密钥文件可选第二因素,存储在用户主目录
⏱️ TOTP 验证码内置 RFC 6238 实现,可视化倒计时
📊 密码健康检查弱密码检测、重复密码检测
📋 JSON 导入 / 导出统一格式,含分组、TOTP、时间戳
🔄 自动备份每次写入前保留最多 3 份旋转备份
⏰ 自动锁定闲置超时 + 窗口失焦立即锁定
🛡️ 解锁频率限制指数退避防暴力破解
🌐 多语言英文、中文、跟随 Obsidian 语言

1.3 与 KeePass 2 的对比

维度KeePass 2MinePass
加密算法AES-256-CBC / ChaCha20AES-256-GCM(认证加密)
密钥派生Argon2id(多档)PBKDF2 60万次 + Argon2id(64MB)
完整性校验SHA-256 哈希GCM AAD 认证头部 + 密文
备份手动导出自动轮换备份(最多 3 份)
TOTP需插件内置,带可视化倒计时
密码健康需插件内置
内存保护原生 C# + DPAPIUint8Array.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 bytes12 bytes │           variable length             │
└──────────────┴──────────┴───────────────────────────────────────┘

Header (85 bytes):
┌────────┬────────┬────────┬───────┬────────┬──────────┬──────────┐
│ Magic  │Version │ Flags  │ KDF   │Created │ KDF P1   │ KDF P2   │
│ 2 bytes1 byte │ 1 byte │1 byte │ 8 bytes4 bytes4 bytes  │
│ 0x4D500x010x00/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 内部布局:
┌──────┬────┬────┬────┬──────────┬────────┬────────┬──────────────────┬────────────────────┐
│ MagicVerFlagKDFCreatedKDF P1KDF P2KeyFile HashSalt         │
│ 2 B1 B1 B1 B8 B4 B4 B32 B32 B          │
│0x4D500x01bit00/1Unix msiter/ops0/memlimSHA-256 of filerandom         │
├──────┼────┼────┼────┼──────────┼────────┼────────┼──────────────────┼────────────────────┤
│[0..1][2][3][4][5..12][13..16][17..20][21..52][53..84]        │
└──────┴────┴────┴────┴──────────┴────────┴────────┴──────────────────┴────────────────────┘

注:解密时 Header + IV 作为 GCM AAD 传入,任何字节的篡改都会导致认证失败。

关键字段说明:

字段大小说明
Magic2 bytes0x4D 0x50("MP"),用于识别 V2 格式
Version1 byte文件格式版本号
Flags1 byte位标志,bit 0 = 是否使用密钥文件
KDF Type1 byte0 = PBKDF2, 1 = Argon2id
Created At8 bytes创建时间戳(uint64 LE)
KDF P1/P24+4 bytesKDF 参数(迭代次数/操作次数/内存限制)
KeyFile Hash32 bytes密钥文件 SHA-256 哈希,用于快速校验
Salt32 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 回收

关键措施:

  1. 主密码以 Uint8Array 存储,而非 string——Uint8Array.fill(0) 可以直接覆写内存字节
  2. 修改密码时传递 buffer 而非解码字符串——避免在错误恢复路径中产生新的字符串副本
  3. 锁定时显式清零所有敏感 buffer——masterPasswordBuffer.fill(0) + keyFileContent.fill(0)
  4. Argon2id 在 WASM 堆中运行——密钥材料不经过 JavaScript 堆内存
  5. 窗口失焦立即锁定——减少敏感数据在内存中的暴露窗口

⚠️ 局限性: 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];
}

生成过程分三步:

  1. 确保每个字符集至少有一个字符被选中
  2. 从合并字符集中随机填充剩余位置
  3. 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)

自动锁定触发条件:

  1. 闲置超时:可配置(默认 5 分钟),每次用户活动重置计时器
  2. 窗口失焦:仅在闲置超时设为 0(关闭定时器)时触发——将失焦作为唯一自动锁定机制
  3. 插件卸载: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 APIAES-GCM、PBKDF2、HMAC-SHA256、SHA-256
hash-wasmArgon2id 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) 也无法清除。修复方案是直接传递 Uint8ArraysetMasterPassword

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 已知局限

  1. 未经第三方安全审计——加密实现依赖 Web Crypto API 的正确性,但集成逻辑可能存在未发现的设计缺陷
  2. JavaScript 内存模型——无法像原生应用那样精确控制内存,Uint8Array.fill(0) 是最佳实践但不是银弹
  3. 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. 参考资源


开源地址: Gitee

许可证: MIT

免责声明: 本项目处于初期阶段,未经过第三方安全审计。对于高敏感凭据,请评估您的威胁模型并考虑使用 KeePass 2 或 Bitwarden 等成熟方案。