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 Standard Encryption 产出受密码保护的工作簿,见 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 描述加密是怎么做的,EncryptedPackage 则是被当作不透明二进制块加密的真正 .xlsx ZIP。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 载荷:encryptedVerifierHashInput、encryptedVerifierHashValue 和 encryptedKeyValue。Excel 写出的是自旋计数为 100000 的 AES-256,但描述符也允许声明 AES-128 或 AES-192,因此 HotXLS 遵从 keyBits 所说的值,而不是一口咬定 256
明文、Standard 与 Agile 工作簿共用一个入口
TXLSXWorkbook.OpenEncrypted 处理调用方可能遇到的全部三种状态:普通 ZIP、Standard 加密和 Agile 加密,因此上传处理器不必在加载前先给文件分类。该方法先嗅探文件:若没有 CFB 签名,就转交给正常的 Open 路径,密码被直接忽略。若文件是 CFB 容器,它先尝试 ECMA-376 Standard Encryption,而当 EncryptionInfo 的版本签名是 Agile 的 4.4 时,就分派到 Agile 流水线。成功时返回值是 1,与 Open 的约定一致
var
Wb: TXLSXWorkbook;
begin
Wb := TXLSXWorkbook.Create;
try
// 对普通 .xlsx、Standard 加密和
// 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 默认的自旋计数 100000,这意味着每尝试一次密码就要串行调用十万次 SHA-512,而这正是全部用意所在。自旋计数是一道暴力破解的节流阀:它让合法调用方一次性付出几毫秒,却让字典攻击者的每一次猜测都要付出同样的几毫秒
// [MS-OFFCRYPTO] 迭代密码哈希:
// H(0) = SHA-512(salt + UTF-16LE(password))
// H(n) = SHA-512(LE32(n - 1) + H(n - 1)),重复 spinCount 次
function AgilePasswordHash(const Password: WideString;
const Salt: TBytes; SpinCount: Integer): TBytes;
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 初始化向量时,同样适用这条 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 在解密前会拿这个前缀与实际流长度核对,因此被截断的上传或被篡改的长度字段会干净地失败,而不是越界读写
错误报告与诚实的边界
各种失败模式被刻意分开。密码错误会抛出一个带明确“密码错误”消息的异常,由校验值不匹配触发,因此界面可以提示用户重试。若某个 CFB 容器的描述符声明了受支持集合之外的算法 —— 在 Agile 描述符里凡不是 AES 加 CBC 链接加 SHA-512 哈希的组合 —— 或者容器既不是 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 读取声明为 AES、CBC 模式、SHA-512 的 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 读写引擎的其余部分并肩