Joplin 端到端加密(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 是一款以隐私为核心的跨平台笔记应用(Windows、macOS、Linux、Android、iOS),其端到端加密(E2EE)是保障笔记内容安全的关键机制。本文以仓库中真实存储的 E2EE 同步快照文件 packages/app-cli/tests/support/syncTargetSnapshots/1/e2ee/6db42e4e9c0b43ed891269d8a0508c76.md 为标本,结合 EncryptionService.ts、BaseItem.ts 等源码,逐字节拆解 JED 密文头格式、SJCL 负载结构、可保留明文元数据字段,以及同步快照目录的生成与迁移测试机制。读完本文,你将能够独立阅读、验证任意一条 Joplin E2EE 密文记录,并理解快照迁移测试(Migration Handler)如何保证同步版本升级时加密数据不被破坏。
一、E2EE 快照文件是什么:一份真实的密文标本
在packages/app-cli/tests/support/syncTargetSnapshots/1/e2ee/目录下,存放着一批用于测试的历史同步目标快照(snapshot)。其中6db42e4e9c0b43ed891269d8a0508c76.md是一个NoteTag(笔记-标签关联项,type_: 6)在端到端加密后的磁盘序列化形态,全文如下:
id: 6db42e4e9c0b43ed891269d8a0508c76 note_id: 1241f69ccbf54c0188b2f4a02e862b40 tag_id: acbba73d7a1a46848ddc96b96c64ec8b created_time: updated_time: 2020-07-25T10:37:00.415Z user_created_time: user_updated_time: encryption_cipher_text: JED0100002205c24138199f5b403fa3e9b8b4f22685c50002d8{"iv":"4y/iBWttvQyriXqtS2CGHw==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"O2duAuTVjV4=","ct":"0p7EzmL/RzbEWyNcYfAL74fe3biMcNKyzNniXLNFcQphuBFTfLRp7zo6w6wSZ+cX//DTlA7bzPUz4/Vh7KRqbjqdEreSS6ZDRFfc2mT5rMW7msfbxvYOwg4iu3B/FfEUXM55GGHuzHYirkEQvpO0tjI84StZ0vXvUXmXxnOuizcKWrMHWDAAo41pAVIvncOFTnNfYPB+KIlG0WQ1gHgQgclpAYRrm0P/CM/sr/hQQWyH8exc+ToUg0JRmamLs12qHAYmXMlXfwf66vHhYBBGEj8mXErnp0uj6CtENE6AV2QdrEz1QcRa/XwmNb2xDNxS6+UaISzE79UrGNY4uO5om0YQRRhZpmC3A0QUzyd4VF5dEKaar/9W5wXJ/PKSw8l0/KJaNVNGhqLux0mlfEaAhRnzaFkru/JuIGY79hasyJl5l1ANEx9eByRAaLCq8HcQvm7yKva5/7zwHJi/zMVc25sQvmV/owUb+hVFifRfp3azkSj2826kRqJDwc2bXJHRGs7yVCeNuRdoSbAZuve9hUud46MjqOr2/MQ/ml7LQXtufba+WBB0gtheVE12l5ceKps5ejEa6mu2wQ=="} encryption_applied: 1 is_shared: type_: 6这份文件同时揭示了 Joplin E2EE 的两层信息暴露面:
- 以明文保留的“同步关键字段”:
id、note_id、tag_id、updated_time、type_。同步引擎必须依赖这些字段完成增量传输、冲突检测和关系建立,因此它们不能被加密; - 被加密的内容:
created_time、user_created_time、user_updated_time等其余字段被序列化后整体加密进encryption_cipher_text。对 NoteTag 这类关联实体,真正的“正文”几乎都藏在密文里。
二、JED01 密文头:解密的第一道钥匙
所有 Joplin 加密数据都以JED01开头。这个标识符由 EncryptionService.encodeHeader_ 生成,其结构严格对应 headerTemplates_ 中定义的模板版本 1:
| 字段 | 字节数 | 取值(以本快照为例) | 含义 |
|---|---|---|---|
| 标识符 | 3 | JED | Joplin Encrypted Data |
| 模板版本 | 2 | 01 | 头模板版本号 |
| 元数据长度 | 6 | 000022 | 十六进制,表示后面元数据共 0x22 = 34 字节 |
| 加密方法 | 2 | 05 | 十六进制,对应EncryptionMethod枚举 |
| 主密钥 ID | 32 | c24138199f5b403fa3e9b8b4f22685c5 | 用于解密此数据的主密钥 |
对照代码中的 encodeHeader_:
let encryptionMetadata = ''; encryptionMetadata += padLeft(header.encryptionMethod.toString(16), 2, '0'); // 加密方法,占 2 字节 encryptionMetadata += header.masterKeyId; // 主密钥 ID,固定 32 字节 encryptionMetadata = padLeft(encryptionMetadata.length.toString(16), 6, '0') + encryptionMetadata; return `JED01${encryptionMetadata}`;可以手工拆解本文件的密文头JED0100002205c24138199f5b403fa3e9b8b4f22685c5:
JED01:标识符 + 模板版本;000022:元数据区长度 34 字节;05:加密方法 = 5,即EncryptionMethod.SJCL1a;c24138199f5b403fa3e9b8b4f22685c5:32 字节十六进制主密钥 ID,指向同目录下的主密钥记录 c24138199f5b403fa3e9b8b4f22685c5.md。
解密时 decodeHeaderSource_ 会先读取 5 字节调用isValidHeaderIdentifier校验JED\d\d格式(见 EncryptionService.ts),再读 6 字节长度、按模板解析出encryptionMethod与masterKeyId,随后从已加载的主密钥中取出明文密钥对负载解密。
三、SJCL 负载与 EncryptionMethod 演进
密文头之后的{"iv":"...","v":1,...}是 SJCL(Stanford JavaScript Crypto Library)的 JSON 密文格式。本快照的负载参数如下:
| 参数 | 值 | 说明 |
|---|---|---|
v | 1 | SJCL 内部格式版本 |
iter | 101 | PBKDF2 密钥派生迭代次数(SJCL 强制要求 >100) |
ks | 128 | AES 密钥长度 128 位 |
ts | 64 | GCM/CCM 认证标签长度 64 位 |
mode | ccm | AES-CCM 认证加密模式 |
adata | 空 | 未使用关联数据 |
cipher | aes | 加密算法 |
salt | O2duAuTVjV4= | PBKDF2 随机盐 |
ct | base64 | 密文本体 |
iter: 101与ks: 128正好对应 EncryptionService.ts 中EncryptionMethod.SJCL1a的实现:因为主密钥本身已经过密钥派生保护,正文无需再做高开销迭代,101 次即可在移动端保持快速解密;同时正文加密前先做escape()处理,以规避 SJCL 只接受合法 UTF-8 导致包含非法字节的笔记解密失败的问题(修复 issue #2591)。
EncryptionMethod枚举完整定义于 EncryptionService.ts,其演进脉络直接写进了代码注释:
| 枚举 | 值 | 状态 | 关键参数 |
|---|---|---|---|
SJCL | 1 | 已弃用(OCB2 模式不再安全,2020-01-23) | AES-128/OCB2, iter=1000 |
SJCL2 | 2 | 已弃用(曾用于主密钥) | AES-256/OCB2, iter=10000, 带 SHA-256 校验和 |
SJCL3 | 3 | 保留兼容 | AES-128/CCM, iter=1000 |
SJCL4 | 4 | 曾用于主密钥(本快照的主密钥即此方法) | AES-256/CCM, iter=10000 |
SJCL1a | 5 | 正文默认方法(本快照使用) | AES-128/CCM, iter=101, 带 escape |
Custom | 6 | 自定义处理器扩展点 | 由EncryptionCustomHandler提供 |
SJCL1b | 7 | 现行正文方法(2023-06-10 起) | AES-256/CCM, iter=101 |
KeyV1 | 8 | 现行主密钥方法(AES-256-GCM + PBKDF2) | SHA-512 派生, keyLength=32, iterationCount=220000 |
FileV1 | 9 | 现行文件/资源加密方法 | AES-256-GCM, 内容按 base64 处理 |
StringV1 | 10 | 现行字符串正文方法 | AES-256-GCM, 内容按 utf16le 处理 |
快照中的05(SJCL1a)说明这套快照生成于 2020-03 之后、2023-06 之前,与文件头部的updated_time: 2020-07-25T10:37:00.415Z完全吻合。而主密钥记录 c24138199f5b403fa3e9b8b4f22685c5.md 中encryption_method: 4、iter: 10000、mode: ccm正是SJCL4的签名——同一套快照里,正文与主密钥使用不同世代的加密方法,是 Joplin E2EE 版本兼容设计的典型体现。
3.1 主密钥的加载与明文缓存
主密钥并不直接参与正文加解密,而是以“密文主密钥 + 内存明文缓存”的方式管理。核心逻辑在 EncryptionService.loadMasterKey:
encryptedMasterKeys_:只记录主密钥的decrypt回调与updated_time,不持有明文;decryptedMasterKeys_:密码校验成功后缓存{ plainText, updatedTime },并以updated_time参与 isMasterKeyLoaded 的新鲜度判断;- 主动/惰性加载:
makeActive=true时立即解密并设为活动主密钥,否则仅登记回调,待首次使用(loadedMasterKey)时才执行解密。
由于正文解密依赖loadedMasterKey(masterKeyId).plainText(见 masterKeyPlainText_),如果对应的主密钥未加载,会抛出masterKeyNotLoaded错误,并由 BaseItem.encrypt 捕获后派发MASTERKEY_ADD_NOT_LOADED事件,提示用户输入主密钥密码。
四、字段级加密:哪些字段保留明文,为什么
对照同目录下的加密笔记 1241f69ccbf54c0188b2f4a02e862b40.md,可以归纳出两条规律:
- 明文保留的字段:
id、parent_id、note_id、tag_id、updated_time、type_等; - 其余业务字段(标题、正文、创建时间、来源 URL 等)全部进入密文。
这个白名单直接由 BaseItem.encrypt 中的keepKeys数组定义:
// List of keys that won't be encrypted - mostly foreign keys required to link items // with each others and timestamp required for synchronisation. const keepKeys = ['id', 'note_id', 'tag_id', 'parent_id', 'share_id', 'updated_time', 'deleted_time', 'type_', 'is_locked', 'extracted_resource_ids'];从源码注释可以看出设计取舍:外键用于在密文状态下建立实体关联(如 NoteTag 通过note_id/tag_id关联笔记与标签),时间戳用于增量同步与冲突检测,这两类信息对同步引擎是“必需品”,因此保持明文;而真正承载用户隐私的字段全部进入encryption_cipher_text,并以encryption_applied: 1标记。加解密后处理见 BaseItem.decrypt:解密不会改变updated_time(“解密不算修改”),随后以ItemChange.SOURCE_DECRYPTION变更源保存明文版本。
五、快照机制:E2EE 数据如何被复制与迁移
5.1 快照的生成与部署
快照目录结构为syncTargetSnapshots/{syncVersion}/{normal|e2ee}/,其中数字 1/2/3 对应同步目标版本。生成脚本位于 syncTargetUtils.ts 的main():
- 初始化数据库与同步器、切换到客户端 1;
- 通过
createTestData(testData)构造固定的测试数据(含文件夹、带附件的笔记、标签等,见 syncTargetUtils.ts); - 若生成
e2ee快照,则调用setEncryptionEnabled(true)并loadEncryptionMasterKey()加载测试主密钥; - 执行一次完整同步,把
syncDir中的文件复制到syncTargetSnapshots/{version}/{type}/形成快照。
测试侧则通过deploySyncTargetSnapshot('e2ee', migrationVersion - 1)把历史快照复制回同步目录,模拟“旧版本同步目标被新版本客户端打开”的场景(见 synchronizer_MigrationHandler.test.ts)。
5.2 迁移测试如何验证加密数据不被破坏
synchronizer_MigrationHandler.test.ts 是理解快照用途的入口。文件头注释说明了快照的完整生命周期:先用createSyncTargetSnapshot.js normal && createSyncTargetSnapshot.js e2ee生成各版本快照,测试再把 n 版快照升级到 n+1 版。E2EE 分支的关键断言如下:
await deploySyncTargetSnapshot('e2ee', migrationVersion - 1); Setting.setConstant('syncVersion', migrationVersion); await migrationHandler().upgrade(migrationVersion); // ... await synchronizer().start(); const masterKey = (await MasterKey.all())[0]; Setting.setObjectValue('encryption.passwordCache', masterKey.id, '123456'); await loadMasterKeysFromSettings(encryptionService()); await decryptionWorker().start(); await expectNotThrow(async () => await checkTestData(testData));其验证链路是:部署快照 → 升级同步版本 → 同步拉取密文 → 用固定密码123456解密主密钥 → 运行decryptionWorker解密全部条目 → 断言测试数据完整无缺。checkTestData(syncTargetUtils.ts)会逐条加载文件夹、笔记、资源与标签并校验关联关系,从而证明“同步版本升级不破坏任何已加密数据”。这就是为什么快照中会出现明文updated_time——迁移测试需要这些字段来判断数据是否在升级后被意外改写。
六、给开发者的验证与调试建议
- 手工验证密文头:任何 JED 密文都可以用
JED01+ 6 字节长度 + 2 字节方法 + 32 字节主密钥 ID 的规则手工解析,无需先解密负载; - 用测试密码复现解密:迁移测试固定使用密码
123456加载主密钥(见 synchronizer_MigrationHandler.test.ts),对快照调试时可沿用; - 追踪加解密调用链:正文加密从 BaseItem.encrypt 进入
EncryptionService.encryptString(EncryptionService.ts),其核心是encryptAbstract_(EncryptionService.ts)——先写 JED 头,再按chunkSize分块加密、每块前置 6 字节十六进制块长;解密则按块长循环读取,天然支持流式处理大文件; - 关注分块大小:不同方法的分块大小定义在 chunkSize:SJCL 系列与 KeyV1 为 5000 字节、FileV1 为 131072 字节(128KB)、StringV1 为 65536 字节(64KB)。代码注释特别说明移动端解密性能随块大小呈指数恶化(50KB≈1000ms,5KB≈10ms),因此文本数据刻意使用小块;
- 兼容旧快照:仓库保留了多代加密方法(含已弃用的 OCB2 模式),任何新方法上线都必须保留旧方法的解密分支,这解释了 EncryptionMethod 枚举只增不减的原因。
七、总结
一份看似普通的快照文件6db42e4e9c0b43ed891269d8a0508c76.md,完整呈现了 Joplin E2EE 的核心设计:JED01头携带加密方法与主密钥 ID,SJCL/AES-GCM 负载承载被加密的业务字段,keepKeys白名单保留同步必需的外键与时间戳,而整套快照目录则服务于“跨版本升级不破坏密文”的迁移测试体系。理解这一结构,无论是排查解密失败、研究同步协议,还是为 Joplin 开发新的存储后端,都能迅速定位到 EncryptionService.ts、BaseItem.ts 与 syncTargetUtils.ts 这三处核心实现。
【免费下载链接】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),仅供参考