技术文章

在 Delphi 中使用 HotXLS 读取 Agile 加密的 Excel 文件

HotXLS 通过单次调用即可读取 Agile 加密的 Excel 文件(Excel 2010 及所有后续版本默认应用的密码保护):TXLSXWorkbook.OpenEncrypted。该组件解析 XML 加密描述符,通过 SHA-512 自旋计数哈希链从密码中派生密钥,针对加密的校验器验证密码,然后解密 4096 字节 AES-CBC 段中的数据包。此过程不涉及 Excel 安装、COM 或外部加密 DLL

本文专门介绍 Agile 加密的读取端。另外两个相关问题有各自的文章:与旧 BIFF .xls 文件中的传统 RC4 和 XOR 方案进行互操作在 ECB 和 RC4 互操作文章中进行了介绍,而使用 ECMA-376 标准加密生成受密码保护的工作簿则在 AES 保护的 XLSX 输出文章中进行了介绍。在本文的场景中,文件已经存在,是其他人对其进行了加密,您的工作是打开它

迫使必须解决这一问题的场景对任何运行文档管道的人来说都很熟悉。服务器端导入服务接受工作簿上传;机器上没有 Excel,将来也不会有;一天早上,客户上传了一个完全普通的 .xlsx 文件,但 ZIP 读取器拒绝接受它,因为它根本不是 ZIP 文件。客户用密码保存了它。从那一刻起,您的加载器要么理解 [MS-OFFCRYPTO],要么只能将文件退回给从他们自己的角度来看并没有做出任何异常举动的用户

Excel 文件中的 Agile 加密是什么?

Agile 加密是 [MS-OFFCRYPTO] §2.3.4.10 至 §2.3.4.15 中定义的密码保护方案,是 Excel 2010 及更高版本在保存带有密码的工作簿时所写入的内容。加密后的文件不再是 ZIP 数据包,而是一个 OLE 复合文档二进制(CFB)容器,包含两个流:描述加密是如何执行的 EncryptionInfo,以及作为不透明 blob 被加密的真实 .xlsx ZIP 的 EncryptedPackage。CFB 签名(D0 CF 11 E0 A1 B1 1A E1)与传统 BIFF .xls 文件携带的魔数相同,这就是为什么不能仅凭扩展名对重命名或加密的文件进行分类的原因

将 Agile 与其前身区分开来的是 EncryptionInfo 是自描述的。在一个 8 字节的版本前缀(主版本和次版本均为 4)之后,该流是一个 UTF-8 XML 描述符。keyData 元素声明了密码算法(AES)、链接模式(ChainingModeCBC)、哈希(SHA512)、以位为单位的密钥长度、块大小以及 Base64 盐值。一个密码 keyEncryptor 元素携带其自身的盐值、spinCount 和三个 Base64 有效负载:encryptedVerifierHashInputencryptedVerifierHashValueencryptedKeyValue。Excel 写入的 AES-256 自旋计数为 100,000,但描述符允许声明 AES-128 或 AES-192,且 HotXLS 遵循 keyBits 所声明的值,而不是盲目假定为 256

明文、标准和 Agile 工作簿的单一入口点

TXLSXWorkbook.OpenEncrypted 处理调用者可能遇到的所有三种状态:普通 ZIP、标准加密 and 和 Agile 加密,因此上传处理器在加载文件之前无需对其进行分类。该方法首先嗅探文件:如果没有 CFB 签名,它将转到普通的 Open 路径且直接忽略密码。如果文件是 CFB 容器,它首先尝试 ECMA-376 标准加密,当 EncryptionInfo 的版本签名是 Agile 的 4.4 时,分发到 Agile 管道。成功时返回值为 1,与 Open 的约定相同

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    // 同样适用于普通 .xlsx、标准加密和
    // Agile 加密的文件
    if Wb.OpenEncrypted('upload.xlsx', 'customer-password') = 1 then
      Writeln(VarToWideStr(Wb.Sheets[1].Cells[1, 1].Value));
  finally
    Wb.Free;
  end;
end;

未加密输入的备用方案比看起来更重要。始终调用 OpenEncrypted 的批处理导入器在调用端不需要分支:从未受保护的文件像以前一样加载,而送达时已加密的文件被就地解密,然后作为内存流送入普通的 ZIP 加载器。需要测试的代码路径只有一条,而不是三条

密码是如何变为 AES 密钥的?

Agile 加密绝不直接使用密码。HotXLS 首先计算迭代哈希:初始摘要是密码盐值与密码的 UTF-16LE 字节拼接之后的 SHA-512 哈希值,然后该摘要被重新哈希 spinCount 次,每轮将 32 位小端迭代计数器前置于前一次摘要之前。在 Excel 默认自旋计数为 100,000 的情况下,每次密码尝试意味着十万次连续的 SHA-512 调用,这正是关键所在。自旋计数是暴力破解的节流阀:它只让合法调用者花费一次几毫秒的成本,但会让字典攻击者的每一次猜测都花费同样多的几毫秒时间

var
  buf: TBytes;
  i: Integer;
begin
  Result := XlsSHA512(Concat(Salt, Utf16LEBytes(Password)));
  SetLength(buf, 4 + 64);
  for i := 0 to SpinCount - 1 do
  begin
    PutLE32(buf, 0, i);            // 迭代计数器,小端
    Move(Result[0], buf[4], 64);   // 前一次的摘要
    Result := XlsSHA512(buf);
  end;
end;

经过自旋计算的哈希仍然不是密钥。通过使用追加了固定的 8 字节块密钥对其再次哈希,可从中派生出三个不同的密钥,每个常量对应一个用途:用于解密校验器输入的 FE A7 D2 76 3B 4B 9E 79,用于校验器哈希的 D7 AA 0F 6D 30 61 34 4E,以及用于解开实际数据包密钥的 14 6E 0B E7 AB AC D0 D6。每个 SHA-512 结果都被截断为声明的密钥长度,并且在哈希短于密钥的理论情况下,根据 [MS-OFFCRYPTO] 用 0x36 字节填充。当密码盐值延伸至块大小以用作 CBC 初始化向量(IV)时,也适用相同的 0x36 填充规则

密码验证与 saltSize 截断陷阱

HotXLS 在触及数据包之前使用描述符中的验证器对验证密码。它使用第一个派生密钥解密 encryptedVerifierHashInput,使用 SHA-512 对结果进行哈希,使用第二个派生密钥解密 encryptedVerifierHashValue,并逐字节比较这两个摘要。不匹配意味着密码错误,并作为独立的结果进行报告,而不是表现为乱码工作簿,更关键的是它意味着决不使用错误的密钥去解密数据包主体,因此不会发生由于密码错误而产生看起来合理的损坏数据的情况

这里有一个容易弄错的规范细节。[MS-OFFCRYPTO] §2.3.4.13 将验证器定义为 saltSize 字节的随机数据,其中 saltSize 是密钥加密器盐值的长度,而不是密码块大小。由于 AES-CBC 密文是块对齐的,解密后的校验器输入返回时会填充为 16 字节的倍数,且在哈希之前必须将其截断为 saltSize。Excel 写入的 saltSize 始终等于 blockSize(两者均为 16),因此跳过截断的实现能够通过针对真实 Excel 输出的每一项测试,却会在处理来自选择不同盐长度的生成器的第一个文件时失败。HotXLS 会截断为盐长度,因为这正是规范所说的,而这两个值在实践中一致仅仅是巧合,而不是契约

EncryptedPackage 是如何被解密的?

EncryptedPackage 流以一个 8 字节小端明文大小开始,随后是 4096 字节段中的密文,HotXLS 逐段对其进行解密,每个段使用全新的 IV。数据包密钥本身并不是由密码派生的:它是写入器加密到 encryptedKeyValue 中的随机中间密钥,HotXLS 用第三个派生密钥解开它,并截断为由 keyData 声明的密钥长度。每个段的 IV 是对 keyData 盐值与 32 位小端段索引拼接后的 SHA-512 哈希,并截断为块大小。这种构建方式意味着任何 4096 字节段都可以独立解密,这在原理上使该格式便于随机访问,尽管 HotXLS 会将整个数据包解密到内存中并将生成的 ZIP 字节交给其正常的 XLSX 加载器

声明的明文大小完成了最后一步工作。AES-CBC 输出是块对齐的,因此最后一个段携带最多 15 字节的不属于文档的填充字节;解密后的缓冲区被截断为尺寸前缀,其结果正是 Excel 加密的 .xlsx ZIP 文件。HotXLS 在解密前根据实际的流长度验证前缀,因此截断的上传或篡改的大小字段会干净利落地失败,而不会发生越界

错误报告与真实的限制

故障模式被刻意分隔开来。由于校验器不匹配,错误的密码会抛出一个带有明确密码错误消息的异常,以便 UI 可以提示用户重试。对于其描述符声明了支持集之外的算法的 CFB 容器(在 Agile 描述符中除带有 CBC 链接和 SHA-512 哈希的 AES 之外的任何算法),或者既不是 Standard 也不是 Agile 的容器,会抛出另一个异常以标明该方案不受支持。这两者绝不能混淆:对不支持的方案重试密码会浪费用户的时间,而将密码错误报告为格式错误会将您的支持团队引向歧途

function LoadUploadedWorkbook(const FileName: WideString;
  const Password: WideString; Wb: TXLSXWorkbook): Boolean;
begin
  Result := False;
  try
    Result := Wb.OpenEncrypted(FileName, Password) = 1;
  except
    on E: EXlsxEncryptionNotImplemented do
      // 针对密码错误和不支持的方案都会抛出;
      // E.Message 说明了具体是哪种情况,因此请原样记录,
      // 且仅在密码错误的情况下提供密码重试
      RejectUpload(FileName, E.Message);
  end;
end;

这些限制值得明确说明。HotXLS 读取声明在 CBC 模式下带有 SHA-512 的 AES 的 Agile 描述符,这覆盖了 Excel 2010 到 Excel 365 实际写入的所有三种密钥大小的内容。声明其他密码算法或哈希算法的描述符会被拒绝而不是瞎猜,并且不咨询基于证书的密钥加密器,而仅使用密码密钥加密器。在写入端,HotXLS 目前生成 Standard Encryption 开发,而不是 Agile,如果下游工具检查加密方案,这一区别就很重要;细节请参阅关于编写 AES 保护的 XLSX 输出的文章

一旦加载器将加密视为文件格式的一部分,而不是其例外,受密码保护的上传便不再是一个特例。此处介绍的 OpenEncrypted 入口点、SHA-512 自旋计数派生以及分段的 AES-CBC 管道,均作为 HotXLS Delphi Excel Component 的一部分发售,同时也包含面向 Delphi 和 C++Builder 的其余原生 XLS 和 XLSX 读写引擎