HotXLS 可透過單一呼叫:TXLSXWorkbook.OpenEncrypted,來讀取 Agile 加密的 Excel 檔案(這是 Excel 2010 及其後所有版本預設套用的密碼保護機制)。此元件會解析 XML 加密描述元 (descriptor),透過 SHA-512 spin-count 雜湊鏈從密碼衍生出金鑰,根據加密的驗證器來驗證密碼,接著以 4096 位元組的 AES-CBC 區段解密套件。整個過程不需要安裝 Excel,不涉及 COM,也不需要外部的密碼學 DLL
本文專門探討 Agile 加密在讀取方面的細節。另外兩個相關的問題則有各自的文章:關於如何與舊版 BIFF .xls 檔案內傳統的 RC4 與 XOR 機制互通,涵蓋於這篇關於 ECB 與 RC4 互通性的文章中;而關於如何產出受 ECMA-376 標準加密保護的活頁簿,則涵蓋於這篇關於 AES 保護之 XLSX 輸出的文章中。在這裡,檔案已經存在,是其他人對其進行了加密,而您的工作就是打開它
任何負責營運文件處理管線 (document pipeline) 的人,對這種迫在眉睫的情境都很熟悉。伺服器端的匯入服務接收活頁簿上傳;該機器上沒有 Excel,未來也不會有;然後某個早晨,一位客戶上傳了一個看似非常普通的 .xlsx 檔案,但 ZIP 讀取器卻拒絕了它,因為它根本不是一個 ZIP 檔案。客戶在存檔時加上了密碼。從那一刻起,您的載入器要嘛理解 [MS-OFFCRYPTO] 規範,要嘛只能把檔案退回給客戶,而在客戶的認知裡,他們並沒有做任何不尋常的事
Excel 檔案中的 Agile 加密是什麼?
Agile 加密是定義在 [MS-OFFCRYPTO] 規範 §2.3.4.10 至 §2.3.4.15 中的密碼保護機制,也是 Excel 2010 及其後版本在儲存帶有密碼的活頁簿時所寫入的格式。加密後的檔案不再是一個 ZIP 套件。它是一個 OLE 複合檔案二進位 (CFB, Compound File Binary) 容器,裡面包含了兩個串流 (stream):EncryptionInfo,描述加密是如何執行的;以及 EncryptedPackage,它是一個作為不透明二進位大型物件 (blob) 被加密的真實 .xlsx ZIP 檔案。其 CFB 簽章 (D0 CF 11 E0 A1 B1 1A E1) 與傳統 BIFF .xls 檔案所帶有的魔術數字相同,這也是為什麼被重新命名或加密的檔案無法單憑副檔名來分類的原因
Agile 與其前身不同的地方在於,EncryptionInfo 是自我描述的 (self-describing)。在一個 8 位元組的版本前綴(主版本和次版本皆為 4)之後,該串流是一個 UTF-8 XML 描述元。一個 keyData 元素宣告了加密演算法 (AES)、串接模式 (ChainingModeCBC)、雜湊演算法 (SHA512)、金鑰長度(以位元為單位)、區塊大小,以及一個 Base64 格式的鹽值 (salt)。一個代表密碼的 keyEncryptor 元素則帶有它自己的鹽值、spinCount(旋轉次數),以及三個 Base64 格式的負載:encryptedVerifierHashInput、encryptedVerifierHashValue 以及 encryptedKeyValue。Excel 寫入的是 AES-256,其 spin count 為 100,000,但描述元也可以宣告 AES-128 或 AES-192,而 HotXLS 會遵循 keyBits 的指示,而不是預設為 256
適用於未加密、標準加密與 Agile 活頁簿的單一進入點
TXLSXWorkbook.OpenEncrypted 會處理呼叫端可能遇到的所有三種狀態:一般的 ZIP 檔案、標準加密 (Standard-encrypted) 以及 Agile 加密的檔案,因此上傳處理常式在載入檔案之前,不需要先對它們進行分類。這個方法首先會嗅探 (sniff) 檔案:如果沒有 CFB 簽章,它會切換到一般的 Open 路徑,並且直接忽略密碼。如果檔案是 CFB 容器,它會先嘗試 ECMA-376 標準加密,當 EncryptionInfo 的版本簽章是 Agile 的 4.4 時,則派送 (dispatch) 給 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;
針對未加密輸入資料的後備方案 (fallback) 比看起來更重要。一個總是呼叫 OpenEncrypted 的批次匯入程式,在呼叫點 (call site) 不需要任何分支:從未受保護的檔案會像以前一樣精確地載入,而加密的檔案會在原地 (in place) 被解密,然後作為記憶體中的串流被送進一般的 ZIP 載入器。您只需要測試一條程式碼路徑,而不是三條
密碼是如何變成 AES 金鑰的?
Agile 加密從不直接使用密碼。HotXLS 首先會計算一個反覆運算的雜湊值 (iterated hash):初始摘要是對密碼鹽值串接密碼的 UTF-16LE 位元組進行 SHA-512 運算,然後該摘要會被重新雜湊 spinCount 次,每一回合都會將 32 位元的小端序 (little-endian) 迭代計數器加在前一個摘要的前面。在 Excel 預設的 100,000 次 spin count 下,這代表每一次密碼嘗試都需要連續呼叫十萬次 SHA-512,而這正是此設計的重點所在。spin count 是一個暴力破解的節流閥 (throttle):它會讓合法的呼叫者耗費幾毫秒的時間,但同時也會讓字典攻擊者在每一次的猜測中都耗費同樣的幾毫秒
// [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;
這個經過旋轉 (spun) 的雜湊值依然不是金鑰。為了從中衍生出三個不同的金鑰,會將它附加一個固定的 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 則用來解開 (unwrap) 實際的套件金鑰。每個 SHA-512 結果都會被截斷為宣告的金鑰長度,並且根據 [MS-OFFCRYPTO],在雜湊值比金鑰短的理論情況下,會以 0x36 位元組進行填補。當密碼鹽值被擴展至區塊大小,以作為 CBC 初始化向量 (IV) 使用時,同樣適用於這條 0x36 填補規則
密碼驗證與 saltSize 截斷陷阱
HotXLS 在觸碰套件之前,會先使用描述元中的驗證器對 (verifier pair) 來驗證密碼。它會用第一個衍生出的金鑰解密 encryptedVerifierHashInput,將結果用 SHA-512 進行雜湊,然後用第二個衍生金鑰解密 encryptedVerifierHashValue,並逐位元組比對這兩個摘要。比對不符代表密碼錯誤,這會被回報為一個獨特的結果,而不是回報一個亂碼的活頁簿;更關鍵的是,這表示套件主體永遠不會被錯誤的金鑰解密,因此不存在因為密碼錯誤而產生看似合理的損壞資料的情境
這裡有一個很容易弄錯的規範細節。[MS-OFFCRYPTO] §2.3.4.13 定義驗證器為長度為 saltSize 個位元組的隨機資料,其中 saltSize 是 keyEncryptor 鹽值的長度,而不是密碼的區塊大小。因為 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 串接模式的 AES 與 SHA-512 雜湊之外的任何東西),或者該容器既不是標準加密也不是 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 在所有三種金鑰長度下的實際寫入內容。宣告其他密碼或雜湊演算法的描述元會被拒絕,而不是去胡亂猜測,同時它也不會查閱基於憑證的 keyEncryptor,只會查詢基於密碼的 keyEncryptor。在寫入方面,HotXLS 目前產生的是標準加密而不是 Agile,如果下游工具會檢查該機制,這個差異就很重要了;詳細內容在這篇關於寫入 AES 保護的 XLSX 輸出的文章中
一旦載入器將加密視為檔案格式的一部分,而不是格式外的例外狀況,受密碼保護的上傳檔案就不再是一個特例。此處描述的 OpenEncrypted 進入點、SHA-512 spin-count 衍生以及分段 AES-CBC 管線,都是 HotXLS Delphi Excel Component 的一部分,與其適用於 Delphi 與 C++Builder 的原生 XLS 和 XLSX 讀寫引擎一同出貨