Joplin 端到端加密(E2EE)数据格式深度解析——从同步快照密文看 JED 加密结构与元数据保护
【免费下载链接】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)机制让笔记、标签、资源等所有数据在离开设备前即完成加密,同步服务器只能看到密文。本文以仓库内 app-cli 测试套件中的一份真实加密同步快照为解剖样本,逐字段拆解 Joplin 的密文存储格式(JED 头、AES-CCM 参数、PBKDF2 密钥派生)、主密钥管理与解密工作流,并对照源码给出可验证的底层原理,帮助你完整理解 Joplin E2EE 的实现细节,也为自研加密同步系统或审计 Joplin 数据安全提供可直接引用的参考。
一、快照文件在 Joplin 测试体系中的定位
本文分析的样本位于 packages/app-cli/tests/support/syncTargetSnapshots/3/e2ee/c9e46ce958bb4bd5a3d0cfb65855d9d2.md。从目录结构看,syncTargetSnapshots下按1/、2/、3/三个版本号划分,每个版本内又有e2ee/(加密场景)与normal/(未加密场景)两个子目录——这意味着 Joplin 通过"跨版本快照 + 加密/非加密双路径"的组合来回归验证同步目标对历史数据格式的兼容性。
每个快照目录都配套一份 info.json,记录该快照场景的同步配置与主密钥状态。本样本对应场景的关键状态为:
{ "version": 3, "e2ee": { "value": true, "updatedTime": 1628355817270 }, "activeMasterKeyId": { "value": "1f3b6b71948c4f5d909d1af6588c78bb", "updatedTime": 1628355817333 }, "masterKeys": [{ "id": "1f3b6b71948c4f5d909d1af6588c78bb", "encryption_method": 4, ... }] }也就是说,这是一份E2EE 已开启、主密钥已建立的同步场景快照。同一目录下的 00dceec04659436196bae6b56eea10ad.md(笔记)与 07cd0925745b4441b898288f000c93d8.md(标签)则是与本样本关联的加密笔记与标签条目,三者共同构成一组完整的加密关联数据。
二、加密条目的逐字段拆解
该快照文件正文即为一个加密同步条目的完整序列化结果。先看除密文外的元数据字段:
| 字段 | 值 | 说明 |
|---|---|---|
id | c9e46ce958bb4bd5a3d0cfb65855d9d2 | 条目自身 ID |
note_id | 00dceec04659436196bae6b56eea10ad | 关联笔记 ID |
tag_id | 07cd0925745b4441b898288f000c93d8 | 关联标签 ID |
created_time/updated_time | 空 /2021-08-07T17:03:37.269Z | 创建与更新时间 |
user_created_time/user_updated_time | 空 | 用户侧时间戳 |
encryption_cipher_text | JED010000... | 加密后的内容密文(见下一节) |
encryption_applied | 1 | 已应用加密的标记 |
is_shared | 空 | 共享状态 |
type_ | 6 | 条目类型编号 |
结合note_id与tag_id两个字段可以推断,type_为 6 对应的是笔记与标签的关联项(NoteTag),即"某笔记被打上某标签"这条关系记录本身也被端到端加密。这体现了 Joplin E2EE 的一个重要设计:不仅正文加密,所有关联关系与元数据同样进入密文,同步端无法从字段结构中反推笔记之间的标签关系。
在 packages/lib/services/database/types.ts 中可以看到,数据库模型统一以type_字段区分条目类型,而 packages/lib/services/e2ee/EncryptionService.ts 的itemIsEncrypted正是通过检查item.encryption_applied && isValidHeaderIdentifier(item.encryption_cipher_text)来判断条目是否已加密——加密标记与密文格式校验缺一不可。
三、JED 密文格式:头标识、长度与元数据载荷
encryption_cipher_text的值是本文的核心,其完整结构为:
JED01000022051f3b6b71948c4f5d909d1af6588c78bb0002d8{...base64 JSON...}可以切分为三段:
JED010000:格式标识符。JED是 Joplin Encryption Data 的缩写,010000为版本号。在 EncryptionService.ts 中,isValidHeaderIdentifier负责校验该前缀,任何不符合标识规则的密文都会被判定为"实际未加密"或非法数据(对应Invalid encryption identifier异常)。2205:紧随标识符的十六进制数,表示其后 JSON 元数据部分的字节长度(0x2205 = 8709字节)。源码 decodeHeaderSource_ 在读取标识符后,会先解析这个长度字段,再按该长度截取并解码元数据块。0002d8之后的 base64 JSON:即实际加密参数与密文载荷(见下节),源码 decodeHeaderBytes_ 负责将其还原为结构化对象,而 encodeHeader_ 负责在加密时按相同格式反向组装。
之所以把"加密参数 + 密文"以自描述方式嵌入头部,是为了让任何持有密钥的客户端都能独立解密:每条数据自带盐、迭代次数、IV 等参数,无需依赖外部配置,这在多端同步场景下是保证互操作性的关键设计。
四、AES-CCM 与 PBKDF2:加密参数逐一解读
剥离头标识与长度字段后,密文 JSON 的明文结构如下(基于样本解码):
{ "iv": "Ks7In3VN+ukwOfMigv5aGg==", "v": 1, "iter": 101, "ks": 128, "ts": 64, "mode": "ccm", "adata": "", "cipher": "aes", "salt": "tVgmTCWSasM=", "ct": "5PCqJZzapvNh8PwCONuOoEFRS+dY0RKHBCZrgEd5rsBNCDHyW9H9GL7s/..." }各参数含义如下:
| 参数 | 样本值 | 含义 |
|---|---|---|
iv | base64(16 字节) | 初始化向量,AES-CCM 每次加密使用随机 IV |
v | 1 | 元数据结构版本 |
iter | 101 | PBKDF2 密钥派生迭代次数 |
ks | 128 | 密钥长度(bit),本条目为 128 位 |
ts | 64 | CCM 认证标签长度(bit),即 8 字节 |
mode | ccm | 加密模式,Joplin 使用 AES-CCM(带认证加密) |
adata | 空字符串 | 关联数据(Associated Data),可绑定上下文防止密文替换 |
cipher | aes | 底层加密算法 |
salt | base64 | PBKDF2 随机盐 |
ct | base64 | 密文(含认证标签) |
这套参数组合意味着:Joplin 采用AES-CCM 认证加密(同时保证机密性与完整性),密钥由PBKDF2从主密钥派生,盐与迭代次数逐条目随机/独立记录。iter数值越小,单条派生越快;而主密钥本身的保护则使用更强的参数(见下节)。
在 EncryptionService.ts 中,decodeHeaderString(cipherText)正是从类似本样本的字符串中还原出上述全部参数后,才执行后续的密钥派生与解密。
五、主密钥与密钥层级:从 info.json 看密钥管理
配套的 info.json 给出了主密钥的完整形态。activeMasterKeyId指向当前激活的主密钥1f3b6b71948c4f5d909d1af6588c78bb,而masterKeys数组中该密钥的content同样是密文,其内嵌参数为:
{ "iv": "...", "v": 1, "iter": 10000, "ks": 256, "ts": 64, "mode": "ccm", "adata": "", "cipher": "aes", "salt": "...", "ct": "..." }对比可见两个关键差异:
iter10000 vs 101:主密钥本体使用 10000 次 PBKDF2 迭代、256 位密钥保护,强度远高于数据条目;条目级密钥只需低迭代快速派生,用于加解密高频读写的内容。encryption_method: 4:显式标记主密钥的加密方案版本,解密端据此选择对应的派生与解密算法。
这构成了 Joplin E2EE 的典型两层密钥结构:用户口令/主密钥密码 → 保护主密钥(高迭代派生)→ 主密钥 → 派生各条目的内容密钥(低迭代、带随机盐与 IV)。加密条目中记录的masterKeyId(样本中为1f3b6b71948c4f5d909d1af6588c78bb,与info.json的activeMasterKeyId完全一致)正是把条目与具体主密钥绑定起来的纽带。
六、解密工作流与源码对应关系
围绕encryption_cipher_text,Joplin 的加解密链路在源码中有多处落点,与本文样本一一对应:
- 加密判定:EncryptionService.ts 的
itemIsEncrypted校验encryption_applied与 JED 头标识,决定条目是否需要解密。 - 头部编解码:encodeHeader_ 组装
JED010000 + 长度 + JSON,decodeHeaderString / decodeHeaderSource_ 反向解析,与样本密文的第三段格式完全吻合。 - 后台解密服务:DecryptionWorker.ts 与配套的 DecryptionWorker.test.ts 负责在同步后批量解密加密条目。
- 冲突处理与内存解密:loadConflictData.ts、decryptNoteInMemory.ts 表明冲突数据在合并前也需先解密,可见 E2EE 贯穿同步、冲突、搜索等全部数据通路。
- 测试验证:EncryptionService.test.ts 中通过
decodeHeaderString(cipherText)验证头部解析,并在 L312 断言解密后的条目携带encryption_cipher_text字段——与本文样本字段结构互为印证。
七、快照的工程价值:回归测试与格式兼容
这份快照文件本身就是 Joplin 工程体系的一部分:syncTargetSnapshots目录被用于同步目标的回归快照测试。其价值体现在:
- 格式冻结:将真实加密条目固化为测试数据,任何对加密格式、序列化方式或同步处理的改动,都必须保证新旧快照仍能按预期解析与解密,防止"改一个字段破坏历史数据"。
- 跨版本兼容:
1/、2/、3/三套快照分别代表不同历史阶段的格式,e2ee/与normal/双路径则覆盖"加密/未加密"两种状态,确保升级后的代码仍能读写旧版本数据。 - 可读可审计:快照以 Markdown 形式明文保存密文与字段,便于人工审查加密参数是否合理、字段是否遗漏,也为外部研究者提供了无需运行客户端即可研究 E2EE 格式的入口。
对于希望深入理解或二次实现 Joplin E2EE 的开发者,建议按以下路径阅读仓库:
- 加密/解密核心:packages/lib/services/e2ee/EncryptionService.ts
- 解密调度:packages/lib/services/DecryptionWorker.ts
- 条目类型定义:packages/lib/services/database/types.ts
- 加密快照全集:packages/app-cli/tests/support/syncTargetSnapshots/3/e2ee/
结语
通过解剖这份type_ = 6的加密快照,我们完整还原了 Joplin E2EE 的核心数据格式:JED010000头标识 + 十六进制长度 + 自描述 JSON 加密参数 + 密文的四段式结构,AES-CCM 认证加密与 PBKDF2 密钥派生的参数体系,以及"主密钥高迭代保护、条目密钥低迭代派生"的两层密钥设计。这些设计共同保证了即使同步服务器完全暴露,攻击者也无法还原笔记内容乃至笔记与标签的关联关系。快照文件作为回归测试资产,则为这一格式的长期稳定与跨版本兼容提供了工程保障——理解这一份密文,也就理解了 Joplin 隐私承诺的技术根基。
【免费下载链接】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),仅供参考