Bài viết kỹ thuật

Mã hóa certificate PDF trong Delphi: RSA-OAEP và ECDH

HotPDF mã hóa một PDF cho những người giữ certificate cụ thể qua security handler public-key của ISO 32000: EnablePubKeyEncryption nhận một seed ngẫu nhiên 20 byte, và mỗi recipient có một phong bì CMS riêng, dựng bằng AddPubKeyRecipientCertificate cho khóa RSA (key transport RSA-OAEP) hoặc AddPubKeyAgreementRecipientWithSecret cho khóa đường cong elliptic (ECDH trên P-256, P-384, P-521, X25519 hay X448). Chẳng ai phải chia sẻ mật khẩu; ai giữ khóa riêng khớp thì mở được tệp

Use case luôn là một biến thể của cùng một câu chuyện. Một gói kiểm toán quý gửi cho ba reviewer bên ngoài, bộ phận pháp chế muốn từng người đọc được, chỉ một người được phép in, và chẳng ai muốn một mật khẩu nằm trong thread email ngay cạnh attachment. Mã hóa bằng mật khẩu không diễn tả nổi điều đó. Mã hóa bằng certificate thì được, vì mỗi recipient mở khóa tài liệu bằng một khóa họ vốn đã giữ, và mỗi recipient có thể mang một bộ permission khác nhau bên trong phong bì của riêng họ

Mã hóa PDF dựa trên certificate khác mật khẩu chỗ nào?

Một PDF mã hóa public-key suy ra file key từ một seed ngẫu nhiên cộng với chính xác từng byte của mọi phong bì recipient, chứ không phải từ bất cứ thứ gì con người gõ vào. Handler được mô tả trong ISO 32000-1 §7.6.4 (§7.6.5 trong ISO 32000-2), và các phong bì là cấu trúc CMS EnvelopedData theo định nghĩa của RFC 5652. HotPDF ghi /Filter /Adobe.PubSec với /SubFilter /adbe.pkcs7.s5; với AES-256, điều đó nghĩa là /V 5 và một entry /DefaultCryptFilter dưới /CF với /CFM /AESV3, còn mảng /Recipients nằm bên trong crypt filter ấy. Mỗi phong bì mã hóa 24 byte: seed 20 byte theo sau là từ permission 32-bit của recipient đó. Giá trị /P trong dictionary mã hóa chỉ là hình thức, vì permission thật đi bên trong từng phong bì. Khi nạp, trình đọc mở một phong bì, thu hồi seed, rồi hash seed cùng với mọi phong bì theo thứ tự /Recipients (SHA-256 cho AES-256, SHA-1 cho các mật mã cũ hơn) để dựng lại file key. Nếu bạn vẫn đang cân nhắc giữa mô hình này và mật khẩu thường, bài hướng dẫn mã hóa mật khẩu AES-256 và các cờ permission bàn về mặt kia của sự đánh đổi

Sơ đồ mã hóa public-key của HotPDF: EnablePubKeyEncryption cố định một seed 20 byte, mỗi phong bì CMS EnvelopedData mã hóa 20 byte đó cộng một từ permission 32-bit bên trong /Filter /Adobe.PubSec với /SubFilter /adbe.pkcs7.s5 và /CFM /AESV3, còn trình đọc mở một phong bì, thu hồi seed rồi hash nó với mọi entry /Recipients theo thứ tự mảng để dựng lại file key
Giá trị /P trong dictionary mã hóa chỉ là hình thức vì permission thật đi bên trong từng phong bì, và chẳng gì ở hạ nguồn được phép sắp xếp lại hay mã hóa lại mảng mà digest chạy qua

Ghi recipient RSA bằng EnablePubKeyEncryption

Với certificate RSA, gọi EnablePubKeyEncryption với aes256, rồi gọi AddPubKeyRecipientCertificate một lần cho mỗi certificate mã hóa DER trước BeginDoc. Helper dựng một phong bì RSAES-OAEP ngay trong tiến trình với các giá trị THPDFRSAOAEPHash cho digest OAEP và digest MGF1 (rohSHA256, rohSHA384 hay rohSHA512), và mã hóa nội dung phong bì bằng AES-256-CBC

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // đúng 20 byte, kể cả AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // kiểu key mặc định là aes128
    // Reviewer A được in; reviewer B chỉ được đọc và trích xuất
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Ba chi tiết trong đoạn code trên là sống còn. Thứ nhất, độ dài seed cố định 20 byte cho mọi kiểu key, kể cả AES-256; EnablePubKeyEncryption ném lỗi với bất kỳ độ dài nào khác. Thứ hai, EnablePubKeyEncryption mặc định là aes128, và cả hai helper certificate đều từ chối chạy trừ khi kiểu key là aes256, nên quên tham số thứ hai là dính exception "certificate envelopes require aes256". Các mật mã cũ (k40, k128, aes128) vẫn chạy, nhưng chỉ qua AddPubKeyRecipient với một phong bì bạn tự dựng ở nơi khác. Thứ ba, mã hóa public-key AES-256 là tính năng PDF 2.0, nên HotPDF tự nâng phiên bản tài liệu lên 2.0. Khi StrictVersionLock được đặt trên một phiên bản thấp hơn, EnablePubKeyEncryption trả về mà không bật gì cả, và thất bại chỉ hiện ở dòng tiếp theo dưới dạng "call EnablePubKeyEncryption first". Đổi mã hóa trong một cập nhật tăng dần ném EInvalidOpException ngay lập tức

Thêm recipient ECDH: P-256, P-384, P-521, X25519 và X448

Với certificate đường cong elliptic, AddPubKeyAgreementRecipientWithSecret ghi một recipient key-agreement CMS (KeyAgreeRecipientInfo, cấu trúc KARI từ RFC 5753, với profile X25519 và X448 từ RFC 8418) và tự tính shared secret ECDH trong tiến trình. Bạn chọn đường cong bằng một giá trị THPDFPubKeyAgreementScheme: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 hay pkasX448. Scheme phải khớp với khóa trong certificate, nếu không lệnh gọi ném "Certificate key does not match the requested agreement scheme". Bên dưới nắp vung, mỗi phong bì nhận một UKM ngẫu nhiên 32 byte mới tinh, một key-encryption key suy ra bằng stdDH KDF (SHA-256 cho P-256 và X25519, SHA-384 cho P-384, SHA-512 cho P-521 và X448), và một AES-256 key wrap theo định nghĩa trong RFC 3394. Bản thân shared secret đến từ code đường cong Pascal thuần, chẳng có crypto provider nền tảng nào tham gia; bài số học đường cong NIST thuần Pascal kể cách tầng đó được dựng và xác minh. Với các đường cong Montgomery, toàn bộ cặp key tạm thời có thể sinh tại chỗ:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Scalar tạm thời mới cho mỗi phong bì; việc clamp diễn ra bên trong ladder
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint: chỉ có nghĩa với các đường cong NIST
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

Các đường cong NIST đòi hỏi bên gọi nhiều hơn. HotPDF chỉ kèm helper public-key cho X25519 và X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), nên với P-256, P-384 và P-521, bạn tự sinh cặp key tạm thời bằng công cụ của mình và truyền một scalar big-endian đúng kích thước trường (32, 48 hay 66 byte) cộng với điểm không nén 0x04||X||Y khớp nhau làm OriginatorPublicKey. HotPDF xác thực điểm recipient theo phương trình đường cong, nhưng nó không thể kiểm tra rằng public key originator của bạn thực sự thuộc về scalar của bạn. Hai nửa lệch nhau vẫn cho ra một phong bì dạng chuẩn hoàn hảo mà chẳng recipient nào mở nổi, và đó là lý do một lần nạp round-trip phải nằm trong test suite của bạn, chứ không chỉ một phép kiểm tra kích thước tệp

Sơ đồ thỏa thuận ECDH của HotPDF: AddPubKeyAgreementRecipientWithSecret suy ra shared secret bằng code đường cong Pascal thuần, trộn một UKM 32 byte mới tinh qua stdDH KDF với SHA-256 cho P-256 và X25519, SHA-384 cho P-384, SHA-512 cho P-521 và X448, rồi bọc content key bằng AES-256 key wrap của RFC 3394 để dựng phong bì KeyAgreeRecipientInfo
Giá trị scheme từ pkasECDHP256 tới pkasX448 phải khớp khóa certificate, và hai nửa scalar với public point lệch nhau vẫn cho ra một phong bì dạng chuẩn mà chẳng recipient nào mở nổi

Vì sao thứ tự của /Recipients lại quan trọng?

Thứ tự của /Recipients quan trọng vì file key là một digest trên seed và mọi phong bì theo thứ tự mảng, nên bên ghi và bên đọc phải hash cùng những byte theo cùng một trình tự. HotPDF giữ các phong bì theo thứ tự bạn thêm vào và ghi chúng nguyên vẹn, nghĩa là bạn có thể thêm recipient theo bất kỳ thứ tự nào mình thích, nhưng chẳng gì ở hạ nguồn được phép sắp xếp lại, mã hóa lại hay "dọn dẹp" mảng đó. Phần lớn bug thật ở khu vực này là biến thể của đúng chủ đề đó, nơi hai bên hash những byte khác nhau một chút:

  • Lưu mảng động vào một TList qua Add chỉ giữ một con trỏ thô trong khi reference count vẫn nằm với biến cục bộ. Lần SetLength kế tiếp giải phóng buffer và có thể tái sử dụng nó, nên mọi slot cuối cùng đều alias phong bì cuối và các tệp nhiều recipient suy ra sai key. Cách sửa là lưu một bản copy sở hữu bằng List.Add(Pointer(System.Copy(Bytes)))
  • Mở phong bì parse DER tại chỗ, và lượt thu hồi key thời đầu hash chính những mảng sống động ấy. Trình đọc giờ chụp snapshot bản nguyên vẹn của mọi phong bì trước khi bất kỳ phép unwrap nào đụng tới, và digest chạy trên các snapshot
  • DER nhị phân đi qua một TStringList Unicode sẽ bị mã hóa lại các byte từ $80 trở lên theo code page, nên HotPDF lưu phong bì dưới dạng văn bản hex ngay bên trong
  • Chuỗi mã hóa và chuỗi nhị phân phải được ghi dưới dạng hex string. Một literal string chịu sự chuẩn hóa cuối dòng, nơi CR, LF và CRLF đều thành một LF duy nhất (ISO 32000-1 §7.3.4.2), và điều đó âm thầm viết lại ciphertext. HotPDF phát mọi entry /Recipients dưới dạng hex string và miễn nó khỏi mã hóa chuỗi, vì mọi trình đọc cần các phong bì trước khi giữ bất kỳ khóa nào
  • Byte đầu của một BIT STRING DER đếm bit chưa dùng và phải bằng 0 với khóa căn theo byte. Bỏ không khởi tạo sau SetLength thì nó ghi những gì còn nằm trên stack, và một unwrapper khắt khe sẽ từ chối key originator, nên tệp thỉnh thoảng mở không nổi bằng chính khóa nó được viết cho
  • Khi cùng khóa ấy vẫn không giải mã nổi, hãy so từng tầng: file key, rồi prefix ciphertext (IV), rồi object key, rồi plaintext. Bug nằm ngay sau tầng đầu tiên không khớp

Mở một PDF mã hóa certificate bằng private key thế nào?

Để mở một PDF mã hóa certificate, hãy đăng ký private key material trước khi gọi LoadFromFile, vì HotPDF thu hồi file key trong lượt duyệt cấu trúc. Gán một khóa RSA hay EC được parse bằng HPDFParsePFX vào PubSecKeyMaterial, thêm các khóa RSA khác bằng AddPubSecKeyMaterial, và đăng ký scalar ECDH thô bằng AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint) với các hằng HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 hay HPDFOIDECP521. Các đường cong NIST đòi hỏi public point không nén của riêng recipient; các đường cong Montgomery thì bỏ qua nó

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // Tùy chọn: chọn thẳng phong bì thay vì thử tất cả
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = thử mọi phong bì theo thứ tự
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Không có callback, HotPDF thử mọi phong bì với mọi khóa đã đăng ký: khóa chính trước, rồi từng khóa RSA bổ sung, rồi EC material. PubSecRecipientQuery nhận số phong bì và trả về một chỉ số đếm từ 0 hay -1, còn chỉ số ngoài mảng ném exception thay vì bị kẹp lại. Lưu ý AddPubSecKeyMaterial chỉ nhận RSA material (nó khăng khăng đòi modulus và private exponent), nên khóa EC phải nằm ở PubSecKeyMaterial hay AddPubSecAgreementKeyMaterial. Khi chẳng khóa nào mở nổi phong bì nào, bước thu hồi trả về mà không có file key thay vì ném lỗi, nên hãy xác minh nội dung bạn mong đợi thực sự được giải mã thay vì tin rằng lệnh gọi nạp đã trả về

Sơ đồ nạp private key của HotPDF: PubSecKeyMaterial mang khóa chính RSA hay EC từ HPDFParsePFX, AddPubSecKeyMaterial chỉ thêm khóa RSA, AddPubSecAgreementKeyMaterial đăng ký scalar ECDH thô dưới các OID đường cong từ HPDFOIDX25519 tới HPDFOIDP521, và khi LoadFromFile provider thử khóa chính, rồi từng khóa RSA bổ sung, rồi EC material với mọi phong bì
Khi chẳng khóa nào mở nổi phong bì nào, bước thu hồi trả về mà không có file key thay vì ném lỗi, nên hãy xác minh nội dung thực sự được giải mã hay ghim phong bì qua PubSecRecipientQuery

Thứ HotPDF không đảm bảo

HotPDF đảm bảo writer và reader của chính nó khớp nhau từng byte, và nó dựng các phong bì theo đúng cấu trúc CMS đã trích ở trên. Nó không đảm bảo mọi trình xem PDF mở được mọi tổ hợp. Hỗ trợ key transport RSA-OAEP và recipient X25519 hay X448 khác nhau giữa các trình đọc và các phiên bản, và chúng tôi chưa công bố kết quả tương thích cho những tổ hợp đó. Nếu một tài liệu bắt buộc phải mở trong một trình xem cụ thể, hãy mã hóa một tệp thử với một certificate thử cùng kiểu khóa và mở nó ở đó trước khi cam kết một scheme. Permission mang trong phong bì vẫn là chính sách mà phần mềm tuân thủ tôn trọng, hệt như dưới mã hóa bằng mật khẩu. Chất lượng seed cũng là trách nhiệm của bạn: AESGenerateRandomBytes sinh ra cho đúng việc đó, và HotPDF quét sạch bản copy seed của mình một khi file key đã suy ra. Nếu bạn còn cần một chuỗi, stream hay attachment dùng một crypt filter khác, bài hướng dẫn chính sách crypt filter cho StmF, StrF và EFF chỉ ra những tên filter mà public-key handler chấp nhận

Mã hóa certificate, phong bì recipient RSA-OAEP và ECDH, cùng nạp private key đều có mặt trong HotPDF Delphi PDF component, bên cạnh mã hóa bằng mật khẩu, chữ ký số và phần còn lại của bộ công cụ ISO 32000 cho Delphi và C++Builder