技術文章

Delphi PDFlibPas 中的 SASLprep AES-256 非 ASCII 密碼

用非 ASCII 密碼加密的 AES-256 PDF,只在寫出它的那套程式裡打得開,換別的程式就不行。原因幾乎總是缺少一道準備步驟:ISO 32000-2 §7.6.4.3.3 要求密碼在 UTF-8 編碼與雜湊之前,先經過 stringprep 的 SASLprep 設定檔處理。PDFlibPas,這套給 Delphi 與 C++Builder 用的 PDF 元件庫,在 EncryptEncryptFileDecryptFile 內部執行了這道準備程序

這不是密碼輸入錯誤的故事,也不是權限位元的故事。如果你的使用者輸入的是你從未核發過的密碼,重試加密 PDF 密碼的文章裡的重試機制才是你要的;如果你想弄清楚一份既有檔案實際上強制了什麼,加密與權限稽核涵蓋了那個範圍。這一篇範圍更窄、也更詭異:密碼是對的,使用者也打對了,檔案在別的地方卻依然拒絕開啟

為何非 ASCII 密碼在一個閱讀器能開,換一個就不行?

因為兩套程式對同樣的按鍵輸入雜湊了不同的位元組序列。ISO 32000-2 §7.6.4.3.3 裡的修訂版 6 金鑰推導,把密碼當成 UTF-8 位元組,截斷到 127 位元組,附加一個鹽值,再跑強化雜湊;結果會拿去比對加密字典裡的 /U/O 項目。這條鏈裡沒有一步是模糊的。輸入裡任何一個位元組的差異,都會產生完全不同的摘要,驗證失敗,而閱讀器能說的只有一句話:密碼錯誤

位元組會分歧,是因為 Unicode 提供了好幾種方式來輸入看起來一樣的密碼。一個中文密碼可能來自某個輸入法的預組合字元,也可能來自另一個輸入法的相容形式。從文字處理軟體複製出來的德文或法文密碼,可能帶有一個使用者以為是普通空格的 NO-BREAK SPACE(U+00A0),或是一個渲染成什麼都沒有的 SOFT HYPHEN(U+00AD)。SASLprep 存在的目的,就是在任何人雜湊之前,把這些全部收斂成一種標準形式,好讓每個遵循規範的實作都從相同的意圖推導出相同的金鑰

SASLprep 究竟對密碼改了什麼?

RFC 4013 把 SASLprep 定義成 RFC 3454 stringprep 框架的一個設定檔,它是四個有序步驟,而不是單一變換。映射排第一:RFC 3454 表 C.1.2(非 ASCII 空白)被映射成 U+0020,表 B.1(常被映射成空的字元)則直接被刪除。接著是正規化為 Unicode NFKC,這一步會折疊相容字元與組合序列。然後禁止輸出檢查會拒絕表 C.2.1 到 C.9 中的任何字元。最後套用 RFC 3454 第 6 節的雙向規則到正規化後的字串

PDFlibPas 在 PDFlibSASLprep 單元裡實作了整個設定檔,並公開一個進入點。PLSASLprepPassword 接收原始密碼,把準備好的形式寫入一個 var 參數,在密碼必須被拒絕時回傳 False。這個函式在正常路徑上刻意做到完全確定:純 ASCII 密碼會原封不動地回傳,所以現有部署不會有任何變化

uses
  PDFlibSASLprep;

var
  Prepared: WideString;
begin
  // RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 deletes SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 maps NBSP to U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC folds ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC folds ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII is never touched
end;

兩張表都沒解決的 U+200B 歧義

有一個碼位同時落在兩張 RFC 3454 表裡,而這兩張表意見不一。ZERO WIDTH SPACE(U+200B)落在 C.1.2 的 U+2000 到 U+200B 範圍內,規則說要把它映射成 U+0020;它也落在 B.1 的 U+200B 到 U+200D 範圍內,規則說要刪除它。以不同順序讀映射步驟,同一個密碼就會得出不同的位元組:a+U+200B+b 依 C.1.2 準備成 a b,依 B.1 準備成 ab。RFC 4013 提到了這兩張表,卻沒說哪個勝出,所以這是規範本身的真實歧義,而不是解讀錯誤。PDFlibPas 先測試 C.1.2 的成員資格,因此把 U+200B 映射成空格,這也是其他廣泛部署的 stringprep 實作所採用的行為;與它們一致才是唯一要緊的事,因為目標是與客戶恰好在用的任何閱讀器達成位元組一致

讀取舊檔案:先試準備過的,再試原始的

這個修正製造了自己的相容性問題。變更之前寫入的每個 AES-256 檔案,雜湊的都是原始 UTF-8 密碼,所以讓讀取端變得嚴格遵循規範,會把客戶鎖在自己的封存檔外頭。PDFlibPas 在讀取端用依序嘗試兩個候選值來解決這個問題。TPDFDocument.SetPassword 建構一份候選清單,以準備過的形式開頭,退回原始形式作為備援,而且只有在文件確實是 AES-256、且兩種形式不同時,才會加入準備過的項目。對純 ASCII 密碼而言,兩種形式一樣,清單只有一個項目,整套機制的成本就只是一次字串比對。DecryptFile 沿著它自己直接改寫的 AES-256 路徑做同樣的事,先用準備過的密碼呼叫 PLDirectDecryptFileAES256

var
  Lib: TPDFlib;
  Bytes: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.DrawText(100, 100, 'saslprep roundtrip');
    // Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
    Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
      Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
    Bytes := Lib.SaveToString;
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    // 'pass' is what SASLprep produced and what any conforming reader computes,
    // so the plain ASCII form opens a file created with the soft-hyphen form
    if Lib.LoadFromString(Bytes, 'pass') = 1 then
      Caption := IntToStr(Lib.PageCount);
  finally
    Lib.Free;
  end;
end;

這個備援機制帶著一個值得抄下來的保護措施。DecryptFile 裡的第二次嘗試,只在準備過的形式與原始形式不同、而且第一次嘗試沒有回報硬性錯誤碼時才會執行。結構性失敗代表輸入已損毀,或不是你假設的加密修訂版,用另一個密碼重試一份損壞的檔案,只會對惡意輸入多燒一次完整剖析;這個反射動作背後的理由,寫在安全剖析不受信任 PDF 的筆記裡。另請注意寫入端沒有備援,而這個不對稱是刻意的。讀取容忍歷史,寫入不容忍:每份新的 AES-256 檔案都得到符合規範的位元組

哪些密碼會被直接拒絕?錯誤 604 又是什麼?

SASLprep 可以完全拒絕一個密碼,一旦它這麼做,加密就必須大聲失敗,而不是悄悄替換成別的東西。只要 Strength 是 3 或 4,EncryptEncryptFile 就會準備擁有者與使用者密碼,拒絕時回傳 0,並把 LastErrorCode 設成 PDFLIB_ERROR_PASSWORD_SASLPREP,也就是 604。有兩類輸入會觸發它。禁止輸出表拒絕控制字元(C.2.1 與 C.2.2)、私用碼位(C.3)、非字元(C.4)、孤立代理項(C.5)、U+FFFD(C.6)、表意文字描述字元(C.7),以及顯示控制與標籤範圍(C.8 與 C.9)。此外,RFC 3454 第 6 節的雙向規則會拒絕任何含有表 D.1 中 RandALCat 字元的字串,除非該字串同時以此類字元開頭與結尾,且完全不含由左到右的字母

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // U+0007 is a C.2.1 control character, so preparation refuses the password
    if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
         Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
    begin
      if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then  // 604
        ShowMessage('The password contains characters that PDF encryption does not permit.');
    end;
  finally
    Lib.Free;
  end;
end;

那條雙向規則正是會讓你的客服部門意外的地方。一個以西方數字結尾的阿拉伯文或希伯來文密碼,或是中間夾了一個雜散拉丁字母的密碼,即便在輸入欄位裡看起來完全合理,規範也會拒絕它。請把 604 呈現成一則關於密碼字元的訊息,而不是通用的加密失敗,否則會有人花一個下午在你的金鑰推導裡找一個根本不存在的錯誤

誠實面對限制:NFKC、近似的 LCat、還有一個 Delphi 陷阱

實作裡有兩處是近似值,兩者都值得明講而不是藏起來。NFKC 正規化由 Windows 的 NormalizeString API 執行,這個 API 是從 Normaliz.dll 動態載入的。當那個程式庫不可用時,會使用未正規化的映射後字串,也就是說映射與禁止檢查兩個步驟仍會執行,只是相容性折疊不會發生。實務上這個 DLL 從 Vista 開始的每一版 Windows 都有內附,所以降級路徑是一個 Vista 之前與非 Windows 平台的顧慮,而非現實中會發生的情況,但一個依賴 NFKC 折疊的密碼,在那種情況下確實會產生不同的位元組,這是一個真實、雖然罕見的分歧。雙向檢查是第二個近似值:偵測 LCat 字元用的是常見的字母範圍,而不是完整的 RFC 3454 表 D.2,而這個誤差的方向正是讓它可被接受的原因。漏判的 LCat 字元只會讓雙向規則通過本該被規範拒絕的情況,絕不會反過來,而且它從不觸及映射或正規化步驟,所以已被接受密碼的準備後位元組序列不受影響。因此剩餘的風險是政策上的分歧,而不是位元組上的分歧:一個更嚴格的實作本該完全拒絕接受的異域文字密碼。雙方都接受的每一個密碼都會雜湊出相同結果,而這正是互通性真正仰賴的特性

最後,一個曾經讓人白白損失一小時的 Delphi 語法陷阱。當一個函式回傳程序型別時,不加括號賦值並不會呼叫它。編譯器把 Proc := GetNormalizeProc; 讀成取 GetNormalizeProc 本身的位址,然後回報 E2009,抱怨沒什麼幫助地說呼叫慣例不同,因為存取器用的是預設呼叫慣例,而被匯入的 API 型別是 stdcall。那對空括號是必要的

type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // loads Normaliz.dll on first use
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: reads as @GetNormalizeProc, conventions differ
  Proc := GetNormalizeProc();    // correct: calls the accessor and assigns its result
  if not Assigned(Proc) then
    Exit;                        // no NFKC available, mapped string is used as-is
end;

密碼準備是那種永遠不會出現在功能清單上、卻決定了一份加密文件遇到另一個地區的客戶時能不能活下來的細節。這裡描述的 EncryptEncryptFileDecryptFileSetPassword 進入點,都是 Delphi 與 C++Builder 版 losLab PDF Developer Library Pascal Edition 的一部分,其產品頁面收錄完整的加密參考與完整的錯誤碼表