你编写了一个工作簿,用密码对其进行加密,然后将文件交给同事,同事在 Excel 中打开它。Excel 询问密码。同事输入密码,Excel 接受了它。到目前为止,加密看起来是正确的。然后 Excel 弹出一个对话框,提示文件已损坏且无法打开,或者打开后显示一张写满无意义单元格的工作表。密码是正确的,但文件仍然损坏。这是 Office 加密中最令人困惑的失败模式,因为告诉你密码正确的部分和保存你数据的部分是由两个不同的操作保护的,保证其中一个正确并不能保证另一个
本文讲的两个陷阱正是这个形状。两种情况下验证器都通过、正文却不行,让你去找一个根本不存在的密码或密钥派生问题。真正的毛病在下游,在包字节怎么变换这一步。两个陷阱彼此独立,一个在 AES 路径上,一个在 RC4 路径上,但它们共享同一个诊断难题,所以值得看看为什么「半对」的结果是最难读的一种
为什么通过的密码无法证明关于主体的任何事情
现代加密的 XLSX 使用的格式是 ECMA-376 标准加密(Standard Encryption),它并排存储了两个加密的内容。一个是 EncryptionVerifier:一个保存随机值和该值哈希的小块,使用从密码派生的密钥进行加密。另一个是 EncryptedPackage:工作簿整个 zip 容器,使用相同的密钥加密。验证器的存在是为了让读取器在把精力花费在数兆字节的主体上之前确认密码。解密验证器,哈希随机值,与存储的哈希进行比较,如果它们匹配,则密码是正确的
陷阱在于验证器和包是通过对不同缓冲区的独立调用进行加密的。无论随后包发生什么情况,正确派生的密钥都会正确解密验证器。因此,如果你的密钥派生是正确的,但你的包转换是错误的,Excel 会通过验证器确认密码,然后在外壳主体上失败。症状表现为“密码正确,文件损坏”,这使调查指向密码路径(而这恰恰是唯一从未损坏的部分)。同样的隔离也支配着遗留的 RC4 情况:首先检查验证器哈希,而漂移出同步的主体仍然让该检查保持完好
陷阱一:AES 要用 ECB,不要用 CBC
[MS-OFFCRYPTO] §2.3.4.15 指定标准加密使用电子密码本(ECB)模式的 AES 加密包。填充包的每个 16 字节块都使用相同的密钥独立加密。块之间没有链接,也没有初始化向量。按照现代标准,这是一个不寻常的选择(通常会避免使用 ECB),但互操作性不是一个重新揣摩规范的地方。Excel 将包解密为 ECB,因此生成器必须将其加密为 ECB,否则两者将无法达成一致
诱人的错误做法是用全零初始化向量的 CBC 模式 AES 去加密包。值得说清它为什么「差一点就对」,而「差一点」恰恰是最糟的落点。CBC 里第一个明文块在加密前先与 IV 异或。IV 全零时这次异或等于什么都没做,所以零 IV 的 CBC 的第一个块与 ECB 的输出完全相同。从第二个块起,CBC 把前一个密文块喂给下一个块,于是第一个块之后每一块都和 ECB 分道扬镳
把这个结论套到结构上。包布局把 8 字节小端长度前缀放在最开头,所以 Excel 最早检查的部分就在第一两个块里。第一块恰好吻合意味着最早的校验通过,而其后每一块都解密成噪声。点破模式之后,解法就不玄了:每个 16 字节块用 ECB 加密,不要链接。引擎里 XlsEncryptStdPackage 以 16 字节为步长遍历填充后的缓冲区,对每块调用 AESEncryptECB128Block——这正是验证器块已经在用的同一个原语。源码在循环处留了一条注释,把规则写得明明白白:零 IV 的 CBC 只有第一块与 ECB 一致,包的其余部分会解密成垃圾,Excel 会拒收
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.Open('report.xlsx');
// SaveAsEncrypted 序列化工作簿,然后运行
// ECMA-376 标准加密管线:对包执行 AES-128 ECB,
// 依据 [MS-OFFCRYPTO] 2.3.4.15。成功时返回 1。
if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
raise Exception.Create('Encryption failed');
finally
Book.Free;
end;
end;
陷阱二:RC4 换密钥的节奏走岔了
[MS-OFFCRYPTO] §2.3.6 指定密码在每个 1024 字节的块边界处重新生成密钥。流被分为 1024 字节的块,为块编号 0、1、2 等导出新的 RC4 密钥,并且在每个块内,密钥流在字节之间连续消耗。两个不变量必须同时保持:在每个边界重新生成密钥,以及在一个块内无间隔地消耗密钥流。RC4 是流密码,因此其密钥流是单个有序序列;你获取的第 n 个字节由你之前获取了多少字节决定。解密是对相同序列的相同异或,这意味着生成器和消费者必须在完全相同的位置获取完全相同的字节
这就是全部的难点。流密码没有重新同步。只要浪费一个字节的 keystream,之后的每个字节都在和错误的 keystream 字节异或,而且错误永不自愈:它一路级联到块尾,一旦运行位置错了,就一直错到后面的每个块。一点簿记上的疏忽恰恰会造成这种局面。设想一个从哨兵值 -1 起步的块计数器,和一个假定计数器已对齐当前块的跳过例程。从这个哨兵值出发,它会换一次密钥并整整烧掉一个本不该消费的 1024 字节 keystream 块,还顺手把剩余计数推成负数。从那一刻起,解密端整整错开了一个块。而在这一切之前检查的验证器照样通过,于是密码看起来是对的,每个数据单元格却解出一堆乱码
在 HotXLS 里,这段逻辑在 TXLSDecrypterRC4 中。Skip 和 Decrypt 共享同一个循环:只有运行位置跨入新块时才换密钥,块下标等于位置除以 REKEY_BLOCK_SIZE(1024),然后只消费到当前块的余量为止。MakeKey 拿到的永远是块下标,绝不是过期下标或哨兵下标,位置按实际处理的字节数精确前进,Skip 和 Decrypt 因此与生产方保持相位对齐。教训落在最小的单位上:在流密码里,浪费一个字节不是小误差,而是下游一切的全灭
var
Book: TXLSWorkbook;
begin
Book := TXLSWorkbook.Create;
try
// 用 RC4 或 RC4 CryptoAPI 保护的旧版 .xls,在 Open 内部解密:
// 传入密码,流解密器会让换密钥节奏
// 与生产方保持对齐
Book.Open('legacy.xls', 'S3cret!');
// 在这里读取单元格
finally
Book.Free;
end;
end;
与冻结规范的互操作性就是精确到字节的匹配
两个陷阱归结为同一条底层原则,值得单独说出来,因为它会改变你权衡设计取舍的方式。当你输出的消费方是一个你改不动的固定外部程序时,密码模式和换密钥节奏就不是可以随手优化或简化的实现细节,它们是线上契约的一部分。Excel 会用 ECB 解密、会在 1024 字节边界换密钥,这些选择你喜不喜欢它都照做,你唯一的任务,是产出在这套精确流程下能解回原文的字节。更现代的模式、看似无害的 IV、起步位置随心所欲的计数器——任何一条,只要偏离读取方的预期,立刻就是缺陷。对冻结规范的互操作没有近似解:要么字节精确,要么就是坏的
这也是为什么验证器本身是一个很差的烟雾测试。它告诉你密钥派生是起作用的,这是必要的但远远不够。仅打开加密文件并确认密码通过的测试会在主体不可读的情况下报告成功。真正的测试是解密包并与原始输入进行比较,或者通过加密和解密往返处理工作簿并读回单元格。验证器证明了密码;只有主体证明了加密
支持的读取和写入受保护工作簿的方式
公开接口很小。要写一个带密码保护的现代工作簿,填充或打开一个 TXLSXWorkbook,然后带上文件名和密码调用 SaveAsEncrypted;它会序列化工作簿并跑完上面描述的标准加密流水线,成功返回 1。SaveAsEncryptedAgile 接受相同的参数,写出 Excel 2010 及以后版本默认使用的 Agile 方案,SHA-512 密钥派生加 AES-256 包加密。读取方面,先用 CanReadEncrypted 判断文件是不是 Compound File 容器,再分支:OpenEncrypted 能解密标准和 Agile 两种包,对普通文件回退到 Open,密码错误或容器用了别的方案时抛出 EXlsxEncryptionNotImplemented。旧版 .xls 文件同样是 Compound File,走 TXLSWorkbook.Open 并传入密码即可。上面讲的模式处理和换密钥循环都垫在这些调用底下;你只管给密码和文件名,引擎替你对齐规范
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.Open('quarterly.xlsx');
Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
// 在消费方重新打开
Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
finally
Book.Free;
end;
end;
受保护输出的形状、EncryptionInfo 流、验证器块以及包布局已在我们对受 AES 保护的 XLSX 输出的指南中介绍。对于工作表级锁定的独立问题,以及保护如何与页面设置和打印交互,请参阅关于保护、页面设置和打印的文章。两者都基于此处描述的加密路径构建,该路径作为 Delphi 和 C++Builder 的 HotXLS Delphi spreadsheet component 的一部分提供,同时还包括本博客其他地方介绍的读取、写入和渲染 API