Joplin 端到端加密同步快照深度解析:从 JED 密文格式到 Sync Version 3 迁移测试
【免费下载链接】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)是保护笔记内容不落盘在第三方服务器上的关键机制。本文以仓库测试夹具 d7f300c742bb44338563ddbd6fb285c3.md 为主线,逐字节拆解 Joplin 加密同步条目的 JED 密文格式,并结合EncryptionService、syncTargetUtils与迁移测试源码,说明同步目标快照(Sync Target Snapshot)体系如何生成、部署,以及 E2EE 快照在 Sync Version 3 迁移测试中承担的角色。读完本文,你将能独立解读任意一条 Joplin E2EE 同步文件,并复现快照的创建与迁移验证流程。
一、这份文档到底是什么:一条被 E2EE 加密的文件夹记录
该文件位于packages/app-cli/tests/support/syncTargetSnapshots/3/e2ee/目录下,是 Joplin 官方测试体系中的同步目标快照(sync target snapshot)夹具。它不是一个普通 Markdown 笔记,而是一条以 Joplin 同步文件格式序列化的、被端到端加密的数据条目。逐行解读其内容:
id: d7f300c742bb44338563ddbd6fb285c3 created_time: updated_time: 2021-08-07T17:03:37.028Z user_created_time: user_updated_time: encryption_cipher_text: JED01000022051f3b6b71948c4f5d909d1af6588c78bb0002c8{...} encryption_applied: 1 parent_id: 376c1a3fe5ce4fc885e344b52b9f37b8 is_shared: share_id: type_: 2关键信息可归纳为下表:
| 字段 | 值 | 含义 |
|---|---|---|
id | d7f300c742bb44338563ddbd6fb285c3 | 条目的全局唯一 ID(32 位十六进制),同时用作同步文件名 |
created_time/user_created_time | 空 | 观察可见,该快照中的条目创建时间未被填充,仅有updated_time |
updated_time | 2021-08-07T17:03:37.028Z | 对应 Unix 时间戳 1628355817028,与同目录 info.json 中记录的updatedTime: 1628355817270处于同一同步时刻,即快照生成于 2021-08-07 前后 |
encryption_cipher_text | JED0100... | 密文字段,承载 JED 头部与 SJCL 密文载荷(详见下一节) |
encryption_applied | 1 | 明文布尔标记,声明该条目已应用加密;该字段本身不加密 |
parent_id | 376c1a3fe5ce4fc885e344b52b9f37b8 | 父条目 ID。查同目录 376c1a3fe5ce4fc885e344b52b9f37b8.md 可知其parent_id为空且type_同为 2,因此本文件是一条嵌套在顶层文件夹之下的子文件夹记录 |
type_ | 2 | 条目类型码。对照 BaseModel.ts 中的ModelType枚举:Note = 1、Folder = 2、Resource = 4、Tag = 5、NoteTag = 6、MasterKey = 9,即本条目是一个文件夹 |
值得注意:id、parent_id、type_、is_shared、share_id、encryption_applied这些结构字段以明文保存在同步文件中,而真正的内容字段(如文件夹标题、笔记正文、标签列表等)被整体序列化后加密进encryption_cipher_text。这正是 Joplin 同步协议的取舍——同步器必须能通过明文的id/type_/parent_id建立条目树、执行增量比较,而内容本身对同步目标(如 Joplin Cloud、Nextcloud、WebDAV)保持不可读。
二、JED 加密格式逐层拆解
encryption_cipher_text的值以JED01000022051f3b6b71948c4f5d909d1af6588c78bb开头,这是 Joplin 自有的JED(Joplin Encrypted Data)容器头。对照 EncryptionService.ts 中encodeHeader_与decodeHeaderBytes_的实现,可逐段解析:
| 片段 | 字节数 | 值 | 含义 |
|---|---|---|---|
| 标识符 | 3 | JED | 密文头固定标识,decodeHeaderBytes_会首先校验该标识,缺失即报错 |
| 头部版本 | 2 | 01 | 头部模板版本 1,对应源码中headerTemplates_的模板定义 |
| 元数据长度 | 6 | 000022 | 十六进制表示的元数据总长,0x22 = 34字节,其后 34 个字节即加密元数据 |
| 加密方法 | 2 | 05 | 由padLeft(encryptionMethod.toString(16), 2, '0')生成,05对应 EncryptionMethod 枚举中的SJCL1a = 5 |
| 主密钥 ID | 32 | 1f3b6b71948c4f5d909d1af6588c78bb | 解密本条目所需主密钥的 ID,与 info.json 中的activeMasterKeyId一致 |
头部模板在源码中的定义(headerTemplates_,模板版本 1)为:
// 字段定义格式:[name, valueSize, valueType] 1: { fields: [['encryptionMethod', 2, 'int'], ['masterKeyId', 32, 'hex']], },在头部之后、SJCL JSON 之前,还可以看到0002c8这样一个 6 位十六进制前缀(十进制 712)。从格式的帧结构可以推断:它用于声明紧随其后的密文载荷长度,使读取方解析完头部后即可确定密文边界(对应头部中的"元数据长度"字段同属这种长度前缀式帧设计)。
真正的密文主体是一个SJCL(Stanford JavaScript Crypto Library)格式的 JSON 对象:
{ "iv": "/TciaFYKNHcgOGewTRhZ7Q==", "v": 1, "iter": 101, "ks": 128, "ts": 64, "mode": "ccm", "adata": "", "cipher": "aes", "salt": "tVgmTCWSasM=", "ct": "SDEcAeYxBnBVeHf+cPjW5pCSfGkSkHKZTQ50r3TnG1kdORfFV3Xgvchnjc10AdJxZs2QgMT7PoKioF+M9vWJbBbttiEZWVD2AdnnpwUtg3pYRenNvVmZFJ2TRc+llUrKoitgZ4iFUdeW6FD6zGRnXU+06apcmQFDZtHpqFEsFpIaoghYqV1SsjLEAt4sBKQPAFNpK3rJoQoVqVyqglMMAv3Z6UGSb2R8bkY6JxcJi86e1sJ9o/rovmco8cUhAWWa2TtZXvkFn36rTWM64caHW/BNGsM+xTiyvoLyNpWygsNSoRL1gzcLBn6WChdcK0UOaiQZHnWVeeHuMpcpR6lqyaFbf+g8yMtxRGhh4es9eG8F/rimV4aBcRnSdirPXJhLmw5H0OLaXtZOfy2RSYm7P7VqQ9BnYgRbqiZ3f/Bd0izciiekxIskvLWOK2hjc09YnaR0JjNQqsnuV4q9sgCnJkkKwXXv+k6WGlZFUTac4HUM3x+NIFa6yCHAt0D57uaiSJppmFG7ypR7xSTAKuNfGLHBaPlkyMFKDimIKf7RSjnpw1Ptkgbh3vxzg/royQ==" }这些参数的含义:
cipher: "aes"、mode: "ccm"——使用AES 分组密码的 CCM 认证加密模式(兼具机密性与完整性校验);ks: 128——密钥长度 128 位;ts: 64——认证标签(tag)64 位;iter: 101、salt: "tVgmTCWSasM="——基于口令的主密钥经加盐、迭代派生密钥的参数;iv——每次加密独立生成的初始化向量(nonce),保证同一密钥下不同条目密文互不相同;ct——密文本体(上例已完整保留,未做截断省略)。
对比 info.json 中的主密钥内容(iter: 10000、ks: 256、encryption_method: 4),可以观察到 Joplin 的安全分层设计:主密钥自身使用更强的派生参数(10000 次迭代、256 位密钥)加密,而条目正文使用相对轻量的参数(101 次迭代、128 位密钥),在移动端性能与安全性之间取得平衡。
三、E2EE 背后的主密钥体系与加密服务
Joplin 的端到端加密采用"主密钥(Master Key)"体系:用户在客户端用主密码加密主密钥,主密钥再加密所有条目。这套逻辑全部收敛在 EncryptionService.ts 中:
- 默认加密方法:源码第 74-76 行定义了
defaultEncryptionMethod_ = EncryptionMethod.StringV1、defaultFileEncryptionMethod_ = EncryptionMethod.FileV1、defaultMasterKeyEncryptionMethod_ = EncryptionMethod.KeyV1。对应枚举StringV1 = 10、FileV1 = 9、KeyV1 = 8——现代 Joplin 对字符串、文件、主密钥分别采用专门的加密方法,而SJCL1a(方法 5)属于历史 SJCL 系列的兼容方法,本文拆解的夹具恰是这一时期的产物。 - 分块大小:
chunkSize()(第 126-138 行)为不同方法定义了解密缓冲块:SJCL与KeyV1为 5000 字节、FileV1为 131072 字节(128K)、StringV1为 65536 字节(64K)。源码注释还记录了实测性能规律——Node 环境下 1MB 数据解密很慢,移动端解密耗时随块增大呈指数级上升(50KB 约 1000ms,5KB 仅约 10ms),因此必须保持较小的分块。 - 主密钥的同步元数据:同目录 info.json 以明文 JSON 记录了同步级状态:
"version": 3(Sync Version 3)、"e2ee": {"value": true}(该目标启用了 E2EE)、"activeMasterKeyId"(当前生效主密钥 ID,与条目头中的1f3b6b71...一致)以及masterKeys数组。主密钥条目本身即ModelType.MasterKey = 9类型的同步项,source_application标记为net.cozic.joplintest-cli,说明该快照由测试版 CLI 应用生成。
四、同步目标快照体系:normal 与 e2ee 的双轨夹具
syncTargetSnapshots目录按版本号/类型/组织:当前仓库中存在版本1/、2/、3/,每种版本下又分normal/(未加密)与e2ee/(端到端加密)两套快照。这套结构的生成与消费逻辑集中在 syncTargetUtils.ts:
- 测试数据模板(
testData,第 17-41 行):定义了一棵包含多层级文件夹、笔记、附件(resource: true)与标签(tags)的条目树,例如folder1 > subFolder2 > note1(含附件与 tag1)、folder3 > note5等; - 数据创建与校验:
createTestData()递归按模板落库(文件夹Folder.save、笔记Note.save、附件shim.attachFileToNote、标签Tag.addNoteTagByTitle);checkTestData()反向校验每个条目、父级关系、附件 URL 与标签关联是否完整,是迁移测试中"数据未被改动"的判定器; - 快照生成入口
main(syncTargetType)(第 119-146 行):限定syncTargetType只能是normal或e2ee;若为e2ee,先调用setEncryptionEnabled(true)与loadEncryptionMasterKey()开启加密并加载主密钥,随后执行一次完整同步(synchronizerStart()+synchronizer().start()),最后把同步目录整体复制到${snapshotBaseDir}/${syncVersion}/${syncTargetType}形成快照; - 快照部署
deploySyncTargetSnapshot(syncTargetType, syncVersion)(第 113-117 行):清空当前同步目录后把对应快照复制回去,模拟"一个旧版本客户端首次面对该同步目标"的场景。
normal/快照提供了绝佳的对照样本。例如 3/normal/933cf209b0094d43884c03149f034128.md 是一条明文附件条目,可见其完整元数据(mime: image/jpeg、size: 2720、type_: 4即 Resource,首行photo.jpg为附件文件名),且encryption_applied: 0。对比同一批测试数据在e2ee/版本中的形态——所有内容字段被加密成JED01...密文,仅保留结构字段——可以直观看到 E2EE 前后同步载荷的差异。
五、快照如何驱动 Sync Version 迁移测试
快照不是静态存档,而是同步协议版本迁移测试的"考古层"。在 synchronizer_MigrationHandler.test.ts 中:
- 文件头部注释明确了快照的再生成方式(与
syncTargetUtils.main()配合):
// To create a sync target snapshot for the current syncVersion: // - In test-utils, set syncTargetName_ to "filesystem" // - Then run: // node tests/support/createSyncTargetSnapshot.js normal && node tests/support/createSyncTargetSnapshot.js e2eetestMigration(migrationVersion, maxSyncVersion)(第 65-97 行)的流程是:deploySyncTargetSnapshot('normal', migrationVersion - 1)部署旧版快照 →fetchSyncInfo断言当前版本 →migrationHandler().upgrade(migrationVersion)执行协议升级 → 再次fetchSyncInfo断言版本已提升 → 对最新版本执行synchronizer().start()后调用checkTestData(testData)验证数据无损,并切换到第二个客户端再同步一次以验证多端一致性。migrationTests表为每个版本定义了目录结构断言:版本 2 与 3 均要求同步目标根目录存在.resource/、locks/、temp/目录与info.json文件,且旧客户端版本标记.sync/version.txt内容为2——这说明 Sync Version 3 在目录布局上向后兼容 Version 2,仅通过info.json的version字段区分。- 对应的 E2EE 路径
testMigrationE2EE会先创建测试数据并开启加密,再部署 e2ee 快照执行同样的升级与完整性校验,本文拆解的d7f300c742bb44338563ddbd6fb285c3.md正是这一路径所需的数据底座之一。
六、延伸阅读:继续深入本仓库
- 想了解加密服务的完整实现:阅读 EncryptionService.ts,重点看
encodeHeader_/decodeHeaderBytes_(JED 头编解码)与chunkSize()(分块策略); - 想复现快照生成与迁移:阅读 syncTargetUtils.ts 与 synchronizer_MigrationHandler.test.ts;
- 想了解同步元数据(
info.json的读写逻辑、e2ee/activeMasterKeyId等键的时间戳优先级设计):阅读 syncInfoUtils.ts; - 想对照各同步版本快照全貌:浏览 syncTargetSnapshots 目录下
1/、2/、3/的normal/与e2ee/两套夹具。
以一条 11 行的加密夹具文件为起点,本文还原了 Joplin E2EE 从"主密钥分层"到"JED 容器头 + SJCL/AES-CCM 密文"再到"快照驱动的协议迁移测试"的完整技术链路。理解这条链路后,无论是排查同步加密问题、阅读 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),仅供参考