Joplin 非正式加密与安全审计全解析:从 OCB2 迁移到现代 E2EE 加密体系
【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin
Joplin 作为一款注重隐私的笔记应用,其核心安全承诺体现在同步过程使用的端到端加密(E2EE)体系。2020 年 4 月,安全专家 Isaac Potoczny-Jones(Tozny 公司 CEO)对 Joplin 的加密实现进行了非正式审计,指出了 OCB2 分组密码模式、冗余密钥扩展、主密钥校验和以及本地明文密码缓存四类问题。本文以这份审计报告(readme/news/20200406-224254.md)为主线,结合当前仓库中 EncryptionService.ts 的完整实现,逐条还原审计结论、修复方案与底层原理,帮助读者理解 Joplin E2EE 的演进脉络,并掌握主密钥升级与数据重加密的完整操作流程。
审计背景与结论概览
这次审计针对的是 Joplin 的加密实现,尤其是同步过程中使用的端到端加密(E2EE)系统。审计者 Isaac Potoczny-Jones 的总体结论是:
我在审阅 Joplin 的加密实现时有一些评论和担忧。我没有看到任何我确认是严重(critical)的问题,但有一些选择和弱点,我想给出一些建议。
审计共提出四类发现,Joplin 团队逐一回应并修复:
| 发现 | 风险等级 | 修复方式 |
|---|---|---|
| OCB2 分组密码模式存在已知弱点 | 安全隐患 | 全部客户端迁移到 CCM 模式,并提供主密钥升级与数据重加密工具 |
| 对随机主密钥执行多余的密钥扩展 | 性能问题 | 笔记加密的 PBKDF2 迭代次数从 1,000 次降到 100 次(源码实际为 101 次) |
| 存储不必要的 SHA-256 主密钥校验和 | 潜在安全隐患 | 新加密方法中彻底移除校验和字段 |
| 本地数据库明文缓存密码 | 本地安全模型 | 引入系统钥匙串(Keychain)服务保存本地密钥 |
这四项修复在今天的 Joplin 源码中均有完整实现,下面逐项展开。
发现一:从 OCB2 到 CCM 的密码模式迁移
审计意见
审计者指出,Joplin 当时选择的 OCB2(Offset Codebook Mode 2)多分组密码模式在近些年暴露出了一些弱点,且它并非 NIST 批准的模式,业界普遍认为它已不再是好的选择。
源码层面的证据:被标记废弃的 OCB2 分支
当前源码 EncryptionService.ts 中,EncryptionMethod.SJCL分支保留了这段历史实现,并明确标注了废弃原因:
// 2020-01-23: Deprecated and no longer secure due to the use og OCB2 mode - do not use. [EncryptionMethod.SJCL]: () => { return sjcl.json.encrypt(key, plainText, { v: 1, // version iter: 1000, ks: 128, // Key size - "128 bits should be secure enough" ts: 64, mode: 'ocb2', // OCB2 mode is slightly faster and has more features, but CCM mode has wider support because it is not patented. cipher: 'aes', }); },同样被废弃的还有用于加密主密钥的EncryptionMethod.SJCL2(L427-L440),它使用 OCB2 模式配合 10,000 次迭代。两个分支的注释都指向同一个结论:OCB2 虽然稍快、特性更多,但 CCM 模式因为不受专利限制而获得更广泛的支持。
修复方案:全部客户端迁移到 CCM
审计后,Joplin 在所有客户端(桌面、移动端、CLI)完成了向 CCM 模式的迁移,并在桌面应用的加密配置界面加入了迁移工具。从源码看,这一过程对应引入了两个新加密方法(L382-L423):
// 2020-03-06: Added method to fix ... Also took the opportunity to change number of key derivations, per Isaac Potoczny's suggestion [EncryptionMethod.SJCL1a]: () => { return sjcl.json.encrypt(key, escape(plainText), { v: 1, iter: 101, // 主密钥已经做过密钥扩展且足够安全,笔记加密时不必再额外迭代,这样解密会更快。SJCL 强制要求 iter 严格大于 100 ks: 128, ts: 64, mode: 'ccm', cipher: 'aes', }); },SJCL1a以 CCM 模式替代 OCB2,密钥长度仍为 128 位;SJCL1b则在 2023 年进一步将密钥升级到 AES-256(L404-L423),注释明确写到 "256-bit is the golden standard that we should follow"。
主密钥升级(Upgrade the master key)
桌面端加密配置界面中的升级主密钥操作,会把主密钥的加密方式转换为 CCM。其底层逻辑在 reencryptMasterKey 中实现:先用旧密码解密主密钥内容,再用新的默认主密钥加密方法重新加密:
public async reencryptMasterKey(model: MasterKeyEntity, decryptionPassword: string, encryptionPassword: string, ...): Promise<MasterKeyEntity> { const newEncryptionMethod = this.defaultMasterKeyEncryptionMethod_; const plainText = await this.decryptMasterKeyContent(model, decryptionPassword, decryptOptions); const newContent = await this.encryptMasterKeyContent( newEncryptionMethod, plainText, encryptionPassword, encryptOptions, ); return { ...model, ...newContent }; }判断哪些主密钥需要升级的逻辑同样在服务层:masterKeysThatNeedUpgrading()会筛选出加密方法不是当前默认方法的所有主密钥(L264-L266),桌面端的 EncryptionConfigScreen.tsx 通过upgradeMasterKey函数驱动这一流程。
数据重加密(Re-encryption)
重加密操作将使用基于 CCM 的新加密方法重新加密全部数据。桌面端加密配置界面中执行此操作时需注意:
- 严格按照界面提示操作;
- 该过程耗时较长,建议安排在夜间或计划好的空闲时段运行;
- 尽管当时尚不完全清楚 OCB2 缺陷如何被实际利用,审计团队仍建议尽快升级数据。
从实现层面看,重加密对每个块执行encrypt()后都会调用crypto.increaseNonce()递增随机数(L598-L614),且每个块之前会写入一个 6 位十六进制的长度前缀,解密时据此逐块切分(L627-L642)。
发现二:消除对随机主密钥的多余密钥扩展
审计意见
审计者注意到,Joplin 的encrypt函数使用 1,000 或 10,000 轮密钥派生(PBKDF2),而其中批量数据加密使用的是随机生成的"主密钥"(master key),并不需要这种针对用户口令的暴力破解防护。审计者判断这可能是批量加密原始数据时的重大性能瓶颈:
加密函数使用了 1k 或 10k 轮密钥派生,其目的是降低针对用户自选密码的暴力破解风险。但批量加密使用的是随机生成的主密钥(假定来自加密强度足够的随机数生成器),并不需要密码扩展。我怀疑这可能是批量加密原始数据的性能问题来源——如果你觉得加密很慢,原因可能就在这。
修复方案:从 1,000 次降到 100 次
这更多是性能问题而非安全问题:旧方案每次加密一条笔记都执行 1,000 次密钥扩展,而主密钥本身已用 10,000 次迭代保护,笔记级加密的额外迭代纯属浪费。修复后,笔记加密只执行约 100 次迭代,桌面端、移动端和 CLI 应用的加解密速度都因此提升。
源码中的迭代次数与审计建议完全对应:
- 主密钥加密(
SJCL4,CCM 模式):iter: 10000,因为要抵抗针对用户口令的暴力破解(L462-L476); - 笔记加密(
SJCL1a/SJCL1b,CCM 模式):iter: 101。源码注释特别说明 SJCL 强制要求迭代次数严格大于 100,因此取 101 而非 100(L392-L393、L413-L414)。
EncryptionService.test.ts(packages/lib/services/e2ee/EncryptionService.test.ts)中对SJCL1a、SJCL1b、KeyV1等新旧方法均有往返加解密测试覆盖,确保迁移后数据的可读性。
分块(chunking)与性能的进一步权衡
密钥扩展只是性能影响的一部分。源码注释中还记录了移动端分块大小的实测数据(L111-L125):在 Android 7.1 模拟器上,解密一个块所需时间随块大小非线性增长——50KB 约 1000ms,5KB 约 10ms,块缩小 10 倍耗时反而降低 100 倍。因此笔记级加密的块大小被设为 5,000 字节,且块大小会写入加密数据头,后续可随时调整。加密流程还会调用shim.waitForFrame()让出执行帧,避免移动端界面在加解密大文件时卡死(L605-L607)。
发现三:移除不必要且可能不安全的 SHA-256 主密钥校验和
审计意见
审计者发现,Joplin 在加密主密钥之外,还额外存储了一份主密码的 SHA-256 校验和:
除了加密之外,你还用 SHA256 生成了主密码的校验和并保存下来。我猜这是为了判断用户密码是否正确。我从未见过这种做法,它让我有些担心,但我不确定这一定是个问题。至少在使用 CCM 模式(我认为 OCB2 也是)时,密码错误就应该无法成功解密。
正如 Cryptography StackExchange 上相关讨论所指出的,在哈希函数的标准模型下,并不要求哈希输出不具备泄露输入信息的属性——校验和的存在可能削弱主密钥的安全性。同时该校验和也是多余的:如果密钥无效,解密算法本身就会失败。
修复方案:让解密失败成为唯一的正确性判据
当前源码中,校验和字段已经基本消失。在encryptMasterKeyContent中,只有历史方法SJCL2才计算校验和,且代码注释直接点明了设计原则(L288-L295):
return { // Checksum is not necessary since decryption will already fail if data is invalid checksum: encryptionMethod === EncryptionMethod.SJCL2 ? this.sha256(hexaBytes) : '', encryption_method: encryptionMethod, content: await this.encrypt(encryptionMethod, password, hexaBytes), };即:校验和不再需要,因为数据无效时解密自然失败。解密侧对应地只在SJCL2分支校验 checksum(L327-L333),其余方法均以解密是否抛错作为密码正确性的判据。密码校验逻辑集中在checkMasterKeyPassword()——尝试解密主密钥内容,成功即认为密码正确(L336-L347)。
正如审计报告所说,这一点也已通过新的主密钥升级工具解决:只要执行过升级,校验和就会从主密钥中移除。
发现四:用系统钥匙串服务保存本地机密
审计意见
审计者注意到,Joplin 在本地数据库中缓存了明文密码:
我注意到你在数据库中缓存了明文密码,这有些令人担忧。但我想你的加密安全模型是针对同步过程而非本地,所以可以理解。存储机密的通用做法是使用钥匙串服务(keychain service),这在几乎所有现代平台上都可用。
修复方案:从本地缓存到系统钥匙串
审计报告解释了当时的设计权衡:密码在本地缓存,是为了避免每次同步加解密笔记时都要重新输入;安全模型假设本地设备本身是安全的。报告同时预告:未来版本将尽可能使用系统钥匙串。
这一承诺已在当前仓库落地为独立的钥匙串服务模块(packages/lib/services/keychain/),采用驱动(Driver)模式适配不同平台:
- Electron 驱动(KeychainServiceDriver.electron.ts):基于 Electron 的
safeStorage,通过safeStorage.encryptString()加密后存入 KvStore,并检查safeStorage.isEncryptionAvailable()判断平台是否支持; - Node 驱动(KeychainServiceDriver.node.ts):面向 CLI 等非 Electron 场景;
- Dummy 驱动(KeychainServiceDriver.dummy.ts):作为不支持钥匙串时的回退实现。
主入口 KeychainService.ts 统一暴露setPassword/password等接口,移动端的加密配置界面(packages/app-mobile/components/screens/encryption-config.tsx)同样集成了钥匙串相关能力。这也是审计报告末尾致谢语境的一部分:这次审计推动 Joplin 在本地机密保护上迈出了实质一步。
超越审计:2024 年后的现代加密体系
审计报告发表于 2020 年,而当前仓库展示了此后数年 Joplin 在加密上的持续演进——这是理解"审计如何长期影响项目"的关键延伸:
原生加密方法引入:
KeyV1(主密钥)、StringV1(笔记文本)、FileV1(附件文件)三种新方法改由原生密码库(Node 的node:crypto/ React Native 的react-native-quick-crypto)驱动,使用AES-256-GCM + PBKDF2,不再依赖 SJCL 的 JS 实现(L478-L517)。其底层实现在 crypto.ts:96 位 IV、16 字节认证标签、密钥长度 32 字节、摘要算法 SHA-512。主密钥派生强度对齐 OWASP:
KeyV1的 PBKDF2 迭代次数设为220,000,源码注释明确引用 OWASP Password Storage Cheat Sheet 的建议(L480-L489);而StringV1/FileV1因主密钥已足够强,迭代次数仅为 3,避免笔记级加解密被密钥派生拖慢。nonce 复用防护:新的加密方法中,主密钥不会直接用于加密数据,而是通过主密钥 + 256 位随机盐派生数据密钥,防止 nonce 复用问题;nonce 由随机部分、7 字节时间戳和 8 字节计数器组成(cryptoShared.ts),计数器用尽时会重新生成随机 nonce。
从审计报告提出的 OCB2 迁移、迭代次数优化、校验和移除到钥匙串集成,再到如今 AES-256-GCM 原生加密体系,Joplin 的 E2EE 演进脉络在 EncryptionService.ts 的每一个EncryptionMethod分支和注释里都有清晰可查的痕迹。对于关注笔记数据安全、或想深入理解端到端加密工程实践(密码模式选型、密钥派生参数、nonce 管理)的开发者而言,这份审计报告配合源码,是一份极具参考价值的完整案例。
【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplin
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考