Bài viết kỹ thuật

SASLprep AES-256 mật khẩu PDF trong Delphi với PDFlibPas

Một PDF mã hóa AES-256 với mật khẩu không phải ASCII mở được trong chương trình đã tạo ra nó và không mở được ở bất kỳ đâu khác. Nguyên nhân gần như luôn là một bước chuẩn hóa bị thiếu: ISO 32000-2 §7.6.4.3.3 yêu cầu mật khẩu phải được xử lý qua hồ sơ SASLprep của stringprep trước khi được mã hóa UTF-8 và băm. PDFlibPas, thư viện PDF cho Delphi và C++Builder, thực hiện bước chuẩn hóa đó bên trong Encrypt, EncryptFileDecryptFile

Đây không phải câu chuyện về mật khẩu sai và cũng không phải câu chuyện về bit quyền hạn. Nếu người dùng của bạn đang gõ một mật khẩu mà bạn chưa từng cấp, cơ chế thử lại trong bài về thử lại mật khẩu PDF mã hóa mới là thứ bạn cần, và nếu bạn đang cố tìm hiểu xem một file hiện có thực sự thực thi điều gì, bài kiểm toán mã hóa và quyền hạn phủ đúng phạm vi đó. Bài này hẹp hơn và kỳ lạ hơn: mật khẩu đúng, người dùng gõ đúng, và file vẫn từ chối mở ở nơi khác

Vì sao một mật khẩu không phải ASCII mở được ở trình đọc này mà không mở được ở trình đọc khác?

Vì hai chương trình băm hai chuỗi byte khác nhau từ cùng một lần gõ phím. Phép suy khóa revision 6 trong ISO 32000-2 §7.6.4.3.3 lấy mật khẩu dưới dạng byte UTF-8, cắt ngắn còn 127 byte, thêm một salt, rồi chạy hàm băm được tăng cường; kết quả sau đó được so khớp với các mục /U/O trong dictionary mã hóa. Không có gì trong chuỗi đó là mơ hồ. Một byte khác nhau ở bất kỳ đâu trong đầu vào tạo ra một digest hoàn toàn khác, việc xác thực thất bại, và trình đọc chỉ có đúng một điều để nói: sai mật khẩu

Các byte khác nhau vì Unicode cung cấp nhiều cách gõ ra thứ trông giống cùng một mật khẩu. Một mật khẩu tiếng Trung có thể đến dưới dạng ký tự đã tổ hợp sẵn (precomposed) từ một bộ gõ và dưới dạng ký tự tương thích (compatibility form) từ một bộ gõ khác. Một mật khẩu tiếng Đức hoặc tiếng Pháp sao chép ra từ trình soạn thảo văn bản có thể mang một dấu cách không ngắt dòng NO-BREAK SPACE (U+00A0) ở nơi người dùng tin rằng đó là dấu cách thường, hoặc một SOFT HYPHEN (U+00AD) hiển thị thành không gì cả. SASLprep tồn tại để gộp tất cả những trường hợp đó về một dạng chuẩn tắc duy nhất trước khi bất kỳ ai băm bất cứ thứ gì, để mọi triển khai tuân thủ đều suy ra cùng một khóa từ cùng một ý định

SASLprep thực sự thay đổi gì ở một mật khẩu?

RFC 4013 định nghĩa SASLprep như một hồ sơ của khung stringprep trong RFC 3454, và đó là bốn bước có thứ tự chứ không phải một phép biến đổi duy nhất. Ánh xạ đến trước: bảng C.1.2 của RFC 3454 (khoảng trắng không phải ASCII) được ánh xạ về U+0020, và bảng B.1 (các ký tự thường được ánh xạ về không có gì) bị xóa hẳn. Chuẩn hóa về Unicode NFKC theo sau, đây là bước gộp các ký tự tương thích và chuỗi tổ hợp. Sau đó bước kiểm tra đầu ra bị cấm loại bỏ bất kỳ thứ gì thuộc các bảng C.2.1 đến C.9. Cuối cùng quy tắc song hướng (bidirectional) từ RFC 3454 mục 6 được áp dụng lên chuỗi đã chuẩn hóa

PDFlibPas triển khai toàn bộ hồ sơ này trong unit PDFlibSASLprep, đơn vị này cung cấp một điểm vào duy nhất. PLSASLprepPassword nhận mật khẩu thô, ghi dạng đã chuẩn hóa vào một tham số var, và trả về False khi mật khẩu phải bị từ chối. Hàm này cố tình toàn vẹn trên đường thuận: một mật khẩu chỉ gồm ASCII trả về giống hệt byte, nên không có gì thay đổi với các triển khai hiện có

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;

Sự mơ hồ U+200B mà các bảng không giải quyết được

Một điểm mã rơi vào hai bảng RFC 3454 cùng lúc, và hai bảng đó mâu thuẫn nhau. ZERO WIDTH SPACE (U+200B) nằm trong khoảng C.1.2 từ U+2000 đến U+200B, nơi quy tắc nói ánh xạ nó về U+0020, và nó cũng nằm trong khoảng B.1 từ U+200B đến U+200D, nơi quy tắc nói xóa nó. Đọc bước ánh xạ theo thứ tự nào cũng cho ra byte khác nhau từ cùng một mật khẩu: a+U+200B+b chuẩn hóa thành a b theo C.1.2 và thành ab theo B.1. RFC 4013 nêu tên cả hai bảng và không nói bảng nào thắng, nên đây là một sự mơ hồ thật sự trong đặc tả chứ không phải lỗi đọc. PDFlibPas kiểm tra thành viên C.1.2 trước và do đó ánh xạ U+200B thành dấu cách, đây là hành vi mà các triển khai stringprep phổ biến khác đã chọn; khớp với chúng là điều duy nhất quan trọng ở đây, vì mục tiêu là khớp byte với bất kỳ trình đọc nào mà khách hàng đang dùng

Đọc file cũ: thử dạng đã chuẩn hóa trước, dạng thô sau

Bản sửa lỗi tự tạo ra vấn đề tương thích của riêng nó. Mọi file AES-256 được ghi trước khi có thay đổi này đều băm mật khẩu UTF-8 thô, nên biến trình đọc trở nên tuân thủ nghiêm ngặt sẽ khóa khách hàng khỏi kho lưu trữ của chính họ. PDFlibPas giải quyết điều này ở phía đọc bằng cách thử hai ứng viên theo thứ tự. TPDFDocument.SetPassword xây một danh sách ứng viên bắt đầu bằng dạng đã chuẩn hóa và rơi về dạng thô, và nó chỉ thêm mục đã chuẩn hóa khi tài liệu thực sự là AES-256 và hai dạng đó khác nhau. Với một mật khẩu ASCII, hai dạng giống hệt nhau, danh sách chỉ có một mục, và chi phí của toàn bộ cơ chế này là một phép so sánh chuỗi duy nhất. DecryptFile làm điều tương tự trên đường viết lại AES-256 trực tiếp của nó, gọi PLDirectDecryptFileAES256 với mật khẩu đã chuẩn hóa trước

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;

Cơ chế dự phòng mang theo một điều đáng để sao chép lại. Lần thử thứ hai trong DecryptFile chỉ chạy khi dạng đã chuẩn hóa và dạng thô khác nhau lần thử đầu không báo mã lỗi nghiêm trọng. Một lỗi cấu trúc nghĩa là đầu vào bị hỏng hoặc không phải revision mã hóa mà bạn đã giả định, và thử lại một file hỏng với mật khẩu khác chỉ tốn thêm một lượt phân tích đầy đủ trên đầu vào thù địch; lý do đằng sau phản xạ đó được trình bày trong bài về phân tích PDF không đáng tin cậy một cách an toàn. Cũng lưu ý rằng không có cơ chế dự phòng ở phía ghi, và sự bất đối xứng đó là có chủ đích. Đọc thì bao dung với lịch sử, ghi thì không: mọi file AES-256 mới đều nhận byte tuân thủ

Mật khẩu nào bị từ chối thẳng, và mã lỗi 604 là gì?

SASLprep có thể từ chối một mật khẩu hoàn toàn, và khi nó làm vậy, việc mã hóa phải thất bại rõ ràng thay vì âm thầm thay thế bằng thứ khác. EncryptEncryptFile chuẩn hóa cả mật khẩu chủ và mật khẩu người dùng bất cứ khi nào Strength là 3 hoặc 4, trả về 0 khi bị từ chối, và đặt LastErrorCode thành PDFLIB_ERROR_PASSWORD_SASLPREP, tức là 604. Hai nhóm đầu vào kích hoạt điều này. Các bảng cấm đầu ra loại bỏ ký tự điều khiển (C.2.1 và C.2.2), điểm mã dùng riêng (C.3), ký tự không phải ký tự (C.4), surrogate cô lập (C.5), U+FFFD (C.6), ký tự mô tả biểu ý (C.7), và các dải điều khiển hiển thị cùng gắn thẻ (C.8 và C.9). Riêng biệt, quy tắc bidi ở mục 6 RFC 3454 loại bỏ bất kỳ chuỗi nào chứa một ký tự RandALCat từ bảng D.1 trừ khi chuỗi vừa bắt đầu vừa kết thúc bằng ký tự đó và không chứa bất kỳ chữ cái trái-sang-phải nào

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;

Quy tắc bidi đó là thứ sẽ làm đội hỗ trợ của bạn bất ngờ. Một mật khẩu tiếng Ả Rập hoặc tiếng Do Thái kết thúc bằng một chữ số phương Tây, hoặc một mật khẩu có lẫn một chữ cái Latin lạc lõng ở giữa, đều bị đặc tả từ chối dù trông hoàn toàn hợp lý trong ô nhập liệu. Hãy hiển thị mã 604 như một thông báo về ký tự trong mật khẩu, không phải như một lỗi mã hóa chung chung, nếu không sẽ có người mất cả buổi chiều đi tìm lỗi trong phần suy khóa của bạn

Giới hạn thực sự: NFKC, một LCat gần đúng, và một cái bẫy Delphi

Hai phần của việc triển khai là các phép gần đúng, và cả hai đều đáng được nói rõ thay vì giấu đi. Việc chuẩn hóa NFKC được thực hiện bởi API NormalizeString của Windows, được tải động từ Normaliz.dll. Khi thư viện đó không có sẵn, chuỗi đã ánh xạ được dùng nguyên không qua chuẩn hóa, nghĩa là các bước ánh xạ và cấm vẫn chạy nhưng việc gộp tương thích thì không. Trong thực tế, DLL này đã có mặt trên mọi bản Windows kể từ Vista, nên đường suy giảm này là mối quan tâm của các hệ trước Vista và ngoài Windows chứ không phải một mối lo còn sống, nhưng một mật khẩu phụ thuộc vào việc gộp NFKC sẽ tạo ra byte khác ở đó và đó là một sự khác biệt thật, dù hiếm gặp. Kiểm tra bidi là phép gần đúng thứ hai: việc phát hiện ký tự LCat dùng các dải chữ cái phổ biến thay vì toàn bộ bảng D.2 của RFC 3454, và hướng của sai số đó là điều khiến nó chấp nhận được. Một ký tự LCat bị bỏ sót chỉ có thể khiến quy tắc bidi thông qua ở nơi đặc tả lẽ ra đã từ chối, không bao giờ ngược lại, và nó không bao giờ chạm vào các bước ánh xạ hoặc chuẩn hóa, nên chuỗi byte đã chuẩn hóa của một mật khẩu được chấp nhận không đổi. Rủi ro còn lại vì vậy là một sự khác biệt về chính sách chứ không phải khác biệt về byte: một mật khẩu chữ viết kỳ lạ mà một triển khai nghiêm ngặt hơn sẽ từ chối hoàn toàn. Mọi mật khẩu mà cả hai bên chấp nhận đều băm giống hệt nhau, đó là đặc tính mà khả năng tương tác thực sự phụ thuộc vào

Cuối cùng, một cái bẫy cú pháp Delphi tốn cả tiếng đồng hồ nếu bạn chưa từng gặp. Khi một hàm trả về một kiểu thủ tục, gán nó mà không có dấu ngoặc đơn không gọi hàm đó. Trình biên dịch đọc Proc := GetNormalizeProc; là lấy địa chỉ của chính GetNormalizeProc, rồi báo E2009 với lời phàn nàn khó hiểu rằng calling convention khác nhau, vì hàm accessor dùng convention mặc định trong khi kiểu API được import là stdcall. Cặp dấu ngoặc rỗng là bắt buộc

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;

Chuẩn hóa mật khẩu là một trong những chi tiết chưa bao giờ xuất hiện trong danh sách tính năng nhưng quyết định xem một tài liệu mã hóa có sống sót qua lần tiếp xúc với một khách hàng ở locale khác hay không. Các điểm vào Encrypt, EncryptFile, DecryptFileSetPassword mô tả ở đây là một phần của losLab PDF Developer Library Pascal Edition cho Delphi và C++Builder, trang sản phẩm của nó mang tài liệu tham chiếu mã hóa đầy đủ và bảng mã lỗi hoàn chỉnh