首页
/ Joplin 同步目标快照与 E2EE 加密格式深度解析:以 v3 快照中加密笔记条目为例

Joplin 同步目标快照与 E2EE 加密格式深度解析:以 v3 快照中加密笔记条目为例

2026-09-10 16:02:10作者:魏侃纯Zoe

本篇文章基于 Joplin 仓库中一份真实的测试快照文件展开。这份位于 packages/app-cli/tests/support/syncTargetSnapshots/3/e2ee/04761c7a9930415f95ce98dac1706411.md 的文件,记录了 Joplin 在 syncVersion 3 + E2EE 加密 状态下,一条加密笔记(type_ 1)在同步目标上的完整落盘形态。通过逐字段拆解该文件、对照 EncryptionServiceMigrationHandlersyncInfoUtils 等源码与对应迁移测试,你将掌握: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.tstestMigration / testMigrationE2EE 两个测试用例的核心做法。

值得注意的是,该测试文件头部的注释保留了快照的再生成命令:在 test-utils 中将 syncTargetName_ 设为 "filesystem" 后,依次执行 node tests/support/createSyncTargetSnapshot.js normalnode tests/support/createSyncTargetSnapshot.js e2ee 即可重新生成整套快照。

二、syncVersion 3 与快照目录的演进依据

syncTargetSnapshots/3/ 对应 Joplin 当前的同步目标版本号 syncVersion: 3,该值定义于 Setting.ts 的默认设置中。同步版本的用途与迁移机制记录在 MigrationHandler.tsmigrations 数组里,迁移历史如下:

版本 迁移动作 源码位置
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 后,同步目标根目录应恰好存在 .resourcelockstemp 三个目录与一个 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.tsSyncInfo 类的 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 带时间戳字段的合并规则

e2eeactiveMasterKeyId 等字段都带 updatedTime,这是为了让多客户端对"是否开启加密""用哪把主密钥"达成一致。mergeSyncInfos()syncInfoUtils.ts)逐字段比较时间戳,取较新者;而对 masterKeys 数组则按密钥 ID 合并、取 updated_time 更新的版本。正是这套机制保证了快照中 activeMasterKeyIdmasterKeys[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_: 2parent_id 为空,二者通过 parent_id 构成父子关系;该文件夹同样被加密(encryption_cipher_textJED01 开头),encryption_applied: 1

4.1 空字段的意义

created_timeis_conflict 等字段在快照中为空,是因为这些值在同步序列化时被省略(值为空/默认值时不留存)。而 updated_timeencryption_cipher_textencryption_appliedtype_必填核心字段,是同步引擎判断条目版本与加密状态的关键。

五、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.jsonmasterKeys 中找到对应密钥,解密出密钥明文;随后按头中记录的加密方法(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.jsoniter:10000 与条目密文的 iter:101 数值不同的根本原因。

七、E2EE 快照如何驱动迁移测试

以上所有细节最终服务于一个目的:验证历史加密数据在版本升级后仍可无损解密。E2EE 迁移测试的完整流程(synchronizer_MigrationHandler.test.ts)为:

  1. deploySyncTargetSnapshot('e2ee', migrationVersion - 1):部署 v3 之前的 E2EE 快照(即 v1、v2 的 e2ee 目录);
  2. Setting.setConstant('syncVersion', migrationVersion) 后调用 migrationHandler().upgrade() 执行升级;
  3. 校验 info.json 中版本号已变为目标版本、目录结构符合断言;
  4. 执行 synchronizer().start() 同步升级后的目标;
  5. 解密验证:取 MasterKey.all()[0] 得到主密钥,注入密码 123456encryption.passwordCache,经 loadMasterKeysFromSettings(encryptionService()) 加载密钥,再启动 decryptionWorker().start() 完成批量解密;
  6. checkTestData(testData) 断言所有测试数据(文件夹/笔记/标签/资源)与加密前完全一致(syncTargetUtils.ts);
  7. 切换到第二客户端重复同步,验证"未解密时读取失败(expectThrow)、解密后读取成功(expectNotThrow)"的对比行为。

测试数据集合 testDatasyncTargetUtils.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.tsbeforeEach 中已强制设置);
  • 重新生成快照:按 synchronizer_MigrationHandler.test.ts 头部注释,以 filesystem 为目标运行 createSyncTargetSnapshot.js 的两个参数即可重建当前版本的 normal / e2ee 快照;
  • 验证完整性checkTestData(testData) 会逐条校验文件夹标题、笔记正文中的资源引用、标签关联关系,可作为"升级未损坏数据"的验收手段。

九、小结:从一条密文看懂 Joplin 的加密同步设计

这条 04761c7a9930415f95ce98dac1706411.md 虽然只有 26 行,却浓缩了 Joplin 同步架构的四个关键设计:

  1. 格式版本化info.json 承载 versionMigrationHandler 逐版本迁移,旧版本快照可被测试复现并升级(migrations/2.tsmigrations/3.ts);
  2. 元数据与数据分离:E2EE 开关、主密钥列表等全局状态放 info.json(带时间戳合并,支持多客户端协商);条目内容按需加密;
  3. 双层密钥体系:主密钥用高迭代(iter:10000)算法保护,条目用低迭代(iter:101)算法保护,兼顾安全性与同步性能;
  4. 自描述密文头JED01 头内嵌加密方法编号与主密钥 ID,使得密文不依赖外部上下文即可定位解密所需密钥与算法——这正是多年格式演进后,新旧客户端仍能互读历史数据的底层保证。

当你再看到任何一个以 JED010000... 开头的 Joplin 同步文件时,就能按本文的拆解顺序:解析头、定位主密钥、识别加密方法、理解 SJCL 参数,完整还原其加密设计与解密路径。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.15 K
2.78 K
kernelkernel
deepin linux kernel
C
34
18
docsdocs
暂无描述
Markdown
904
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
931
1.86 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
862
1.36 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.95 K
1.03 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.38 K
1.47 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
535
605
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
549
398
leetcodeleetcode
🔥LeetCode solutions in any programming language | 多种编程语言实现 LeetCode、《剑指 Offer(第 2 版)》、《程序员面试金典(第 6 版)》题解
Markdown
77
23