解密 Joplin E2EE 同步快照:从一份加密 Note 文件看懂端到端加密的存储与同步格式
【免费下载链接】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 同步快照文件49e1777d9c17439fb612cce85700d16d.md为切入点,完整拆解这份端到端加密(E2EE)笔记在 v2 同步协议下的序列化结构:包括encryption_cipher_text的JED01加密头部、SJCL/AES-CCM 密文载荷、type_类型码与父级引用,并结合packages/lib中的EncryptionService与syncTargetUtils源码,说明这些快照是如何生成与消费的。读完后,你将能够独立解析任意一份 Joplin 加密 Item 的字段语义,并理解 E2EE 模式下“加密在客户端、存储只存密文”的架构设计。
快照文件的位置与用途
指定文档位于packages/app-cli/tests/support/syncTargetSnapshots/2/e2ee/目录下,是 Joplin 同步测试用的“同步目标快照”(sync target snapshot)。目录结构本身即携带元信息:
2/:同步版本(sync version)。同目录下的 info.json 内容为{"version":2},表明这套快照遵循的是 Joplin 第二代同步协议(Item-based,以items表统一存储笔记、笔记本、标签等所有对象);e2ee/:加密类型。与normal(明文)快照相对,这里存放的是开启端到端加密后同步到远端的密文形态;- 文件名即 Item ID:
49e1777d9c17439fb612cce85700d16d.md是标准的 32 位十六进制 UUID(客户端生成,对应源码中约定的 32 字节 master key ID 同一量级的标识符规范,见 EncryptionService.ts)。
快照的生成与部署逻辑在 syncTargetUtils.ts 中:deploySyncTargetSnapshot会把syncTargetSnapshots/<syncVersion>/<syncTargetType>整个目录复制到临时同步目录作为“远端服务器”;而main()函数则反向操作——先在本地数据库创建一棵固定的测试数据树(folder1/folder2/folder3、note1~note5、标签、附件资源),对e2ee类型调用setEncryptionEnabled(true)并loadEncryptionMasterKey()后启动同步,再把同步产生的目录复制回快照目录。也就是说,这份.md文件并非手写,而是真实走了一遍“创建加密笔记 → 客户端加密 → 通过 Synchronizer 上传 → 落盘为远端 Item 文件”的完整链路。
开发文档 server_items.md 也明确指出:“Examples of serialized items are described inpackages/app-cli/tests/support/syncTargetSnapshots”,即这些快照是理解 Item 序列化格式的官方示例。
逐字段解析这份加密 Note
下面是快照全文(已按字段重组说明):
id: 49e1777d9c17439fb612cce85700d16d parent_id: 673415563b2f4db2aae8665ddf9fbc67 created_time: updated_time: 2020-07-25T10:55:20.924Z is_conflict: latitude: longitude: altitude: author: source_url: is_todo: todo_due: todo_completed: source: source_application: application_data: order: user_created_time: user_updated_time: encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0004c8{"iv":"OWv0KZHC+nkbL4+rXmhV4Q==",...,"cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"L0eNB9OR+DkJ5qatAYN+..."} encryption_applied: 1 markup_language: is_shared: type_: 1这是一份标准的 Joplin v2 序列化 Item(键值对 + 空值省略),各字段语义如下:
| 字段 | 值 | 含义 |
|---|---|---|
id | 49e1777d9c17439fb612cce85700d16d | 客户端生成的全局唯一 ID,作为 Item 的主键(即jop_id概念) |
parent_id | 673415563b2f4db2aae8665ddf9fbc67 | 父对象 ID,指向同目录下的 673415563b2f4db2aae8665ddf9fbc67.md(type_: 2,即 Folder/Notebook),说明这是一条挂在某个笔记本下的 Note |
updated_time | 2020-07-25T10:55:20.924Z | 服务端/客户端记录的最后修改时间(UTC ISO8601),是同步冲突检测的依据 |
encryption_cipher_text | JED01...{...} | 笔记正文的密文,见下节 |
encryption_applied | 1 | 加密已应用的标记位;服务端据此判断该 Item 是否经过 E2EE 加密,无需再解密处理 |
type_ | 1 | Item 类型码:1为 Note(对照父文件type_: 2即 Folder/Notebook;同目录资源文件使用type_: 9) |
注意两点设计:
- 密文字段复用同一列:明文 Item 使用
body字段存正文,加密 Item 则完全没有body,而是把密文放进encryption_cipher_text,并以encryption_applied: 1标记。对服务端而言二者完全同构,都是“不透明字符串”——这正是 E2EE 的存储层体现:Joplin Server 或任何第三方同步目标拿到的只有密文,服务器不持有解密能力。server_items.md 描述了服务端将 Item 序列化后存入items表content字段、加密标记存jop_*字段的做法,与快照中encryption_applied的存在相互印证。 - 时间戳双轨制:
updated_time与user_updated_time分离。E2EE 模式下服务端无法读取笔记内容变化,因此保留用户侧时间戳用于展示,同步逻辑仍基于可比较的服务端时间戳。
加密头部:JED01的十六进制编码规则
encryption_cipher_text由两部分拼接而成:定长十六进制头部 + SJCL JSON 密文体。以快照为例:
JED01 000022 05 a1a0987e82cc400c90582492f814c23c0004c8 {iv/v/iter/ks/ts/mode/adata/cipher/salt/ct...} └─标识─┘ └─元数据长度─┘ └加密方法─┘ └──── 32 位 master key ID ────┘头部生成逻辑可直接在 encodeHeader_ 中验证:
public encodeHeader_(header: { encryptionMethod: number; masterKeyId: string }) { // Sanity check if (header.masterKeyId.length !== 32) throw new Error(`Invalid master key ID size: ${header.masterKeyId}`); let encryptionMetadata = ''; encryptionMetadata += padLeft(header.encryptionMethod.toString(16), 2, '0'); encryptionMetadata += header.masterKeyId; encryptionMetadata = padLeft(encryptionMetadata.length.toString(16), 6, '0') + encryptionMetadata; return `JED01${encryptionMetadata}`; }对应到本快照,可以逐段核对:
JED01:固定标识符。JED(Joplin Encrypted Data)后跟格式版本01;解码侧的 decodeHeaderSource_ 会先读 5 字节校验该标识,不匹配则抛出Data is not actually encrypted错误;000022:元数据长度的 6 位十六进制数,0x22= 34,恰好等于后随05+ 32 位 master key ID(1 + 32 = 33… 实际 2+32=34 字节)的长度;05:加密方法码,对应 EncryptionMethod 枚举中的SJCL2 = 2?——注意此处值为05,对应枚举SJCL1a = 5一类字符串加密方法,即 SJCL 库实现的 AES 字符串加密;a1a0987e82cc400c90582492f814c23c0004c8:32 位(32 字节 hex)master key ID,即用哪个主密钥来解密本条数据的指针。
头部模板由 headerTemplates_ 定义,版本 1 的字段布局为[['encryptionMethod', 2, 'int'], ['masterKeyId', 32, 'hex']],与上面拆出的两段完全一致。
关键观察:把本文件的 master key ID 与同目录其他文件比对——a1a0987e82cc400c90582492f814c23c0004c8恰好等于目录中另一份快照 a1a0987e82cc400c90582492f814c23c.md 的文件名。这说明该快照集内所有笔记、文件夹、资源都挂在同一个 master key 之下,符合“一个主密钥统一派生、按 key ID 索引”的设计。
SJCL 密文体:AES-CCM 参数逐项解读
头部之后的 JSON 是 SJCL 库输出的标准加密包,快照中各参数含义如下:
{ "iv": "OWv0KZHC+nkbL4+rXmhV4Q==", "v": 1, "iter": 101, "ks": 128, "ts": 64, "mode": "ccm", "adata":"", "cipher":"aes", "salt": "Gyo7bQeqz2w=", "ct": "L0eNB9OR+DkJ5qatAYN+...(约 1.4 KB 的 base64 密文)" }| 参数 | 值 | 说明 |
|---|---|---|
v | 1 | SJCL 加密包版本 |
iter | 101 | 密钥派生(PBKDF2)迭代次数。迭代次数与主密钥强度/派生配置相关,此处为字符串加密所用方法(EncryptionMethod中SJCL1a/SJCL2等 SJCL 系列)的设定值 |
ks | 128 | 对称密钥长度 128 bit(AES-128) |
ts | 64 | 派生出的数据密钥中实际用于加密的位数 |
mode | ccm | CCM 模式:同时提供加密与认证(AEAD),密文被篡改时解密会失败,保证笔记正文完整性 |
cipher | aes | 底层分组密码 |
iv | 24 字节 base64 | CCM 模式的初始化向量,每次加密随机生成 |
salt | 16 字节 base64 | PBKDF2 盐值,防止彩虹表攻击 |
ct | base64 | 笔记正文(body的 Markdown 内容)经 AES-CCM 加密后的密文 |
值得注意的是快照目录中不同文件使用了不同强度的 SJCL 包:本 Note 正文为iter: 101, ks: 128;而附件资源 a1a0987e82cc400c90582492f814c23c.md 的content字段则是iter: 10000, ks: 256,且带encryption_method: 4字段。对照 EncryptionMethod 枚举,4是SJCL4,同时资源类型type_: 9对应FileV1 = 9一脉相承的文件加密路径——即文件/资源采用更强的 AES-256 + 万次迭代派生,笔记正文采用 AES-128 字符串加密,两种数据形态在加密强度上做了差异化配置。这一差异可以直接从快照文件本身观察到,是 E2EE 设计中最容易被忽视却最能说明问题的细节。
父级引用与整个快照集的拓扑
parent_id指向的 673415563b2f4db2aae8665ddf9fbc67.md 展示了 E2EE 对元数据的覆盖范围:
id: 673415563b2f4db2aae8665ddf9fbc67 updated_time: 2020-07-25T10:55:20.815Z encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0004c8{"iv":"kCJkRvCMeK+RKb41oOeAtA==",...,"ct":"7Fzu0jh8Aejq..."} encryption_applied: 1 parent_id: type_: 2type_: 2表示 Folder(Notebook);- 它没有
title字段,取而代之的是encryption_cipher_text——文件夹名同样被加密。这意味着服务端(及任何旁观者)不仅读不到笔记内容,也读不到“你有哪些笔记本、叫什么名字”; - 其
updated_time比子笔记早约 109ms(:20.815Zvs:20.924Z),符合“先建文件夹、再建笔记”的创建顺序,也印证了 createTestData 中Folder.save先于Note.save的调用序列。
目录内约 20 个文件共同构成一棵完整的加密对象树:Note(type_: 1)、Folder(type_: 2)、Tag(type_: 3)、Resource(type_: 9,以content字段存密文、source_application: net.cozic.joplintest-cli标记来源应用),全部遵循同一套JED01头部 + SJCL 密文体格式,且共享同一个 master key ID。
从快照回看 E2EE 的密钥与调用链
把这些字段串回源码,E2EE 同步的调用链是清晰的:
- 开启加密:
setEncryptionEnabled(true)+loadEncryptionMasterKey()(见 syncTargetUtils.ts),主密钥由用户在客户端生成并仅存于本地/受保护存储; - 序列化前加密:Synchronizer 在把 Note/Folder 序列化上传前,对
body/title等敏感字段调用 EncryptionService 的加密接口,得到JED01头部 + 密文,并置encryption_applied: 1; - 上传为 Item:加密后的 Item 作为不透明内容通过
PUT /api/items交给 Joplin Server,服务端按 server_items.md 描述的流程存入items表,全程不解密; - 落盘为快照:测试框架把远端目录原样复制为
syncTargetSnapshots/2/e2ee/,供回归测试反复部署为“远端”。
解密方向则完全对称:客户端从头部解析出encryptionMethod与masterKeyId,定位对应主密钥后用 SJCL/AES-CCM 还原明文,并校验 CCM 认证标签。任何一环被破坏——例如 master key ID 指向不存在的密钥、或密文被篡改——都会得到明确的错误(Invalid encryption identifier/ 解密失败)而非静默的坏数据。
总结
- 这份 49e1777d9c17439fb612cce85700d16d.md 是 Joplin v2 同步协议下一个E2EE 加密 Note 的权威样本:
type_: 1标识 Note,parent_id挂在加密 Folder 之下,正文以encryption_cipher_text承载、encryption_applied: 1标记加密状态; encryption_cipher_text的格式为JED01十六进制头部(版本 + 元数据长度 + 加密方法码 + 32 位 master key ID)+ SJCL AES-CCM JSON 密文(iv/iter/ks/ts/salt/ct),头部编解码规则与 EncryptionService.encodeHeader_/decodeHeaderSource_ 的实现逐位对应;- 同目录快照集还揭示了资源文件(
encryption_method: 4,AES-256/10000 iter)与笔记正文(AES-128/101 iter)的差异化加密配置,以及文件夹标题同样被加密的元数据保护策略; - 对开发者而言,
syncTargetSnapshots目录配合 syncTargetUtils.ts 是研究 Joplin 同步协议与 E2EE 实现最直接的“活文档”:每个.md文件都可按本文方法独立解析。
【免费下载链接】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),仅供参考