技術文章

Delphi 用 HotXLS 以 AES 加密 XLSX 檔案

Excel 把兩個東西都叫做「密碼」,但其中只有一個是加密。開啟密碼驅動一把真正的加密器:沒有它,檔案根本讀不出來。工作表與活頁簿的保護密碼完全不是這回事。它們設下一個由配合的編輯器同意遵守的旗標,而一份只帶著這個旗標的活頁簿,是一個再普通不過、可讀取的 zip,資料就明晃晃地擺在裡面。挑錯那一個,你就會出貨一份在 Excel 裡看起來鎖住、卻能在任何文字編輯器裡讀取的薪資檔

證明只要十秒鐘。把一個受保護的 .xlsx 改名成 .zip,用任何封存工具打開它,看看 xl/worksheets/sheet1.xml。如果儲存格數值就以明文 UTF-8 擺在那裡,那這個檔案就沒有加密,無論 Excel 在有人試圖編輯儲存格時跳出多少次密碼提示。這個落差能在那些把工作表保護當成機密性的團隊裡存活好幾年,而它通常在某一場安全審查跑了完全一樣的這個改名動作的那一天才浮上檯面

HotXLS 是一個原生 Delphi 與 C++Builder 試算表函式庫,而它把這兩個功能放在那條線相反的兩側。工作表與活頁簿保護是編輯限制,背後是一個刻意做弱的舊式雜湊。SaveAsEncrypted 產生一個 AES 加密的封包,除非有密碼,否則什麼也打不開它。以下各節涵蓋了那個呼叫寫了什麼、你必須為之設計的非對稱性(HotXLS 能寫入加密檔案卻無法把它們讀回來),以及較舊的 XLS 路徑有何不同

圖解對比:Delphi XLSX 工作表保護存弱雜湊、儲存格資料在普通 zip 中可讀,與 HotXLS SaveAsEncrypted 衍生 AES-128 金鑰並寫出 OLE 加密容器
工作表保護只存一個弱雜湊,套件仍是讀得開的 zip;SaveAsEncrypted 衍生 AES-128 金鑰,寫出任何壓縮工具都列不動的 OLE 容器

為什麼工作表保護不是加密

工作表上的 Protect 方法與活頁簿上的 ProtectWorkbook 儲存的是密碼的一個 4 位十六進位雜湊。那是 OOXML 與 BIFF 雙雙從 1990 年代 Excel 繼承而來的舊式演算法,而格式文件從不曾宣稱它能做的比擋下意外編輯更多。這個封包一直是一個普通可讀的 zip:儲存格資料、公式與共用字串全都是明文 XML。預設值讓事情更糟,而非更好。每個儲存格一開始都是 Locked=True,所以在沒有先解開一個輸入範圍的情況下呼叫 Protect,會把整張工作表凍結而無法編輯,同時卻把每個數值都留在光天化日之下

這一切都不代表保護沒有用。把使用者引導到可編輯的範圍,以及穩住一個供列印用的版面,都是真正的工作,這在我們的工作表保護與頁面設定文章裡有涵蓋。但那些是可用性工作。在需求變成機密性的那一刻,唯一能回應它的 API 是 SaveAsEncrypted

SaveAsEncrypted 實際上寫了什麼

這套實作遵循 ECMA-376 Standard Encryption,規範於 [MS-OFFCRYPTO] 第 2.3.4 節。密碼跑過 50,000 次 SHA-1 反覆運算來推導出一把 AES-128 金鑰。一個以 AES-128 的 ECB 模式加密的驗證器區塊,讓消費者在解密任何東西之前就能確認密碼,然後整個活頁簿封包再用 AES-128 的 CBC 模式加密。落在磁碟上的根本不是 zip。它是一個 OLE 複合檔案,持有 EncryptionInfoEncryptedPackage 與 DataSpaces 資料流,沒有 xl/ 目錄可供封存工具列出,這正是改名測試現在會一無所獲的原因。Excel 2007 與之後版本只用密碼就能開啟它,而目前的 LibreOffice 也讀得懂 Standard Encryption

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  rc: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Payroll');
    Sheet.Cells[1, 1].Value := 'Employee';
    Sheet.Cells[1, 2].Value := 'Net pay';
    Sheet.Cells[2, 1].Value := 'A. Garcia';
    Sheet.Cells[2, 2].Value := 4815.16;

    rc := Book.SaveAsEncrypted('payroll-2026-06.xlsx', PasswordFromVault);
    if rc <> 1 then
      raise Exception.CreateFmt('Encrypted save failed (rc=%d)', [rc]);
  finally
    Book.Free;
  end;
end;

對待密碼變數要像對待連線字串一樣小心。在最後一刻才從一個保險庫或一個產生祕密的服務取它,絕不記錄它,也絕不把它寫進活頁簿本身。回傳碼檢查不是可有可無的儀式。一次跑到一半失敗的加密儲存必須中止交付,因為呼叫端程式碼唯一能提供的後備,是一份未加密的副本,而那份副本正是這個功能存在來防止的那種事件

還有一個幾乎不花成本的機器可檢查驗收測試:對你剛寫出的檔案呼叫 CanReadEncrypted。它只有在輸出真的是一個加密容器時才回傳 true,所以在每次加密儲存之後斷言它,能在它發生的那一刻就逮到最重要的回歸——一條悄悄退回成普通 SaveAs 的程式碼路徑——而不是幾週後在客戶的收件匣裡。最終判決仍然屬於發行測試時,用真正密碼在 Excel 裡手動開啟一次

設計上只寫不讀:處理 EXlsxEncryptionNotImplemented

這就是那個該塑造你管線架構的非對稱性:HotXLS 在儲存時加密,卻不在開啟時解密。OpenEncrypted 在指向一個真正的加密封包時會引發 EXlsxEncryptionNotImplemented;在一份普通活頁簿上,它就單純落到一次正常的 Open。配套的探測 CanReadEncrypted 能以低成本偵測出 OLE 加密容器,於是進件程式碼能在不觸發例外的情況下路由這類檔案:

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.CanReadEncrypted(FileName) then
    begin
      // 加密容器:HotXLS 無法解密
      Writeln(FileName + ': needs manual decryption in Excel first');
      Exit;
    end;
    try
      Book.OpenEncrypted(FileName, '');   // 未加密檔案會落到 Open
      Writeln(FileName + ': opened, ' + IntToStr(Book.Sheets.Count) + ' sheet(s)');
    except
      on EXlsxEncryptionNotImplemented do
        Writeln(FileName + ': encrypted - routed to manual queue');
    end;
  finally
    Book.Free;
  end;
end;

這個非對稱性有一個清楚的架構解讀:在交付邊緣、最後一步才加密。把明文母版留在你的信任邊界之內,放在資料庫、文件存放區或一個存取受控的共用裡,並在檔案離開系統之前的最後一步產生加密副本。一條只封存加密輸出的管線,等於把自己鎖在自己的資料外面,因為同一個系統的任何後續階段都無法重新開啟這些檔案。當一個下游的 HotXLS 處理程序再次需要這份活頁簿時,交給它的是明文母版,絕不是交付產物

Delphi HotXLS SaveAsEncrypted 呼叫管線圖:保存庫密碼經 50,000 輪 SHA-1 衍生 AES-128 金鑰、ECB 驗證區塊與 CBC 套件加密,產出以 CanReadEncrypted 檢查的 OLE 複合檔案
一次 SaveAsEncrypted 呼叫把保全密碼變成 AES-128 金鑰與 OLE 複合檔;CanReadEncrypted 為儲存提供了機器可驗的驗收關卡

AES-128 Standard Encryption 與 AES-256 合規線

Office 檔案加密有兩個世代。Standard Encryption——也就是 HotXLS 寫出的那一個——使用 AES-128 搭配 SHA-1 金鑰推導。Agile Encryption 較晚出現,改用 AES-256 搭配 SHA-512,以及一個不同的、以 XML 描述的金鑰容器。兩者在 Excel 裡都能透明開啟,而 AES-128 對於保護一份傳送給客戶的檔案來說,在運算上依然牢靠

Delphi 服務架構圖:明文活頁簿母本留在信任邊界內,HotXLS SaveAsEncrypted 在交付邊緣作為最後一步,並警告不要只封存加密副本
在交付邊緣最後一步加密,明文母本留在信任邊界內;只封存加密副本,等於把後續每個階段都鎖在自己的資料門外

在某份安全問卷要求「檔案靜止時採 AES-256 加密」的那一天,這個差異就不再是理論上的了。Standard Encryption 達不到那條線,無論密碼多強,而 SaveAsEncrypted 也沒有任何參數能改變它發出的演算法。所以要在你的安全文件裡精確地陳述這個設定檔:AES-128、ECMA-376 Standard Encryption、50,000 次反覆運算的 SHA-1 金鑰推導。一個能在審查中存活下來的宣稱,比一個在稽核下崩潰的樂觀宣稱更有價值

舊式 XLS 路線:RC4 只出不進,RC4 與 XOR 進得來

BIFF 外觀有著相反的形狀。它的加密較舊也較弱,但來回是完整的:它寫得出什麼,就也讀得回什麼。在 SaveAs 之前設定 EncryptionPassword,會透過 BIFF 的 FilePass 機制產生一份 RC4 加密的 .xls,而帶密碼參數的 Open 則讀得懂全部三種舊式機制:RC4、RC4 CryptoAPI,以及古老的 XOR 混淆:

var
  Writer, Reader: IXLSWorkbook;   // interface refs: no manual Free
begin
  Writer := TXLSWorkbook.Create;
  Writer.Sheets.Add.Cells.Item[1, 1].Value := 'Confidential';
  Writer.EncryptionPassword := 'S3cret!';
  Writer.SaveAs('confidential.xls');

  Reader := TXLSWorkbook.Create;
  if Reader.Open('confidential.xls', 'S3cret!') > 0 then
    Writeln(Reader.Sheets[1].Cells.Item[1, 1].Value);  // Entries are 1-based
end;

RC4 是過時的密碼學,絕不該用來保護今天重要的資料;它僅存的價值是與那些仍在交換 .xls 的系統互通。不過讀取的那一側,在遷移工作裡掙得了它的存在。一份密碼保護的舊式檔案以 Open(FileName, Password) 開啟,橋接進 OOXML 模型,再透過 AES 路徑重新加密——這是一次單向升級,整個過程不需要 Excel 出現在迴圈裡任何地方。對於大量加密交付,我們的伺服器批次工作串流寫入文章裡的儲存端吞吐量注意事項,適用於發生在加密之前的內容建置階段

加密與保護不是對手

還有一點值得講清楚,因為它會在某個人把本頁頂端的警告讀成「保護一文不值」的那一刻冒出來。它不是。加密與保護回答的是不同的問題,而它們能乾淨地疊加。加密決定的是誰能開啟檔案;保護決定的是已經進到裡面的讀者能改變什麼。一份薪資交付合理地兩者都做:把封包加密,好讓只有持有密碼的人看得見它,然後鎖住公式儲存格,讓收件者能篩選與排序,卻無法悄悄改寫計算。錯誤從來不在於加了保護。錯誤在於讓它的存在頂替了加密,而當時的需求其實是機密性

保管這一側沒有安全網,而這是刻意設計的。50,000 次反覆運算的金鑰推導之所以存在,就是要讓猜測變得昂貴,而檔案內部沒有任何東西代管這個祕密。密碼弄丟就是資料弄丟。用你套用在資料庫憑證上的同一套紀律,來產生、交付並儲存這些密碼,加密就能守住它這一端

真正的檔案加密在 HotXLS 裡是一個呼叫。紀律住在這個呼叫周圍的一切裡:密碼保管、讓 HotXLS 無法重新開啟自己輸出的只寫邊界,以及一個你能在稽核中為之辯護的演算法宣稱。SaveAsEncrypted 與舊式的來回轉換,隨HotXLS Delphi Component一起出貨,在 Delphi 與 C++Builder 處理程序裡原生執行,整條路徑裡沒有任何 Excel 自動化