密文文件格式深度解析:从 syncTargetSnapshots 测试快照看 JED 头与 SJCL 密文结构)
Joplin 端到端加密E2EE密文文件格式深度解析从 syncTargetSnapshots 测试快照看 JED 头与 SJCL 密文结构【免费下载链接】joplinJoplin - the privacy-focused note taking app with sync capabilities for Windows, macOS, Linux, Android and iOS.项目地址: https://gitcode.com/GitHub_Trending/jo/joplinJoplin 是一款以隐私为核心、支持全平台同步的笔记应用其端到端加密E2EE机制保证笔记、文件夹、标签等数据在离开本地前就被加密服务端只能看到密文。本篇文章以仓库中 syncTargetSnapshots/1/e2ee 目录 下的一份加密笔记快照文件273392c6dfee427b90225e7d1de78dfc.md为骨架逐字段、逐字节拆解 Joplin 加密笔记的真实落盘格式JED 文件头、SJCL 密文 JSON 参数、分块加密与分块长度前缀并说明这些快照在同步目标迁移测试中的用途。读完本文你将能读懂任意一份 Joplin E2EE 同步文件的结构并理解加密体系在同步、迁移、解密流水线中的实际工作方式。一、背景syncTargetSnapshots 是什么在 Joplin 仓库中packages/app-cli/tests/support/syncTargetSnapshots 目录存放着同步目标快照sync target snapshot——即某一版本syncVersion客户端执行一次完整同步后同步目标例如文件系统目录上留下的真实文件集合。快照按版本号/类型组织例如1/normalsyncVersion 1、未启用 E2EE 的普通快照1/e2eesyncVersion 1、启用 E2EE 的快照其中locks/、temp/等目录结构也与真实同步目标一致2/...、3/...更高同步版本对应的快照。本文分析的273392c6dfee427b90225e7d1de78dfc.md即位于1/e2ee下是快照生成时写入同步目标的一篇加密笔记文件。文件名就是笔记在数据库中的id文件内容则是笔记字段序列化后经过 E2EE 加密的结果——这也是为什么快照里几乎每个.md文件都以JED0100002205...开头。快照的生成方式快照并非手工编写而是由测试工具代码生成。在 packages/lib/testing/syncTargetUtils.ts 中testData定义了标准测试数据树3 个文件夹、5 篇笔记部分笔记带附件资源与标签main(syncTargetType)负责创建测试数据、按需开启 E2EEsetEncryptionEnabled(true)并loadEncryptionMasterKey()随后执行一次完整同步同步完成后把syncDir整体复制到${snapshotBaseDir}/${syncVersion}/${syncTargetType}即为快照。其中snapshotBaseDir ${supportDir}/syncTargetSnapshots。因此1/e2ee快照中的所有密文都是 Joplin 真实加密流水线EncryptionService的输出可作为研究密文格式的第一手证据。快照的消费方式同步目标迁移测试快照最主要的消费者是 synchronizer_MigrationHandler.test.ts。文件头部的注释明确说明To create a sync target snapshot for the current syncVersion: … then run:node tests/support/createSyncTargetSnapshot.js normal node tests/support/createSyncTargetSnapshot.js e2ee。这些测试的工作方式是取版本 n 的快照并升级到 n1。测试通过deploySyncTargetSnapshot(e2ee, migrationVersion - 1)将旧版本快照部署为同步目标再用 MigrationHandler 执行升级随后对新目标执行同步、解密decryptionWorker().start()并调用checkTestData(testData)校验数据未被迁移破坏。这一机制正是本文密文格式存在的主要目的作为回归测试的基准数据验证任何加密格式或同步格式的改动都不会破坏旧数据。二、加密笔记文件的完整字段结构打开273392c6dfee427b90225e7d1de78dfc.md可以看到一个「字段名 空行 值」风格的序列化文件与 Joplin 数据库中的笔记实体字段一一对应。以下逐字段解读字段示例值含义id273392c6dfee427b90225e7d1de78dfc笔记全局唯一 ID32 位十六进制也是文件名parent_idc65e06fbbe4d456aafbbf0264be59e06所属文件夹 IDcreated_time/updated_time空 /2020-07-25T10:37:00.287Z创建/更新时间快照生成时部分字段为空is_conflict空是否为冲突笔记latitude/longitude/altitude空地理位置字段author/source_url/source空笔记来源信息is_todo/todo_due/todo_completed空待办相关字段source_application空创建来源应用application_data/order/user_created_time/user_updated_time空应用数据与用户时间戳encryption_cipher_textJED0100002205c24138199f...密文详见下文格式拆解encryption_applied1标记该笔记已应用加密markup_language空笔记正文标记语言is_shared空是否共享type_1实体类型编号1 表示笔记见 BaseModel.ts 的ModelType枚举Note1、Folder2、Tag5、MasterKey9 等值得注意密文文件依然保留明文元数据字段如id、parent_id、type_、updated_time等。这些字段是同步引擎定位、排序、冲突检测所必需的索引信息因此刻意保持明文而笔记的正文、标题、标签关联等内容字段则被整体加密进encryption_cipher_text。也就是说E2EE 保护的是内容而非文件系统层面的全部信息。同一快照目录下的其他文件印证了这一点c65e06fbbe4d456aafbbf0264be59e06.mdtype_: 2是文件夹实体同样只有encryption_cipher_text承载内容77c94e3da5d44db28eb485162d1b3f41.mdparent_id为空、type_: 2是根级文件夹c24138199f5b403fa3e9b8b4f22685c5.mdtype_: 9为主密钥实体且带有独立的encryption_method: 4对应EncryptionMethod.SJCL4字段与source_application: net.cozic.joplintest-cli。三、逐字节拆解 JED 加密头encryption_cipher_text的开头是JED0100002205c24138199f5b403fa3e9b8b4f22685c5000470{...}从源码 EncryptionService.ts 的 encodeHeader_ 与 decodeHeaderSource_ 可以还原出完整的 JED 头格式。头部按如下顺序拼接段长度内容本例实际值标识符3 字符JEDJoplin Encrypted DataJED模板版本2 个十六进制字符头模板版本号decodeHeaderBytes_中parseInt(reader.read(2), 16)011对应头模板 v1元数据长度6 个十六进制字符后续加密元数据的总字节数十六进制00002234加密方法2 个十六进制字符见下方EncryptionMethod枚举055SJCL1a主密钥 ID32 个十六进制字符用于解密本段内容的 Master Key IDc24138199f5b403fa3e9b8b4f22685c5密文负载剩余部分SJCL JSON 密文0470{...}头模板header template的定义头模板在 EncryptionService.ts 构造函数 中定义this.headerTemplates_ { // Template version 1 1: { // Fields are defined as [name, valueSize, valueType] fields: [[encryptionMethod, 2, int], [masterKeyId, 32, hex]], }, };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}; }即JED 模板版本01 6 位十六进制元数据长度 2 位十六进制加密方法 32 位十六进制主密钥 ID。本例中00002234正是052 字节 32 字节主密钥 ID 的总长。解码侧通过isValidHeaderIdentifier校验标识符/JED\d\d/.test(id)见 EncryptionService.ts 第 21-25 行因此JED01、JED02均合法JEDxx只要后两位是数字即可。itemIsEncrypted与fileIsEncrypted都依靠这个正则来快速判断一段数据是否为 Joplin 加密数据。EncryptionMethod 枚举与默认值密文中的05对应 EncryptionService.ts 的 EncryptionMethod 枚举export enum EncryptionMethod { SJCL 1, // Deprecated - OCB2 模式不再安全勿使用 SJCL2 2, // Deprecated - 曾用于加密主密钥 SJCL3 3, // 保留兼容 SJCL4 4, // 曾用于加密主密钥OCB210000 迭代 SJCL1a 5, // AES-128-CCMiter101 Custom 6, // 自定义加密如 WebDAV 加密等第三方方案 SJCL1b 7, // AES-256-CCMiter101 KeyV1 8, // 主密钥加密PBKDF2 220000 迭代 FileV1 9, // 文件资源加密分块 128 KB StringV1 10, // 字符串/笔记内容加密分块 64 KB }当前默认值第 74-76 行笔记内容等字符串默认StringV1附件文件默认FileV1主密钥默认KeyV1。本例快照生成于 2020-07-25当时默认方法是SJCL1a枚举值 5与头中05完全吻合。源码注释显示SJCLOCB2 模式于 2020-01-23 弃用SJCL1aAES-128-CCMiter101于 2020-03-06 引入SJCL1bAES-256-CCMiter101于 2023-06-10 引入AES 密钥从 128 位升级到 256 位2024-08 起主密钥的 PBKDF2 迭代次数提升到 220000符合 OWASP 建议。兼容性设计所有旧方法1/2/3/5/7都保留在代码中以解密历史数据。这也是加密方法编码必须写在明文头部的原因——解密器必须能在不知道密钥的前提下先根据头部得知用哪种算法和哪个密钥去尝试解密。四、SJCL 密文 JSON加密参数逐项解读JED 头之后紧跟的{...}是 SJCLStanford JavaScript Crypto Library的 JSON 密文格式。0470前的数据是头部元数据0470之后开始是 SJCL JSON。以主密钥实体文件 c24138199f5b403fa3e9b8b4f22685c5.md 的content为例{iv:qukPmj886S4Y8nyT9z/WFA,v:1,iter:10000,ks:256,ts:64,mode:ccm,adata:,cipher:aes,salt:FTTpwryRSrM,ct:ShoeEpKzYWDz...}各参数含义与源码中的对应关系参数含义说明iv初始化向量Base64 编码随机生成保证同一密钥下每次加密结果不同vSJCL 格式版本固定为 1iterPBKDF2 迭代次数见下方双 KDF 层次分析ksAES 密钥长度bit128 或 256tsGCM/CCM 认证标签长度bit64mode分组密码工作模式ccm推荐或ocb2旧版已弃用adata关联数据additional data当前未使用为空字符串cipher底层分组密码固定为aessaltPBKDF2 盐值Base64 编码随机生成与iter共同用于从主密钥推导加密密钥ct密文Base64 编码的实际加密数据回到笔记文件273392c6dfee427b90225e7d1de78dfc.md其 SJCL 参数为v:1,iter:101,ks:128,ts:64,mode:ccm,cipher:aes与EncryptionMethod.SJCL1a的加密实现EncryptionService.ts 第 385-402 行逐项一致[EncryptionMethod.SJCL1a]: () { return sjcl.json.encrypt(key, escape(plainText), { v: 1, iter: 101, // 主密钥已做过密钥派生这里无需高迭代SJCL 强制 iter 100 ks: 128, // AES-128 ts: 64, mode: ccm, cipher: aes, }); },注意escape(plainText)源码注释说明 SJCL 内部使用encodeURIComponent处理数据只接受合法 UTF-8而笔记偶尔含非法 UTF-8 内容因此加密前先escape转义以避免抛错。双 KDF 层次主密钥与内容密钥从参数可以看到两套完全不同的密钥派生强度这正是 Joplin E2EE 的双 KDF设计主密钥加密层KeyV1/ 旧版SJCL4用用户的密码经 PBKDF2iter10000旧版主密钥KeyV1已提升至iterationCount: 220000见 EncryptionService.ts 第 480-560 行派生密钥加密主密钥本体。这一层迭代次数高因为它是抵御离线密码猜测的第一道防线内容加密层StringV1/SJCL1a/SJCL1b用已解密的主密钥作为输入再经低迭代 PBKDF2iter101派生会话密钥加密笔记内容。因为输入本身已是高熵的 256 位主密钥无需高迭代即可保证安全同时大幅提升移动端的加解密速度。源码注释还记录了一个重要的性能经验EncryptionService.ts 第 111-125 行在 Android 模拟器上解密 50 KB 分块约需 1000 ms而 5 KB 分块仅约 10 ms——分块缩小 10 倍速度提升约 100 倍因此 SJCL 系列的分块大小固定为 5000 字节。分块加密长度前缀协议笔记正文可能远超单个 SJCL 密文能舒适处理的长度因此encryptAbstract_EncryptionService.ts 第 581-615 行将数据按chunkSize切块每块独立加密后按6 位十六进制长度前缀 密文块的顺序写入解密方decryptAbstract_第 618-643 行先读 6 位十六进制长度再读取并解密对应字节直到流结束。各加密方法的分块大小chunkSize映射第 126-142 行方法分块大小说明SJCL 系列1/2/3/5/75000 字节移动端性能权衡的产物KeyV15000 字节主密钥本身不分块该值实际不生效FileV1131072 字节128 KB附件等大文件StringV165536 字节64 KB笔记内容字符串由于块长度前缀内嵌在密文中将来调整分块大小不会破坏旧数据的可解密性——这正是注释可以随时更改因为分块大小已并入加密数据的含义。五、加密笔记在同步与解密流水线中的位置快照文件不仅是静态证据更贯穿 Joplin 的同步与解密流程同步上传启用 E2EE 后本地笔记先经EncryptionService.encryptString加密得到encryption_cipher_text与encryption_applied: 1再由Synchronizer写入同步目标形成与快照一致的文件同步下载与解密对端客户端同步到密文后DecryptionWorkerpackages/lib/services/DecryptionWorker.ts解析 JED 头取出masterKeyId从已加载的主密钥池中找到对应密钥按头部的encryptionMethod选择解密算法逐块解密并回写数据库迁移升级当同步版本升级如 1→2→3MigrationHandler.upgrade逐版本执行迁移packages/lib/services/synchronizer/MigrationHandler.ts 中migrations数组注册了 migration1/2/3迁移期间持有排他锁并持续刷新保证多客户端不会同时写入版本校验checkCanSync会比较同步目标info.json中的版本与客户端支持的syncVersion当前默认值 3见 Setting.ts 第 306 行目标版本更高抛outdatedClient更低抛outdatedSyncTarget防止新旧格式互写破坏数据。在 synchronizer_MigrationHandler.test.ts 的testMigrationE2EE中测试流程完整展示了密文数据的生命周期await deploySyncTargetSnapshot(e2ee, migrationVersion - 1); // 部署旧版 E2EE 快照 Setting.setConstant(syncVersion, migrationVersion); await migrationHandler().upgrade(migrationVersion); // 升级同步目标 await synchronizer().start(); // 同步 Setting.setObjectValue(encryption.passwordCache, masterKey.id, 123456); await loadMasterKeysFromSettings(encryptionService()); await decryptionWorker().start(); // 加载主密钥并解密 await expectNotThrow(async () await checkTestData(testData)); // 校验数据完整注意测试中第二个客户端client 2在解密前调用checkTestData会预期抛错——这从侧面验证了快照中的密文确实无法在无密钥时被读取E2EE 语义成立。六、如何验证与复现从快照到本地同步目录快照目录的布局与真实文件系统同步目标完全一致locks/、temp/、.resource/、info.json等因此可直接用于本地验证将packages/app-cli/tests/support/syncTargetSnapshots/1/e2ee下所有文件复制到任意空目录作为文件系统同步目标在 Joplin 客户端中把同步目标设置为该目录并在加密设置中提供主密钥密码触发同步后DecryptionWorker会解密所有密文笔记恢复出快照生成时的测试数据树。若想重新生成快照syncTargetUtils.ts的注释给出了步骤在test-utils中将syncTargetName_设为filesystem然后运行node tests/support/createSyncTargetSnapshot.js normal node tests/support/createSyncTargetSnapshot.js e2ee生成的快照会写入syncTargetSnapshots/${syncVersion}/${类型}目录可用于对比不同版本客户端加密输出的差异例如对比1/e2ee与3/e2ee中同名文件的encryption_method变化。七、总结273392c6dfee427b90225e7d1de78dfc.md虽然只是测试快照中的一页密文但它完整呈现了 Joplin E2EE 的落盘形态明文元数据 密文内容id、parent_id、type_等同步必需字段保持明文正文等内容字段整体加密JED 头JED 头模板版本 6 位十六进制元数据长度 2 位十六进制加密方法 32 位主密钥 ID是解密的第一把钥匙SJCL 密文iv/salt/iter/ks/ts/mode/cipher/ct完整记录加密参数配合主密钥高强度 KDF 内容低强度 KDF的双层设计在安全与移动端性能之间取得平衡分块协议6 位十六进制长度前缀 密文块让算法演进与分块调整不破坏历史数据。这些机制共同保障了 Joplin 的承诺无论同步到哪台服务器未经解密的数据对服务商和中间人始终是不可读的密文。而syncTargetSnapshots快照与迁移测试则为这份加密格式的长期稳定演进提供了可验证的回归保障。深入阅读指引加密核心实现packages/lib/services/e2ee/EncryptionService.ts加密单元测试packages/lib/services/e2ee/EncryptionService.test.ts解密工作器packages/lib/services/DecryptionWorker.ts快照生成工具packages/lib/testing/syncTargetUtils.ts迁移处理与测试packages/lib/services/synchronizer/MigrationHandler.ts、packages/lib/services/synchronizer/synchronizer_MigrationHandler.test.ts同步信息与 E2EE 开关packages/lib/services/synchronizer/syncInfoUtils.ts快照数据本身packages/app-cli/tests/support/syncTargetSnapshots/1/e2ee【免费下载链接】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),仅供参考