你编写了一个工作簿,用密码对其进行加密,然后将文件交给同事,同事在 Excel 中打开它。Excel 询问密码。同事输入密码,Excel 接受了它。到目前为止,加密看起来是正确的。然后 Excel 弹出一个对话框,提示文件已损坏且无法打开,或者打开后显示一张写满无意义单元格的工作表。密码是正确的,但文件仍然损坏。这是 Office 加密中最令人困惑的失败模式,因为告诉你密码正确的部分和保存你数据的部分是由两个不同的操作保护的,保证其中一个正确并不能保证另一个。
这里描述的两个错误都具有完全相同的形式。在每种情况下,验证器都通过了,但主体没有通过,这会让你去寻找实际上并不存在的密码或密钥派生错误。真正的故障在下游,即包字节如何被转换。这两个故障是独立的(一个在 AES 路径上,一个在 RC4 路径上),但它们面临着相同的诊断问题,因此值得看看为什么半正确的结果是最难解读的。
为什么通过的密码无法证明关于主体的任何事情
现代加密的 XLSX 使用的格式是 ECMA-376 标准加密(Standard Encryption),它并排存储了两个加密的内容。一个是 EncryptionVerifier:一个保存随机值和该值哈希的小块,使用从密码派生的密钥进行加密。另一个是 EncryptedPackage:工作簿整个 zip 容器,使用相同的密钥加密。验证器的存在是为了让读取器在把精力花费在数兆字节的主体上之前确认密码。解密验证器,哈希随机值,与存储的哈希进行比较,如果它们匹配,则密码是正确的。
陷阱在于验证器和包是通过对不同缓冲区的独立调用进行加密的。无论随后包发生什么情况,正确派生的密钥都会正确解密验证器。因此,如果你的密钥派生是正确的,但你的包转换是错误的,Excel 会通过验证器确认密码,然后在外壳主体上失败。症状表现为“密码正确,文件损坏”,这使调查指向密码路径(而这恰恰是唯一从未损坏的部分)。同样的隔离也支配着遗留的 RC4 情况:首先检查验证器哈希,而漂移出同步的主体仍然让该检查保持完好。
错误一:使用 ECB 而非 CBC 模式的 AES
[MS-OFFCRYPTO] §2.3.4.15 指定标准加密使用电子密码本(ECB)模式的 AES 加密包。填充包的每个 16 字节块都使用相同的密钥独立加密。块之间没有链接,也没有初始化向量。按照现代标准,这是一个不寻常的选择(通常会避免使用 ECB),但互操作性不是一个重新揣摩规范的地方。Excel 将包解密为 ECB,因此生成器必须将其加密为 ECB,否则两者将无法达成一致。
错误在于,包是使用带有全零初始化向量的 CBC 模式 AES 加密的。这就是为什么它几乎可行,而“几乎可行”往往是最糟糕的境地。在 CBC 中,第一个明文块在加密前与 IV 进行异或(XOR)。当 IV 全为零时,该异或不改变任何内容,因此带有零 IV 的 CBC 的第一个块产生的密文与 ECB 完全相同。从第二个块开始,CBC 将前一个密文块输入到下一个中,因此第一个块之后的每个块都与 ECB 发生偏离。
现在将其叠加到结构上。包布局在最开始放置了一个 8 字节的小端长度前缀,因此 Excel 最早检查的文件部分位于前一两个块中。碰巧匹配的第一个块意味着最早的验证通过,而随后的每个块都解密为噪声。一旦指明模式,修复方法就很简单了:用 ECB 加密每个 16 字节块并停止链接。在引擎中,XlsEncryptStdPackage 以 16 字节为步长遍历填充的缓冲区,并在每个块上调用 AESEncryptECB128Block,这是已经用于验证器块的相同原语。源码在循环处有一条注释,清楚地说明了规则:带有零 IV 的 CBC 仅对第一个块匹配 ECB,因此包的其余部分将解密为垃圾,Excel 将拒绝它。
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
Book.Open('report.xlsx');
// SaveAsEncrypted serializes the workbook, then runs the
// ECMA-376 Standard Encryption pipeline: AES-128 ECB over the
// package per [MS-OFFCRYPTO] 2.3.4.15. Returns 1 on success.
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 个字节由你之前获取了多少字节决定。解密是对相同序列的相同异或,这意味着生成器和消费者必须在完全相同的位置获取完全相同的字节。
这就是全部的困难所在。流密码没有重新同步。如果你浪费了一个字节的密钥流,它之后的每个字节都会与错误的密钥流字节进行异或,并且该错误永远不会自我纠正;它会级联到块的末尾,一旦运行位置错误,就会波及之后的每个块。这里的错误正是这样做的。块计数器从负 1 的哨兵值开始,跳转例程假定计数器已经与当前块匹配。从该哨兵开始,它重新生成密钥并运行了一个本不应该消耗的完整 1024 字节的密钥流块,并且在此过程中它使剩余计数变为负数。从那时起,解密器偏离了整整一个块的相位。在此之前检查的验证器仍然通过,因此密码看起来是正确的,而每个数据单元格出来的都是垃圾。
纠正后的逻辑存在于 TXLSDecrypterRC4 中。Skip 和 Decrypt 共享一个循环:仅当运行位置跨入新块时才重新生成密钥(其中块索引是位置除以 REKEY_BLOCK_SIZE (1024)),然后消耗当前块的剩余部分,引导其不再多消耗。使用块索引调用 MakeKey,绝不使用陈旧或哨兵索引,并且位置按处理的精确字节数推进,以便 Skip 和 Decrypt 与生成器保持相位对齐。教训存在于最小的单位中:流密码中单个浪费的字节不是小错误,而是下游一切内容的彻底丧失。
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
// CanReadEncrypted checks the Compound File (OLE2) signature so
// you can branch before attempting a normal Open. OpenEncrypted
// routes plain files to Open and handles the encrypted container.
if Book.CanReadEncrypted('legacy.xls') then
Book.OpenEncrypted('legacy.xls', 'S3cret!')
else
Book.Open('legacy.xls');
// read cells here
finally
Book.Free;
end;
end;
与冻结规范的互操作性就是精确到字节的匹配
这两个错误都归结为相同的根本原则,这一点值得单独说明,因为它改变了你衡量设计选择的方式。当你的输出消费者是一个你无法更改的固定外部程序时,密码模式和重新生成密钥的节奏就不是你可以优化或简化的实现细节。它们是线路契约的一部分。无论你是否喜欢这些选择,Excel 都会使用 ECB 进行解密,并在 1024 字节边界上重新生成密钥,你的唯一工作就是生成在此精确过程下解密为原始字节的字节。一个更现代的模式、一个看似无害的 IV、一个从感觉自然的地方开始的计数器;其中任何一个在它偏离读取器预期的瞬间都是缺陷。针对冻结规范的互操作不是近似的。它是按字节精确的,否则就是损坏的。
这也是为什么验证器本身是一个很差的烟雾测试。它告诉你密钥派生是起作用的,这是必要的但远远不够。仅打开加密文件并确认密码通过的测试会在主体不可读的情况下报告成功。真正的测试是解密包并与原始输入进行比较,或者通过加密和解密往返处理工作簿并读回单元格。验证器证明了密码;只有主体证明了加密。
支持的读取和写入受保护工作簿的方式
公共表面很小。要写入受密码保护的现代工作簿,填充或打开 TXLSXWorkbook,并使用文件名和密码调用 SaveAsEncrypted;它会序列化工作簿并运行第一个修复所纠正的标准加密管道,成功时返回 1。要读取,调用 CanReadEncrypted 以测试文件是否为加密的复合文件容器,然后进行分支:OpenEncrypted 处理加密路径并为普通文件回退到 Open,且可以直接使用带密码的 Open。上述模式处理和重新生成密钥循环位于这些调用之下;你提供密码和文件名,引擎代表你匹配规范。
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
Book.Open('quarterly.xlsx');
Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
// Reopen on the consumer side
Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
finally
Book.Free;
end;
end;
受保护输出的形状、EncryptionInfo 流、验证器块以及包布局已在我们对受 AES 保护的 XLSX 输出的指南中介绍。对于工作表级锁定的独立问题,以及保护如何与页面设置和打印交互,请参阅关于保护、页面设置和打印的文章。两者都基于此处描述的加密路径构建,该路径作为 Delphi 和 C++Builder 的 HotXLS spreadsheet component 的一部分提供,同时还包括本博客其他地方介绍的读取、写入和渲染 API。