Joplin 同步目标快照与 E2EE 加密格式深度解析:以 v3 快照中加密笔记条目为例
本篇文章基于 Joplin 仓库中一份真实的测试快照文件展开。这份位于
packages/app-cli/tests/support/syncTargetSnapshots/3/e2ee/04761c7a9930415f95ce98dac1706411.md的文件,记录了 Joplin 在 syncVersion 3 + E2EE 加密 状态下,一条加密笔记(type_ 1)在同步目标上的完整落盘形态。通过逐字段拆解该文件、对照EncryptionService、MigrationHandler、syncInfoUtils等源码与对应迁移测试,你将掌握:Joplin 同步目标快照的目录组织与版本演进、JED01加密头的二进制编码规则、SJCL 密文载荷的算法参数(AES-CCM / iter / salt 等)在源码中的出处,以及如何通过deploySyncTargetSnapshot让测试重现历史版本数据。阅读结束后,你将具备独立解读 Joplin 任意一条加密同步条目的能力。
一、快照文件是什么:同步目标测试基础设施
在理解这条加密笔记之前,先要回答"这个 .md 文件为什么会出现在 tests/support 目录下"。
packages/app-cli/tests/support/syncTargetSnapshots/ 目录存放的是 Joplin 各历史版本同步目标(sync target)的真实文件快照,其下按两层结构组织:
syncTargetSnapshots/
├── 1/ # syncVersion 1 的快照
│ ├── e2ee/ # 开启端到端加密后的同步目标文件
│ └── normal/ # 未加密的同步目标文件
├── 2/
│ ├── e2ee/ # 含 locks/、temp/ 目录与 info.json
│ └── normal/
└── 3/ # syncVersion 3(当前版本)
├── e2ee/ # 本文主角所在目录
└── normal/
快照的生成逻辑定义在 syncTargetUtils.ts 中:main() 先构建一组固定测试数据,若为 e2ee 类型则调用 setEncryptionEnabled(true) 并加载主密钥,随后执行一次完整同步,最后把整个同步目录复制到 syncTargetSnapshots/{syncVersion}/{syncTargetType}/。换句话说,快照不是手写样例,而是由测试代码"真实同步"出来的产物,因此每条记录的字段形态与真实用户数据完全一致。
快照的消费逻辑是同一文件中的 deploySyncTargetSnapshot(syncTargetType, syncVersion):测试运行时把某个历史版本的快照整体拷贝到同步目录,模拟"一个老版本客户端留下的数据",再交由 MigrationHandler 升级到新版本。这正是 synchronizer_MigrationHandler.test.ts 中 testMigration / testMigrationE2EE 两个测试用例的核心做法。
值得注意的是,该测试文件头部的注释保留了快照的再生成命令:在
test-utils中将syncTargetName_设为"filesystem"后,依次执行node tests/support/createSyncTargetSnapshot.js normal与node tests/support/createSyncTargetSnapshot.js e2ee即可重新生成整套快照。
二、syncVersion 3 与快照目录的演进依据
syncTargetSnapshots/3/ 对应 Joplin 当前的同步目标版本号 syncVersion: 3,该值定义于 Setting.ts 的默认设置中。同步版本的用途与迁移机制记录在 MigrationHandler.ts 的 migrations 数组里,迁移历史如下:
| 版本 | 迁移动作 | 源码位置 |
|---|---|---|
| 1 | 初始同步目标,无 info.json,版本号记录在 .sync/version.txt |
MigrationHandler 内建逻辑 |
| 2 | 写入 .sync/version.txt(值为 2,兼容旧客户端)、创建 locks/ 与 temp/ 目录 |
migrations/2.ts |
| 3 | 将本地 SyncInfo 缓存序列化为 info.json 上传,版本号改为 3 |
migrations/3.ts |
对照快照目录可以看到这一演进留下的痕迹:
- version 1 快照没有
info.json,也没有locks/、temp/目录; - version 2/3 快照均已包含
locks/、temp/与根目录下的info.json。
迁移测试对升级后的目录结构做了断言(synchronizer_MigrationHandler.test.ts):升级到版本 3 后,同步目标根目录应恰好存在 .resource、locks、temp 三个目录与一个 info.json 文件,同时旧客户端仍能从 .sync/version.txt 读到版本号 2。这解释了为什么 v3 快照中依然保留 .sync/version.txt——向后兼容旧客户端读取版本号的需要。
三、info.json:E2EE 状态与主密钥的唯一权威来源
v3 快照最重要的结构性变化,是引入根目录下的 info.json 作为同步目标元数据的唯一权威来源。其完整内容见 syncTargetSnapshots/3/e2ee/info.json,核心字段如下:
{
"version": 3,
"e2ee": { "value": true, "updatedTime": 1628355817270 },
"activeMasterKeyId": { "value": "1f3b6b71948c4f5d909d1af6588c78bb", "updatedTime": 1628355817333 },
"masterKeys": [
{
"checksum": "",
"encryption_method": 4,
"content": "{...SJCL 密文...}",
"created_time": 1628355817331,
"updated_time": 1628355817331,
"source_application": "net.cozic.joplintest-cli",
"id": "1f3b6b71948c4f5d909d1af6588c78bb"
}
]
}
3.1 字段语义
字段结构与 syncInfoUtils.ts 中 SyncInfo 类的 toObject() 一一对应:
version:同步目标格式版本,本快照为 3;e2ee:{ value, updatedTime }结构,value: true表示该同步目标已开启端到端加密。注意SyncInfo.load()对缺失字段提供默认值(syncInfoUtils.ts),e2ee缺省为false;activeMasterKeyId:当前"激活"的主密钥 ID。mergeActiveMasterKeys()(syncInfoUtils.ts)在合并本地/远端同步信息时会优先保留"已被使用过(hasBeenUsed)"的密钥,其次才是时间戳更新的密钥,避免多客户端重复建钥造成混乱;masterKeys:主密钥数组。每个密钥的content字段用encryption_method指定的算法加密后存储。从快照可见本密钥encryption_method: 4,对应EncryptionMethod.SJCL4(SJCL JSON 格式、AES-CCM、iter: 10000、256 位密钥),其具体算法参数定义见 EncryptionService.ts。
3.2 带时间戳字段的合并规则
e2ee、activeMasterKeyId 等字段都带 updatedTime,这是为了让多客户端对"是否开启加密""用哪把主密钥"达成一致。mergeSyncInfos()(syncInfoUtils.ts)逐字段比较时间戳,取较新者;而对 masterKeys 数组则按密钥 ID 合并、取 updated_time 更新的版本。正是这套机制保证了快照中 activeMasterKeyId 与 masterKeys[0].id 完全一致(都是 1f3b6b71948c4f5d909d1af6588c78bb)。
3.3 客户端版本强制
info.json 中还承载 appMinVersion 语义(syncInfoUtils.ts):当同步目标由新版本客户端写入后,checkIfCanSync() 会拒绝旧客户端继续同步,防止不支持新字段的客户端破坏数据。
四、逐字段拆解加密笔记条目
回到主角文件 04761c7a9930415f95ce98dac1706411.md,它本质是 Joplin 数据库某条记录的键值对序列化,字段名与 BaseItem 模型列一一对应:
| 字段 | 值(本例) | 说明 |
|---|---|---|
id |
04761c7a9930415f95ce98dac1706411 |
全局唯一 ID(16 字节 hex) |
parent_id |
376c1a3fe5ce4fc885e344b52b9f37b8 |
父目录 ID,指向同目录下的加密文件夹条目 |
created_time / updated_time |
空 / 2021-08-07T17:03:37.157Z |
ISO 时间戳;updated_time 用于同步冲突与增量比较 |
is_conflict |
空 | 是否冲突副本 |
latitude / longitude / altitude |
空 | 笔记地理位置,未设置 |
author / source_url |
空 | 笔记作者、来源 URL |
is_todo / todo_due / todo_completed |
空 | 待办标记 |
source / source_application |
空 | 笔记来源应用 |
application_data |
空 | 应用自定义数据 |
order |
空 | 排序字段 |
user_created_time / user_updated_time |
空 | 用户可见时间戳 |
encryption_cipher_text |
JED01000022051f3b6b...(长密文) |
核心加密载荷 |
encryption_applied |
1 |
是否已加密 |
markup_language |
空 | Markdown 语言标记 |
is_shared / share_id / conflict_original_id |
空 | 共享与冲突关联字段 |
type_ |
1 |
条目类型:1=笔记,2=文件夹 |
同一快照目录中的 376c1a3fe5ce4fc885e344b52b9f37b8.md 即为该笔记的父文件夹,其 type_: 2、parent_id 为空,二者通过 parent_id 构成父子关系;该文件夹同样被加密(encryption_cipher_text 以 JED01 开头),encryption_applied: 1。
4.1 空字段的意义
created_time、is_conflict 等字段在快照中为空,是因为这些值在同步序列化时被省略(值为空/默认值时不留存)。而 updated_time、encryption_cipher_text、encryption_applied、type_ 为必填核心字段,是同步引擎判断条目版本与加密状态的关键。
五、JED01 加密头的二进制结构解码
密文以固定前缀开头:
JED01000022051f3b6b71948c4f5d909d1af6588c78bb0004ac{"iv":"lewZXUA2jDkYdCad/AI54w==",...}
这一前缀的编码规则由 encodeHeader_() 生成(EncryptionService.ts),解码规则由 decodeHeaderBytes_() 实现(EncryptionService.ts),逐段拆解如下:
| 十六进制段 | 长度(字符) | 含义 | 本例取值 |
|---|---|---|---|
JED |
3 | 固定标识符,用于校验"数据确已加密" | JED |
01 |
2 | 头版本号(version 1) | 01 |
000022 |
6 | 后续元数据的总长度(hex) | 0x22 = 34 字节 |
05 |
2 | 加密方法编号(hex int) | 0x05 = SJCL1a |
1f3b6b71948c4f5d909d1af6588c78bb |
32 | 主密钥 ID(hex,必为 32 字符,否则 encodeHeader_ 抛错) |
1f3b6b71948c4f5d909d1af6588c78bb |
0004ac |
6 | 剩余密文载荷的字符长度(hex) | 0x4ac = 1196 字符 |
校验逻辑在 decodeHeaderSource_()(EncryptionService.ts):先读 5 字符确认以 JED01 开头(非法则抛 invalidIdentifier),再读 6 字符元数据长度,最后读取元数据本身。itemIsEncrypted()(EncryptionService.ts)对同步条目执行同样的前缀校验,只有 encryption_applied 且密文以 JED01 开头才判定为已加密。
解密流程概述:客户端先用主密钥 ID 在 info.json 的 masterKeys 中找到对应密钥,解密出密钥明文;随后按头中记录的加密方法(0x05 → SJCL1a)对 ct 密文执行 SJCL JSON 解密,即可还原笔记的完整内容。
六、SJCL 密文载荷的参数详解
元数据之后是一段 SJCL JSON 格式的密文载荷,本例(SJCL1a)解构如下:
{
"iv": "lewZXUA2jDkYdCad/AI54w==",
"v": 1,
"iter": 101,
"ks": 128,
"ts": 64,
"mode": "ccm",
"adata": "",
"cipher": "aes",
"salt": "tVgmTCWSasM=",
"ct": "yd4oVd062nvTUBHM/kj14xPa..."
}
6.1 参数含义与源码依据
这些参数不是随机生成,而是 EncryptionService.ts 中 SJCL1a 加密函数显式指定的:
iv:16 字节初始化向量(Base64),每次加密随机生成;v: 1:SJCL JSON 格式版本;iter: 101:PBKDF2 密钥派生迭代次数。源码注释解释了这一取值:主密钥本身已通过高迭代派生(见下方iter: 10000),对条目密文再做高强度派生收益有限且拖慢解密速度;SJCL 又强制要求iter严格大于 100,故取 101;ks: 128:AES 密钥长度 128 位。注意源码注释(EncryptionService.ts)指出 2023-06-10 起新方案 SJCL1b 已改用 AES-256(ks: 256),本快照(2021 年生成)仍属 AES-128 时代的产物;ts: 64:认证标签(tag size)64 位;mode: "ccm":AES-CCM 认证加密模式——同时提供机密性与完整性保护,源码注释说明选用 CCM 是因为其无专利限制、支持面广(OCB2 虽更快但受专利约束);adata: "":关联数据(associated data),本方案未使用;cipher: "aes":底层分组密码算法;salt:PBKDF2 盐值(Base64),与iv一样每次随机生成,用于派生实际加密密钥;ct:密文本体(Base64)。
6.2 不同加密方法的参数对照
加密方法的编号、新旧与参数差异均可在 EncryptionService.ts 中核实,整理如下:
| 方法编号 | 常量名 | 用途 | 关键参数 |
|---|---|---|---|
| 1 | SJCL |
早期条目加密 | AES-CCM / iter:101 / ks:128 |
| 2 | SJCL1a |
条目加密(2020-03 起,本例所用) | AES-CCM / iter:101 / ks:128,先 escape() 处理避免非法 UTF-8 报错 |
| 3 | SJCL1b |
条目加密(2023-06 起) | AES-CCM / iter:101 / ks:256 |
| 4 | SJCL2 |
早期主密钥加密 | AES-OCB2 / iter:10000 / ks:256 |
| 5 | SJCL3 |
遗留方法(源码注明未使用,仅为保证历史数据可解密而保留) | AES-CCM / iter:1000 / ks:128 |
| 6 | SJCL4 |
主密钥加密(info.json 中 encryption_method: 4 所指) |
AES-CCM / iter:10000 / ks:256 |
| 7 | KeyV1 |
新一代密钥派生(2024-08 起) | 原生 AES-256-GCM + PBKDF2,iterationCount: 220000(OWASP 建议值) |
| 8 | FileV1 |
新一代文件加密 | AES-256-GCM,128KB 分块 |
| 9 | StringV1 |
新一代字符串加密 | AES-256-GCM,64KB 分块 |
由此可以完整还原本例的安全设计:外层用 iter:10000 的 SJCL4 保护主密钥(抵御离线字典攻击);内层用 iter:101 的 SJCL1a 保护笔记正文(派生开销小、解密快)。这就是快照中 info.json 的 iter:10000 与条目密文的 iter:101 数值不同的根本原因。
七、E2EE 快照如何驱动迁移测试
以上所有细节最终服务于一个目的:验证历史加密数据在版本升级后仍可无损解密。E2EE 迁移测试的完整流程(synchronizer_MigrationHandler.test.ts)为:
deploySyncTargetSnapshot('e2ee', migrationVersion - 1):部署 v3 之前的 E2EE 快照(即 v1、v2 的 e2ee 目录);Setting.setConstant('syncVersion', migrationVersion)后调用migrationHandler().upgrade()执行升级;- 校验
info.json中版本号已变为目标版本、目录结构符合断言; - 执行
synchronizer().start()同步升级后的目标; - 解密验证:取
MasterKey.all()[0]得到主密钥,注入密码123456到encryption.passwordCache,经loadMasterKeysFromSettings(encryptionService())加载密钥,再启动decryptionWorker().start()完成批量解密; checkTestData(testData)断言所有测试数据(文件夹/笔记/标签/资源)与加密前完全一致(syncTargetUtils.ts);- 切换到第二客户端重复同步,验证"未解密时读取失败(
expectThrow)、解密后读取成功(expectNotThrow)"的对比行为。
测试数据集合 testData(syncTargetUtils.ts)包含嵌套文件夹、带图片资源的笔记、多标签笔记等结构,覆盖了加密同步的典型场景;本快照中的文件夹/笔记条目正是这套数据在 v3 E2EE 状态下的真实落盘结果。整条链路证明:即使同步目标格式从 v1 演进到 v3,只要主密钥仍在 info.json 中且可被密码解锁,历史加密内容就能被正确迁移与解密。
八、实践指南:如何查看与复用这些快照
- 查看:快照文件是纯文本 Markdown/JSON,直接用任意编辑器打开即可;注意密文为真实加密数据,无法直接还原明文;
- 对应关系:
type_1=笔记、2=文件夹;parent_id指向父条目;主密钥 ID 可在同目录 info.json 中查找; - 运行迁移测试:在
packages/lib下执行对应的 Jest 用例(synchronizer_MigrationHandler套件),测试会自动部署 v1/v2 快照并升级到 v3。注意测试依赖文件系统同步目标,且需将syncTargetName_设为"filesystem"(synchronizer_MigrationHandler.test.ts 的beforeEach中已强制设置); - 重新生成快照:按 synchronizer_MigrationHandler.test.ts 头部注释,以
filesystem为目标运行createSyncTargetSnapshot.js的两个参数即可重建当前版本的 normal / e2ee 快照; - 验证完整性:
checkTestData(testData)会逐条校验文件夹标题、笔记正文中的资源引用、标签关联关系,可作为"升级未损坏数据"的验收手段。
九、小结:从一条密文看懂 Joplin 的加密同步设计
这条 04761c7a9930415f95ce98dac1706411.md 虽然只有 26 行,却浓缩了 Joplin 同步架构的四个关键设计:
- 格式版本化:
info.json承载version,MigrationHandler逐版本迁移,旧版本快照可被测试复现并升级(migrations/2.ts、migrations/3.ts); - 元数据与数据分离:E2EE 开关、主密钥列表等全局状态放
info.json(带时间戳合并,支持多客户端协商);条目内容按需加密; - 双层密钥体系:主密钥用高迭代(
iter:10000)算法保护,条目用低迭代(iter:101)算法保护,兼顾安全性与同步性能; - 自描述密文头:
JED01头内嵌加密方法编号与主密钥 ID,使得密文不依赖外部上下文即可定位解密所需密钥与算法——这正是多年格式演进后,新旧客户端仍能互读历史数据的底层保证。
当你再看到任何一个以 JED010000... 开头的 Joplin 同步文件时,就能按本文的拆解顺序:解析头、定位主密钥、识别加密方法、理解 SJCL 参数,完整还原其加密设计与解密路径。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.21 K637- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python250
jforgamejforgame是一个一站式游戏服务器开发框架。包含游戏服务器开发所需要的各种组件,比如网关,socket服务端与客户端,自定义高效消息编解码,游戏热更新,游戏通用工具等等。包含游戏服,跨服,匹配服,后台管理系统等实现,同时提供大量业务案例以供学习。亦可用于其他socket应用,例如及时聊天等。Java301
fizz-gateway-nodeAn Aggregation API Gateway in Java . FizzGate 是一个基于 Java开发的微服务聚合网关,是拥有自主知识产权的应用网关国产化替代方案,能够实现热服务编排聚合、自动授权选择、线上服务脚本编码、在线测试、高性能路由、API审核管理、回调管理等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行API服务治理、减少中间层胶水代码以及降低编码投入、提高 API 服务的稳定性和安全性。Java210
certd开源SSL证书管理工具;全自动证书申请、更新、续期;通配符证书,泛域名证书申请;证书自动化部署到阿里云、腾讯云、主机、群晖、宝塔;https证书,pfx证书,der证书,TLS证书,nginx证书自动续签自动部署JavaScript190
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python300