Ente 2of3 原理深度解析:Shamir 秘密共享、卡片份额格式与离线恢复机制
【免费下载链接】ente💚 End-to-end encrypted cloud for everything.项目地址: https://gitcode.com/GitHub_Trending/en/ente
2of3 是 Ente 推出的免费端到端私有 Web 应用(部署于 2of3.ente.com),它把一个重要秘密拆成三张恢复卡片,任意两张即可还原秘密,单张卡片则什么都得不到。本文以 2of3 的官方原理文档为主体,结合 web/apps/twoof3 下的开源源码,逐层拆解其背后的 Shamir 秘密共享数学、卡片上份额的二进制格式、版本兼容策略、离线恢复 HTML 文件以及宽容的 QR 解码流程。读完本文,你将能读懂一张 2of3 卡片上的每一个字节,理解"任意两卡恢复"在 GF(256) 上如何成立,并掌握如何审计、校验甚至自行实现一套恢复工具。
Shamir 秘密共享速览:2of3 的数学基础
2of3 建立在 1979 年提出的经典密码学方案Shamir 秘密共享之上。它实现的是该方案最简单也最有趣的场景:把一个秘密拆成三份份额,任意两份即可重构原秘密,而单独一份除透露秘密的字节长度外,对内容一无所知。
一条直线穿过两个点的直觉
Shamir 方案的直觉可以用一条直线来理解。任取一条穿过 y 轴的直线:y 轴截距就是你的"秘密",斜率则是一个用完即弃的随机数。随后取三个不同的 x 值(例如 x = 1、2、3),计算直线上对应的 y 值,每一对 (x, y) 就是一份"份额"。
- 把任意一份份额(直线上一个点)交给某人,他可以画出无数条经过该点的直线,关于 y 轴截距一无所知;
- 把任意两份份额(直线上两个点)交给某人,同时经过两点的直线有且仅有一条,他能直接算出 y 轴截距,也就是秘密。
为什么必须在 GF(256) 上做算术
如果直接在普通整数上执行上述运算会泄露信息(例如大秘密产生的份额大小具有可预测性),因此 Shamir 方案把算术放在有限域中进行。2of3 使用GF(256)(含 256 个元素的有限域),它的便利之处在于:每一个字节都是合法元素。这让 2of3 可以逐字节拆分秘密,且每份份额与秘密完全等长。
在 2of3 使用的 (k=2, n=3) 方案中,逐字节的数学可以完整写出来:
- 对每个秘密字节s,2of3 选取一个均匀随机的字节r;
- 份额 1 存储sXORr;
- 份额 2 存储sXOR (r × 2 in GF(256));
- 份额 3 存储sXOR (r × 3 in GF(256))。
GF(256) 的乘法采用标准 AES 多项式(0x11b)。上述三份份额中的任意两份,加上它们的份额编号(即 "x 值" 1、2、3),就足以逐字节还原s,从而拼回完整秘密;而单独一份只是sXOR(某个随机字节),与随机数据不可区分。
源码 shamir.ts 中的splitSecret与这份数学一一对应:常数GF_POLY = 0x11b定义了域多项式,gfMul用"移位 + 按位异或"实现 GF(256) 乘法,拆分时用crypto.getRandomValues一次性生成与"秘密 + 校验和"等长的随机系数数组,然后逐字节写入三份份额。
与 Legacy Kit 的关系
2of3 刻意只实现 2-of-3 这一种场景,以保证界面、卡片、恢复流程和解释都能做到具体而无摩擦。同样的 2-of-3 数学也支撑着 Ente 账户版的 Legacy Kit:它在同一套 2-of-3 数学之上,叠加了由服务器介导、可撤销、且与 Ente 账户绑定的恢复流程。如果你需要面向研究项目的通用 k-of-n Shamir 共享,可以参考 SLIP-0039 等标准;2of3 的定位是把 2-of-3 对普通人做到零摩擦,而非做一个通用密码学库。
卡片上到底有什么:文本码与份额格式
每张卡片携带一份份额,编码为一串短文本码(同卡上还会渲染为 QR 码,方便手机拍摄)。文本码形如:
2of3-AQEA...2of3-前缀的作用有二:让读者一眼认出这是 2of3 份额;让 2of3 自身在收到非份额文本时能给出清晰的错误提示("That code does not look like a 2of3 share.",即"该代码看起来不像 2of3 份额")。源码 shamir.ts 中定义了SHARE_PREFIX = "2of3-"。
前缀之后是 base64url 编码的字节负载,负载由 10 字节头部加份额数据构成。编码与解码分别由base64UrlEncode/base64UrlDecode完成,标准 base64 的+、/、=被替换为-、_与空(参见 shamir.ts)。
份额格式
从负载起始处读起,各字节含义如下:
| 字节 | 字段 | 含义 |
|---|---|---|
| 0 | 版本 | 当前格式恒为2;恢复流程也兼容版本 1 创建的卡片 |
| 1 | 卡片编号 | 1、2或3,即恢复时用于合并份额的 "x 值" |
| 2-3 | 长度 | 秘密字节数的 16 位大端(big-endian)长度;恢复时两份份额的该字段必须一致 |
| 4-9 | 随机 ID | 6 字节crypto.getRandomValues输出,每套卡片生成时新鲜产生;同一秘密的三张卡片共享该 ID |
| 10+ | 份额数据 | 秘密字节的份额,后随 4 字节校验和;该校验和与秘密一起受到两卡阈值保护 |
源码中的常量与此完全吻合:shamir.ts 定义HEADER_LENGTH = 10(4 字节版本/编号/长度 + 6 字节 ID)、CHECKSUM_LENGTH = 4、ID_LENGTH = 6;splitSecret在拆分前把secretBytes与checksumBytes(secretBytes)拼接为protectedBytes(shamir.ts),因此校验和作为"被保护数据"的一部分参与份额拆分——这正是下文 v2 格式安全性的来源。
随机 ID 与受保护的校验和:恢复为何既安全又贴心
ID 与受保护的校验和,是恢复流程能够"安全且友好"的两大支柱:
- 随机 ID 用于识别错配卡片。若把来自两套不同卡片的份额组合在一起,两份卡片都能正常解析,但 ID 不一致,2of3 会停止并提示:"These two cards are from different sets. Match the ID on both cards."("这两张卡片来自不同套。请匹配两张卡片上的 ID。")。卡片 UI 会把 base64url 编码后的 ID 前 8 个字符作为人可读的指纹展示(例如
ID A1B2C3D4),方便你肉眼比对卡片。 - 校验和用于识别损坏卡片。拆分之前,2of3 先把 32 位 FNV-1a 校验和附加到秘密之后;合并两份卡片后,重新计算校验和并与恢复出的值比对。不一致时报错 "These shares did not reconstruct a valid secret."("这些份额未能还原出有效秘密。"),而不是静默返回垃圾数据。由于校验和与秘密一起被拆分保护,单张卡片无法用它来试探关于秘密的猜测。
校验和的实现见 shamir.ts:checksumBytes以0x811c9dc5为偏移基、0x01000193为素数,通过Math.imul保证 32 位乘法语义,输出 4 字节大端结果,是标准 FNV-1a 32 位实现。
秘密大小的上限
2of3 接受的最大秘密为 2048 字节(格式层面的MAX_SECRET_BYTES,见 shamir.ts),但实际限制是"能放进一张可读的 QR 码加四行打印文本的卡片",目前约为 200 字节文本。这一实际上限由打印布局决定:卡片渲染逻辑把份额文本按每行最多 80 字符、共 4 行打印(PRINTED_SHARE_CHARS_PER_LINE = 80、PRINTED_SHARE_LINE_COUNT = 4,见 export.ts),maxSecretBytesForEncodedShareLength据此反推可容纳的秘密字节数。界面中Secret输入框下方的字节计数器会实时显示当前上限。
格式兼容性:版本 1 与版本 2
早期版本创建的是版本 1 卡片:校验和明文存放,单张卡片即可确认低熵秘密的猜测。版本 2 把校验和保护在两卡阈值之后,堵住了这一缺口。
恢复流程同时支持两个版本(shamir.ts 中的parseShare会读取版本号:版本 1 使用LEGACY_HEADER_LENGTH = 14——即 4 字节版本/编号/长度 + 6 字节 ID + 4 字节明文校验和,数据区仅含秘密字节;版本 2 使用 10 字节头部,校验和随秘密一起拆分)。如果你持有较旧的离线恢复页面,重新生成一套卡片即可获得当前版本,或在 2of3.ente.com 上直接恢复。若某张版本 1 卡片可能已经暴露且秘密可被猜测,请在制作新卡片前先轮换(rotate)该秘密。
恢复算法:任意两张卡片如何还原秘密
恢复的关键在 shamir.ts 的combineShares。它的步骤可以概括为:
解析两份额并做三组前置校验:版本必须一致("These two cards use different formats.");ID 与长度必须一致(否则报"来自不同套");卡片编号必须不同(否则报"Use two different cards from the same set.",即需使用同一套中两张不同的卡片)。
逐字节插值。设两份份额的编号分别为
i₁、i₂,分母denominator = i₁ ^ i₂,对每个字节计算:output[i] = gfMul(share₁[i], gfDiv(i₂, denominator)) ^ gfMul(share₂[i], gfDiv(i₁, denominator))由于份额 1 =s^r、份额 2 =s^ (r·2)(GF(256) 乘法),把上式按 XOR 对加法和乘法分配律展开后,随机项
_r_恰好抵消,_s_项系数为 1,于是逐字节精确还原出"秘密 + 校验和"。gfDiv通过gfInv(利用gfPow(value, 254)求逆元)实现域内除法。校验:对前
length字节重新计算 FNV-1a 校验和并与恢复出的尾部 4 字节比对;不一致即报"These shares did not reconstruct a valid secret."。最后用严格 UTF-8 解码(TextDecoder("utf-8", { fatal: true })),非 UTF-8 文本报"These shares did not reconstruct readable text."。
从源码结构可以看出,恢复流程在parseShare阶段就已把 ID、编号、长度全部提取出来用于人类可读的状态提示(如Card 2 from ID A1B2C3D4),并在界面层(index.tsx)预先检测"ID 或长度不匹配"与"重复卡片"两类常见错误,尽早阻断无效组合。
离线恢复文件:2of3-recovery.html
点击Download all cards时,2of3 会在三张卡片 PNG 之外附带一个额外文件:2of3-recovery.html——恢复流程的一份离线副本。其中包含:
- 一个独立的 HTML 页面,承载与 2of3.ente.com 相同的恢复界面;
- 完整的 Shamir 合并逻辑,以内联 JavaScript 形式写出;
- 一个打包进文件内的 QR 解码器,可上传打印卡片的手机照片并本地解码;
- 零网络请求,不依赖 Ente、不依赖 2of3.ente.com、也不依赖任何 CDN。
双击即可在任意现代浏览器中打开。用该文件恢复与在 2of3.ente.com 上恢复完全一致:放入两张卡片(图片或文本),点击Recover secret,复制结果。
这个文件回答了"如果 2of3.ente.com 十年后不在了怎么办":答案是什么都不会发生——打开与卡片一同保存的 HTML 文件即可恢复。即使 Ente 公司整体消失,这个文件也不需要从我们这里获取任何东西。它只是几百行可审查的 JavaScript,做的正是 2of3.ente.com 所做的事。
[!IMPORTANT]
请像对待备份一样对待离线恢复文件。至少在一个耐久的介质上保存一份副本:U 盘、备份,或(若存放在保险柜中)与卡片一起打印。卡片是秘密本身,恢复文件是打开它们的工具。
从源码看,该文件由createOfflineRecoveryHtml生成(export.ts):完整的 Shamir 实现、份额解析、图像读取与 QR 解码逻辑都以字符串模板内联进 HTML,卡槽(Card A / Card B)支持上传图片、粘贴代码与拖放文件,恢复按钮直接调用内联的combineShares。QR 解码器源码由 offline-qr-source.ts 在构建期从qr-raw模块的源码文本剥离export关键字、去除 source map 注释后拼接而成,最终暴露为globalThis.__twoOf3DecodeQR供页面调用——因此离线文件中的解码能力与在线版完全同源。
这个文件还是一个可验证目标:因为它是自包含的纯 JavaScript,你可以通读它、做静态分析,或与 web/apps/twoof3 下的开源源码逐一对照,说服自己它确实言行一致——没有任何远程服务需要信任。
QR 解码器:对真实手机照片的宽容处理
2of3 的恢复流程接受的不只是裁剪干净的 QR 图片。打印卡片的手机照片通常让 QR 只占画面的一部分,四周是卡片其余内容。为了不强迫用户手动裁剪,QR 解码器会对每张上传图片尝试多轮解码(实现见 qr-code.ts):
- 以完整尺寸解码整张图片;
- 对整张图片做自动方形裁剪(
cropToSquare)后解码; - 依次做三次逐步收紧的卡片形裁剪——每轮都裁到 2of3 卡片上 QR 的大致位置(三组比例参数为
[0.086, 0.213, 0.829, 0.591]、[0.135, 0.238, 0.73, 0.49]、[0.16, 0.255, 0.68, 0.52]),每次裁剪后分别以"不裁剪"和"自动方形裁剪"两种方式解码。
第一个返回合法代码的尝试即获胜。实际上,一张整卡在画面内、对焦正常的随手手机照片,会在第一次或第二次卡片形裁剪时成功解码;如果你已经把图片裁剪得紧贴 QR,则整图尝试就会成功。同一套逻辑被打包进 离线恢复文件,因此离线恢复同样宽容。
若所有尝试都失败,恢复流程会提示 "Could not read that QR code."("无法读取该 QR 码。"),你可以换一张更清晰的照片,或直接粘贴文本码。图片读取与画布绘制走createImageBitmap优先、HTMLImageElement兜底的路径,解码失败的图片会报 "Could not read that image."(参见 qr-code.ts)。值得一提的是,卡片的 QR 码生成使用medium纠错级别(qr-code.ts),在信息密度与抗污损之间取了平衡,这也是手机照片能顺利解码的工程基础之一。
从原理到实践:卡片完整生命周期回顾
把原理落到使用上,一套 2of3 卡片的完整生命周期如下:
- 拆分:在浏览器内输入秘密(Ente 恢复密钥、密码管理器主密码、钱包助记词、全盘加密密钥或 2FA 备份码等),
splitSecret在本地用crypto.getRandomValues完成拆分——秘密在渲染任何内容之前就已本地拆分为份额,卡片图片与 QR 均在本地构建,没有任何内容会被发送给 Ente。 - 制卡与分发:每张卡片可单独打印、下载为高分辨率 PNG、通过系统分享面板分享、或仅复制
2of3-开头的文本码;Download all cards则一次性下载三张 PNG 与离线恢复文件。下载的 PNG 遵循${标签}-set-${指纹}-card-${编号}.png的命名规则(export.ts)。务必把三张卡片存放在三个相互独立的地方,切勿同处存放——两张卡片合起来就是完整秘密。 - 验证:存放前用任意两卡(最好是 1+2、1+3、2+3 全部组合)测试恢复,以捕获打印模糊、下载截断、混入异套卡片或复制丢字符等问题。
- 恢复:随时回到 2of3.ente.com 或打开离线文件,放入任意两张同套卡片(图片上传、拖放或粘贴文本码皆可),点击Recover secret得到原秘密。若不慎同时丢失两张卡片,则仅剩一份份额无法重构,此时应把秘密视为已丢失并按需轮换。
完整的界面操作步骤见 Getting started,常见问题见 FAQ,2of3 的整体定位见 2of3 概述。
总结
2of3 的可靠性建立在三层设计之上:数学层用 GF(256) 上的 Shamir 2-of-3 秘密共享保证"任意两卡还原、单卡无信息";格式层用 10 字节头部 + 份额数据的编码承载版本、卡片编号、长度、随机 ID 与受保护的 FNV-1a 校验和,让错配、损坏与格式不兼容都可被明确检测;分发层用可打印卡片、可复制的文本码与完全离线的2of3-recovery.html保证恢复不依赖任何在线服务。整条链路(拆分、制卡、恢复、离线文件生成)都落在 web/apps/twoof3 的开源源码中,任何持有卡片的人,都可以依据本文所述的份额格式与源码审计,亲手验证甚至独立实现一套自己的恢复工具。
【免费下载链接】ente💚 End-to-end encrypted cloud for everything.项目地址: https://gitcode.com/GitHub_Trending/en/ente
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考