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
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
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
TListquaAddchỉ giữ một con trỏ thô trong khi reference count vẫn nằm với biến cục bộ. LầnSetLengthkế 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ằngList.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
TStringListUnicode sẽ bị mã hóa lại các byte từ$80trở 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
/Recipientsdướ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 STRINGDER đế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 sauSetLengththì 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ề
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