Joplin E2EE 同步快照深度解析:从一条加密 Note-Tag 关联记录看端到端加密数据格式与迁移测试机制
【免费下载链接】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 同步快照记录(packages/app-cli/tests/support/syncTargetSnapshots/2/e2ee/f58c1af04627410da55d9c771b28bece.md)为切入点,深入拆解 Joplin 端到端加密(E2EE)同步数据的落盘格式:包括快照目录的组织结构、JED加密文本的字段语义、主密钥文件与 AES-CCM 密文的参数细节,并结合仓库源码还原快照的生成、部署与迁移校验的完整链路。读完本文,你将能够读懂任意一条 Joplin 加密同步项文件,并理解 Joplin 如何用快照体系保障同步协议跨版本升级时的数据兼容性。
一、什么是同步目标快照(Sync Target Snapshot)
Joplin 的同步协议会随版本演进,而协议升级必须保证"旧数据可迁移、迁移后数据不被破坏"。为此,仓库维护了一套同步目标快照:即在某一特定协议版本下,用一个固定数据集真实执行一次同步后,把同步目标(远端存储)上的目录与文件原样保存下来,作为后续测试的基准输入。
快照根目录位于 packages/app-cli/tests/support/syncTargetSnapshots,其目录约定为:
syncTargetSnapshots/ ├── 1/ # 同步协议版本 1 │ ├── e2ee/ # 开启端到端加密的快照 │ └── normal/ # 未加密的快照 ├── 2/ # 同步协议版本 2(info.json 中 version: 2) │ ├── e2ee/ │ └── normal/ └── 3/ # 同步协议版本 3 ├── e2ee/ └── normal/其中2/e2ee/info.json的内容为{"version":2},用于标识该快照对应的同步协议版本;locks/子目录保存同步锁文件。而数据本身以"一项一个 Markdown 文件"的方式存储,文件名即该项的id。每个文件与本地数据库中的一条记录一一对应。
二、逐字段解析一条加密同步项文件
本文主角f58c1af04627410da55d9c771b28bece.md是快照2/e2ee/中的一条记录,完整内容如下:
id: f58c1af04627410da55d9c771b28bece note_id: 04c4e932fe3c4c4a9450c09208bd6c21 tag_id: 8a17074d4ec24de7b5a1aa666f7d8b38 created_time: updated_time: 2020-07-25T10:55:20.803Z user_created_time: user_updated_time: encryption_cipher_text: JED0100002205a1a0987e82cc400c90582492f814c23c0002d8{"iv":"j4Au5F1MRKUgSevr56iisw==","v":1,"iter":101,"ks":128,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"Gyo7bQeqz2w=","ct":"HvRLpScsoN1juxw18ostkjkjcxO+VWaprzz15PzE+3LU0KfoPqo0g2GgVPpbmp9dNyuyv+akfj5u5/buMZipiyGteEOa5WxoJ16KJ9qIYjv+kxu9kcfteDhHP6gTz1Mc0DfQRlLfZ5EbcpYQwkcNvdF71t8JsH5QwqA27P/wk5TKZDM/gd641zL92tNViAM4dZws2FDeWvgb4xRU3L7tfSBVoR5DXBKPl5syNrr8m2prdolydWms+RZuQQnFWIMn0jIKucSx56YEwcmCsAdsOLpNd8/MLqHfUOafrCQYDd9QIWEbNz509wWoXiu/Cjl49B+xx0ACa5z/Ey0yBQAwibPLMBq2yAQgxo6SWG00reOkGKQmxYIkD7mQa87zUtsCRWPebFohDV2LfAbbPFScsqNsH2wNhVXJZJ4JcOKMUR1R4hx66P158wOn9VY1Rf3zyJujiqzAhGivdvMQ2qp1TyAoiA8ibPizZIEPh0oyYf7EoZf1mIO/hyFMfs31xSbfdfXhUn57BCgVkndA7NRT2xoEdqBgymLMp2z7ll7F8xjMEG+8wUlAWNXzNDjhzPza2D2s8Co9pqgs9g=="} encryption_applied: 1 is_shared: type_: 6各字段的语义如下:
| 字段 | 值 | 含义 |
|---|---|---|
id | f58c1af04627410da55d9c771b28bece | 该项在同步目标中的唯一标识(也是文件名) |
note_id/tag_id | 04c4e9.../8a1707... | 关联的两条实体 id,构成"笔记—标签"关系 |
created_time/updated_time | 空 /2020-07-25T10:55:20.803Z | 创建与更新时间(由同步器写入,快照抓取时部分字段为空) |
encryption_cipher_text | JED0100002205... | 加密后的内容主体,见下一节详解 |
encryption_applied | 1 | 标记该字段已被加密 |
type_ | 6 | 实体类型编码,见下文类型映射 |
2.1type_字段的实体类型映射
type_是理解快照内容的关键索引。Joplin 在 BaseModel.ts 中通过ModelType枚举维护了类型常量,其中与本快照直接相关的是:
TYPE_NOTE = 1(笔记)TYPE_FOLDER = 2(笔记本/文件夹)TYPE_TAG = 5(标签)TYPE_NOTE_TAG = 6(笔记-标签关联)
因此本文件的type_: 6表示它是一条笔记与标签的多对多关联记录,与note_id、tag_id两个外键字段互相印证。同目录下的 8a17074d4ec24de7b5a1aa666f7d8b38.md(type_: 5)正是被关联的标签本体,同样以密文形式存储。
2.2 明文记录与加密记录的差异
对比同版本的normal/快照可以发现,加密模式下的同步项:
- 不直接保存
title、body等业务字段,而是将所有内容折叠进encryption_cipher_text; - 保留
id、note_id、tag_id等建立索引所需的最小字段,使同步器无需解密即可完成差异比较; - 附加
encryption_applied: 1标记,便于客户端识别加密项并触发解密流程。
这种设计保证了 Joplin 在不解密的情况下也能高效地增量同步——只有真正读取内容时才需要解密。
三、JED加密文本格式:加密内容的核心载体
encryption_cipher_text的值以JED0100002205a1a0987e82cc400c90582492f814c23c0002d8开头,随后紧跟一段 SJCL JSON 密文。其结构可以拆解为:
JED01 00002205 a1a0987e82cc400c90582492f814c23c 0002d8 {SJCL JSON} │ │ │ │ │ │ │ │ │ └─ 密文负载长度(十六进制) │ │ │ └─ masterKeyId(32 位十六进制) │ │ └─ 加密元数据长度(十六进制) │ └─ 格式版本号 01 └─ Joplin Encryption Data 标识前缀3.1 头部元数据与主密钥绑定
Joplin 的加密实现位于 packages/lib/services/e2ee/EncryptionService.ts。从源码看,现代版本使用显式编码的头部来携带加密元数据,其模板定义于 EncryptionService.ts:
fields: [['encryptionMethod', 2, 'int'], ['masterKeyId', 32, 'hex']],而 encodeHeader_ 的编码逻辑为:encryptionMetadata += padLeft(header.encryptionMethod.toString(16), 2, '0') + header.masterKeyId。将上述头部与快照文本对照,a1a0987e82cc400c90582492f814c23c正是本快照主密钥(Master Key)的 id,说明这条 NoteTag 记录是使用该主密钥派生的会话密钥加密的。
3.2 SJCL JSON 参数逐项解读
紧随头部的是标准 SJCL(Stanford JavaScript Crypto Library)密文对象,本快照中的参数为:
| 参数 | 值 | 含义 |
|---|---|---|
iv | j4Au5F1MRKUgSevr56iisw== | 16 字节随机初始化向量(Base64) |
v | 1 | SJCL JSON 结构版本 |
iter | 101 | PBKDF2 迭代次数 |
ks | 128 | 密钥长度 128 位(AES-128) |
ts | 64 | GCM/CCM 认证标签长度 64 位 |
mode | ccm | 认证加密模式:AES-CCM |
adata | 空 | 附加认证数据 |
cipher | aes | 底层分组密码算法 |
salt | Gyo7bQeqz2w= | 用于密钥派生的随机盐 |
ct | 长 Base64 串 | 密文(含认证标签) |
其中iter: 101是一个容易引起疑问的细节。源码注释给出了明确解释(EncryptionService.ts):主密钥本身已经通过强密钥派生函数保护,因此用主密钥解密得到的"会话密钥"已经足够安全,对每条记录再做高迭代派生只会拖慢加解密速度;而 SJCL 强制要求iter严格大于 100,因此 Joplin 取最小值101。
3.3 加密方法版本与主密钥文件
快照目录中的 a1a0987e82cc400c90582492f814c23c.md 即上文头部引用的主密钥文件,其关键字段为:
source_application: net.cozic.joplintest-cli encryption_method: 4 checksum: content: {"iv":"...","iter":10000,"ks":256,"ts":64,"mode":"ccm",...,"salt":"...","ct":"..."} type_: 9encryption_method: 4对应 EncryptionMethod 枚举中的SJCL4(该枚举还包括SJCL=1、SJCL2=2、SJCL3=3、SJCL1a=5、SJCL1b=7等历史版本,体现加密方案的演进脉络);- 与普通记录不同,主密钥使用
iter: 10000、ks: 256的高成本 PBKDF2 派生,因为主密钥直接由用户密码保护,需要更高的暴力破解成本; type_: 9标记其为主密钥实体,与普通业务项(笔记、标签、关联)区分开;source_application: net.cozic.joplintest-cli表明该快照由测试专用的 CLI 客户端生成,这也是理解快照来源的重要线索。
四、快照如何生成:从测试数据到加密同步目标
快照并非手工伪造,而是由脚本真实执行"建数据 → 开加密 → 同步 → 拷贝远端目录"流程生成的。生成逻辑位于 packages/lib/testing/syncTargetUtils.ts:
4.1 测试数据集定义
testData(syncTargetUtils.ts)定义了一个固定结构的数据集:3 个笔记本(folder1含 2 个子文件夹、folder2、folder3),5 条笔记,其中若干笔记附带资源(resource: true,来自supportDir/photo.jpg)和标签(tags: ['tag1', 'tag2'])。createTestData(syncTargetUtils.ts)递归遍历该结构,通过Folder.save、Note.save、shim.attachFileToNote、Tag.addNoteTagByTitle在本地数据库建立完整数据。本文主角f58c1af...正是note1(04c4e9...)与tag1(8a1707...)之间关联记录的加密形态。
4.2 加密模式与快照落盘
main函数(syncTargetUtils.ts)的执行流程为:
- 校验快照类型必须是
normal或e2ee; setupDatabaseAndSynchronizer(1)+switchClient(1)初始化测试环境;createTestData(testData)创建数据集;- 若为
e2ee类型,则调用setEncryptionEnabled(true)开启端到端加密,并loadEncryptionMasterKey()加载测试主密钥; synchronizerStart()后执行一次完整同步,把全部数据推送到同步目标;- 读取
Setting.value('syncVersion')确定当前协议版本,将同步目录整体复制到snapshotBaseDir/{version}/{type},并打印输出路径。
也就是说,2/e2ee/下的每一个.md文件,都是同步器在协议版本 2 下真实序列化输出的结果,这也保证了快照与真实线上数据格式的严格一致。
五、快照的实战用途:同步协议迁移测试
快照最主要的使用场景是同步协议版本迁移测试,测试代码位于 synchronizer_MigrationHandler.test.ts。
5.1 部署旧版本快照
deploySyncTargetSnapshot(syncTargetType, syncVersion)(syncTargetUtils.ts)负责把指定版本的快照复制为"当前同步目标":
export async function deploySyncTargetSnapshot(syncTargetType: string, syncVersion: number) { const sourceDir = `${snapshotBaseDir}/${syncVersion}/${syncTargetType}`; await fs.remove(syncDir); await fs.copy(sourceDir, syncDir); }测试中(synchronizer_MigrationHandler.test.ts)的模式是:先部署migrationVersion - 1的快照,读取并断言旧版本号(fetchSyncInfo返回migrationVersion - 1),再调用migrationHandler().upgrade(migrationVersion)执行协议升级,最后验证info.json/version.txt已更新、新增目录(.resource、locks、temp、info.json)符合预期。
5.2 加密数据的解密校验
对于e2ee快照(synchronizer_MigrationHandler.test.ts),迁移完成后还须验证数据未被迁移过程破坏:
- 从快照读取主密钥:
const masterKey = (await MasterKey.all())[0]; - 注入测试密码
Setting.setObjectValue('encryption.passwordCache', masterKey.id, '123456'); - 加载主密钥并启动
decryptionWorker().start()解密全部数据; - 调用
checkTestData(testData)逐项断言——每个笔记本、笔记、资源、标签及其关联都必须完好存在(syncTargetUtils.ts 中通过loadByTitle、extractImageUrls、Tag.hasNote等做反向校验); - 再
switchClient(2)用第二个客户端同步一遍,验证多客户端场景下数据依旧一致。
正是这条"生成快照 → 部署旧版 → 升级 → 解密 → 校验数据完整性"的闭环,保证了 Joplin 每次同步协议升级都不会让用户已有的加密数据(包括本文这类细粒度的 Note-Tag 关联记录)发生丢失或损坏。
六、总结与延伸阅读
通过本文,你可以掌握三条核心知识:
- 快照即事实:
syncTargetSnapshots/{version}/{mode}/下的每个 Markdown 文件都是某协议版本下同步目标的真实截影,type_字段决定了实体类型,encryption_cipher_text承载全部业务内容; - 加密格式可读:
JED前缀 + 头部元数据(加密方法 + 主密钥 id)+ SJCL JSON(AES-CCM、PBKDF2、随机盐/IV)构成了 Joplin E2EE 的完整密文结构;主密钥用高成本派生保护,业务记录用低成本迭代换取速度; - 快照驱动兼容性:借助
deploySyncTargetSnapshot与迁移测试,仓库能对每一个历史协议版本持续回归验证。
如需继续深入,推荐阅读以下源码文件:
- 快照生成与校验:packages/lib/testing/syncTargetUtils.ts
- 协议迁移测试:packages/lib/services/synchronizer/synchronizer_MigrationHandler.test.ts
- 加密算法与头部格式:packages/lib/services/e2ee/EncryptionService.ts
- 实体类型常量映射:packages/lib/BaseModel.ts
- 主密钥快照样例:packages/app-cli/tests/support/syncTargetSnapshots/2/e2ee/a1a0987e82cc400c90582492f814c23c.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),仅供参考