Bài viết kỹ thuật

Chữ ký số PDF và PAdES trong Delphi với HotPDF

Chữ ký PDF chủ yếu là công việc hạch toán byte (byte accounting), và hạch toán byte chính là nơi mọi thứ đi sai. Phần mật mã học chạy trên đoạn mã đã được kiểm toán trong hai thập kỷ, và phần đó gần như không bao giờ hỏng. Thứ hỏng trong môi trường sản xuất khiêm tốn hơn nhiều: một chỗ giữ chỗ (placeholder) được dành quá nhỏ cho chữ ký thực sự, một hash được tính trên đúng đoạn sai của tệp, hoặc một lần "lưu" sau khi ký đã âm thầm ghi đè các byte mà chữ ký vốn đã đóng băng. Sắp xếp các byte đúng cách và dấu tích xanh sẽ tự lo liệu

HotPDF hỗ trợ ký ở ba mức cho Delphi và C++Builder, và bạn chọn giữa chúng bằng cách trả lời một câu hỏi: khóa riêng nằm ở đâu? Một tệp PFX trên đĩa chỉ cần một lệnh gọi hàm duy nhất. Một khóa bị khóa trong HSM hoặc một dịch vụ ký từ xa cần chuỗi thao tác dành chỗ-hash-chèn, vì không thư viện nào có thể chạm vào một token và lấy khóa ra. Một chữ ký phải thỏa mãn quy định châu Âu cần thêm các cấu trúc nền tảng PAdES bên trên đó. Các phần bên dưới sẽ đi theo tiến trình đó

Sơ đồ quyết định lựa chọn giữa ký một lượt PFX của HotPDF, đường reserve-hash-insert khi khóa nằm trong HSM hoặc dịch vụ từ xa, và cấu trúc baseline PAdES cho ký số theo quy định châu Âu
Chọn tầng ký bằng cách hỏi khóa riêng nằm ở đâu; một tệp PFX đọc được gói ký vào một lệnh gọi duy nhất, trong khi khóa nằm trên token buộc phải đi vòng ở mức byte và quy định châu Âu cộng thêm lớp PAdES

/ByteRange định vị các byte đã ký như thế nào

Một chữ ký phải nằm bên trong chính tệp mà nó ký, và nó không thể tự ký chính nó. PDF vượt qua nghịch lý này bằng cách để lại một lỗ hổng. Trước khi ký, trình ghi dành sẵn một mục /Contents có kích thước cố định đầy các số không và ghi lại một mảng /ByteRange cho hai đoạn ở hai bên của nó: mọi thứ trước lỗ hổng, mọi thứ sau nó. Bên ký sẽ hash hai đoạn đó và ghi khối CMS kết quả vào lỗ hổng dưới dạng thập lục phân. Cái bẫy nằm ở từ cố định. Bạn phải cam kết kích thước của lỗ hổng đó trước khi biết chữ ký hoàn chỉnh sẽ lớn đến mức nào, nên việc dành chỗ phải là một ước lượng dư dả và chắc chắn. Tám kilobyte thường đủ chứa thoải mái một chữ ký CMS tách rời (detached) với một chuỗi chứng chỉ ngắn

HotPDF tách hai trường hợp thành hai lệnh gọi, và việc nhầm lẫn giữa chúng là một lỗi phổ biến ban đầu. AddSignatureField đặt một trường trống, hiển thị được để một người ký sau này trong một trình xem. AddSignedSignatureField tạo trường và dành sẵn lỗ hổng /Contents, đây chính là lệnh bạn muốn dùng bất cứ khi nào mã lệnh, chứ không phải con người, sẽ hoàn tất chữ ký. Đưa cho một bên ký bên ngoài một trường trống thì nó chẳng có gì để điền vào

Con đường một lệnh gọi: ký từ một tệp PFX

Khi chứng chỉ và khóa riêng của nó nằm trong một tệp PFX/PKCS#12 mà tiến trình của bạn có thể đọc, toàn bộ pipeline rút gọn thành một hàm lớp:

if THotPDF.SignPDFWithPFX('invoice-unsigned.pdf', 'invoice-signed.pdf',
    'company-cert.pfx', 'pfx-password') then
  Writeln('Signed: invoice-signed.pdf')
else
  raise Exception.Create('PFX signing failed');

Khi việc này thất bại, PDF hiếm khi là vấn đề. PFX mới là vấn đề. HotPDF đọc các container được bảo vệ bằng PBES2, nghĩa là suy khóa PBKDF2 trên AES-256-CBC. Một PFX được xuất bởi trình hướng dẫn chứng chỉ Windows cũ hơn, hoặc bởi OpenSSL trước phiên bản 3.0, thường được bọc bằng RC2 hoặc 3DES kiểu cũ thay vào đó, và nó đơn giản là sẽ không phân tích cú pháp được. Cách khắc phục là xuất lại container một lần với bảo vệ hiện đại; OpenSSL ngày nay làm điều này theo mặc định, và đó không phải là một thay đổi mã lệnh. Vì vậy khi việc ký chết ngay lập tức trên một chứng chỉ "hoạt động ở mọi nơi khác," hãy xem cách PFX được tạo ra trước khi nghi ngờ mã lệnh của chính bạn

Con đường dành chỗ-hash-chèn cho HSM và token

Con đường một lệnh gọi giả định rằng tiến trình của bạn có thể đọc khóa như một tệp. Ngày càng nhiều trường hợp không thể. Khóa nằm trong HSM, trên một token USB, hoặc đằng sau API của một dịch vụ ký, và không có cách nào để một thư viện chạm trực tiếp vào nó. HotPDF xử lý điều đó bằng cách chia việc ký thành các bước ở cấp độ byte: ghi một tài liệu chỗ giữ chỗ, yêu cầu thư viện cung cấp các khoảng hash, chuyển đầu vào hash cho bất cứ thứ gì đang giữ khóa, rồi ghép khối CMS trả về trở lại vào lỗ hổng

HotPDF: Pipeline reserve-hash-insert bốn bước trên placeholder.pdf cho thấy lỗ /Contents dành sẵn giữa hai đoạn ByteRange và một HSM đổi digest lấy CMS hex
HotPDF đặt chỗ lỗ trống và báo cả hai vùng ByteRange, bên giữ khóa của bạn ký ở ngoài, và CMS trả về được ghép lại đúng từng byte mà không chạm vào byte nào đã đóng băng
var
  Doc: THotPDF;
  Fs: TFileStream;
  PdfBytes, HashInput, SigHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, CStart, CLen: Integer;
begin
  // 1. Ghi tài liệu với một lỗ hổng /Contents đã được dành sẵn
  Doc := THotPDF.Create(nil);
  try
    Doc.FileName := 'placeholder.pdf';
    Doc.BeginDoc;
    Doc.CurrentPage.AddSignedSignatureField('Sig1',
      Rect(50, 100, 350, 150), 8192, 'adbe.pkcs7.detached',
      'Contract approval', 'Boston, MA', 'legal@example.com');
    Doc.EndDoc;
  finally
    Doc.Free;
  end;

  // 2. Tải các byte đã lưu; các offset trả về đánh số từ 0
  Fs := TFileStream.Create('placeholder.pdf', fmOpenRead);
  try
    SetLength(PdfBytes, Fs.Size);
    Fs.ReadBuffer(PdfBytes[1], Fs.Size);
  finally
    Fs.Free;
  end;
  THotPDF.PreparePDFForSigning(PdfBytes, R1Start, R1Len, R2Start, R2Len,
    CStart, CLen);

  // 3. Hash cả hai đoạn và ký bên ngoài (HSM, token, dịch vụ)
  HashInput := Copy(PdfBytes, R1Start + 1, R1Len) +
               Copy(PdfBytes, R2Start + 1, R2Len);
  SigHex := SignWithHsm(HashInput);  // tích hợp của bạn: trả về CMS dạng hex

  // 4. Ghép chữ ký vào lỗ hổng đã dành sẵn
  THotPDF.InsertSignatureHex(PdfBytes, SigHex);
  Fs := TFileStream.Create('signed.pdf', fmCreate);
  try
    Fs.WriteBuffer(PdfBytes[1], Length(PdfBytes));
  finally
    Fs.Free;
  end;
end;

Hai chi tiết trong chuỗi thao tác này gây ra phần lớn các lỗi không thường xuyên. Điều đầu tiên là PreparePDFForSigning hoạt động trên các byte của một tệp đã hoàn tất. Chỗ giữ chỗ phải được ghi và lưu đầy đủ trước khi các offset có ý nghĩa gì; tính toán chúng dựa trên một luồng vẫn đang được lắp ráp và chúng sẽ không khớp với các byte mà cuối cùng bạn hash. Điều thứ hai, một lần nữa, là kích thước dành chỗ. 8192 byte bạn yêu cầu phải chứa được khối CMS cuối cùng, và một chữ ký mang theo các chứng chỉ trung gian, hoặc một chữ ký được một dịch vụ trang trí thêm các thuộc tính đã ký, có thể vượt quá con số đó. InsertSignatureHex sẽ không mở rộng lỗ hổng để tạo thêm chỗ. Dấu hiệu nhận biết là một pipeline ký ổn với một chứng chỉ nhưng lại thất bại với chứng chỉ tiếp theo; cách chữa là tạo lại chỗ giữ chỗ với một mức dành chỗ được đo từ một chữ ký thực do chính bên ký thực tế tạo ra, chứ không phải đoán chừng

Các baseline PAdES, và các dấu thời gian giữ cho chữ ký còn hiệu lực

Nếu bạn đang ký theo quy định châu Âu, tiêu chuẩn áp dụng là ETSI EN 319 142-1, tiêu chuẩn này xếp chồng bốn mức baseline PAdES. B-B là chữ ký thuần túy. B-T thêm một dấu thời gian đáng tin cậy chứng minh thời điểm nó được tạo ra. B-LT nhúng tài liệu xác thực, các chứng chỉ và dữ liệu thu hồi, vào bên trong tài liệu để nó vẫn có thể được kiểm tra nhiều năm sau. B-LTA xếp thêm các dấu thời gian tài liệu định kỳ lên trên, để bằng chứng tồn tại lâu hơn cả các thuật toán mà nó được xây dựng dựa trên. HotPDF phát ra các cấu trúc phía tài liệu cho từng mức:

HotPDF: Các mức baseline PAdES xếp chồng từ B-B qua B-T và B-LT đến B-LTA, cùng dòng thời gian gia hạn cho thấy các dấu thời gian tài liệu định kỳ giữ chữ ký còn xác minh được sau nhiều thập kỷ
Mỗi cấp xếp thêm lớp bảo vệ mới lên cấp trước; B-LTA liên tục gia hạn document timestamp để bằng chứng sống lâu hơn các thuật toán từng dựng nên nó
// Trường chữ ký baseline PAdES (ETSI EN 319 142-1)
Pdf.CurrentPage.AddPAdESSignatureField(
  'ApprovalSig', Rect(50, 100, 350, 150), 'B-B',
  'Contract approval', 'Boston, MA', 'legal@example.com');

// Dấu thời gian tài liệu: dành chỗ lớn hơn cho token TSA và chuỗi chứng chỉ
Pdf.CurrentPage.AddDocumentTimestampSignature('ArchiveTS', 16384);

Mức dành chỗ 16384 byte cho dấu thời gian là có chủ đích. Một cơ quan cấp dấu thời gian (timestamp authority) trả về một token kéo theo cả chuỗi chứng chỉ riêng của nó, nên nó thường cần nhiều chỗ hơn mức 8 KB mà một chữ ký thuần túy hài lòng. Những dấu thời gian tài liệu đó cũng chính là cơ chế đứng sau B-LTA: đóng lại dấu thời gian cho một chữ ký đã lưu trữ mỗi vài năm một lần, bằng các thuật toán vẫn còn hiện hành, chính là điều giữ cho một tài liệu bạn đã ký vào năm 2026 vẫn có thể xác minh được vào năm 2040

Một lời về các chuỗi lý do, địa điểm, và thông tin liên hệ mà cả hai lệnh gọi trường đều chấp nhận: chúng chỉ là siêu dữ liệu tiện lợi và không hơn không kém. HotPDF lưu trữ chúng như các mục dictionary thuần túy và vẽ chúng vào hình thức hiển thị chữ ký có thể nhìn thấy, nhưng không trình xác minh nào kiểm tra chúng dựa trên bất cứ điều gì. Hãy điền chúng nhất quán từ dữ liệu quy trình làm việc của bạn, vì các kiểm toán viên có đọc chúng, nhưng đừng bao giờ nhầm chúng với bằng chứng. Tuyên bố mật mã học thực sự nằm hoàn toàn trong CMS và chuỗi chứng chỉ của nó, và một trình xác minh bỏ qua hoàn toàn văn bản hiển thị

Sau khi ký, tệp chỉ có thể lớn thêm

Ngay khi một chữ ký tồn tại, các byte bên trong phạm vi của nó bị đóng băng. Cách hợp lệ duy nhất để thay đổi tệp sau đó là một bản cập nhật gia tăng (incremental update) theo ISO 32000-1 §7.5.6, cách này nối thêm các đối tượng mới và đã thay đổi sau các byte gốc và xâu chuỗi một phần tham chiếu chéo mới trở lại chúng. Làm theo cách đó, chữ ký vẫn hợp lệ cho phiên bản của nó và một trình xem báo cáo trạng thái trung thực: phiên bản đã ký vẫn nguyên vẹn, tài liệu đã được mở rộng sau đó. Thay vào đó, tuần tự hóa lại toàn bộ tệp sẽ ghi đè các đoạn đã ký, điều này phá hủy chữ ký ngay cả khi không có gì thay đổi có thể nhìn thấy. Cùng cơ chế phiên bản đó cũng là cách một tài liệu mang nhiều chữ ký: mỗi chữ ký mới rơi vào bản cập nhật gia tăng riêng của nó, và phạm vi của nó bao phủ mọi thứ trước nó, kể cả các chữ ký trước đó. Cơ chế chỉ-nối-thêm, và khi nào thì an toàn để nén gọn chúng, được trình bày trong bài viết về object stream và cập nhật gia tăng

Có hai ranh giới đáng ghi nhớ khi bạn thiết kế. Chế độ xuất PDF/A của HotPDF từ chối thẳng thừng các trường chữ ký, nên sự tuân thủ lưu trữ và một chữ ký được nhúng phải được phát hành dưới dạng các tệp riêng biệt. Và việc ký không nói lên điều gì về tính bí mật: nó chứng minh ai đã tạo ra một tài liệu và tài liệu đó chưa thay đổi kể từ đó, nhưng bất kỳ ai vẫn có thể đọc được nó. Che giấu nội dung là một công việc riêng biệt, được xử lý bởi mã hóa AES-256 và chính sách quyền hạn

Dù bạn xây dựng gì, hãy kiểm thử nó bằng một thứ gì đó khác với chính đoạn mã đã tạo ra tệp. Mở tệp đầu ra trong bảng chữ ký của Acrobat và xác nhận ba điều: chữ ký hợp lệ, danh tính xâu chuỗi đến gốc mà bạn mong đợi, và bảng điều khiển báo cáo không có thay đổi nào kể từ khi ký. Sau đó lật một byte duy nhất bên trong phạm vi đã ký của một bản sao dùng để bỏ đi và xác nhận rằng bảng điều khiển giờ đây gọi tài liệu là đã bị thay đổi. Một pipeline ký mà bạn chưa bao giờ chứng kiến nó từ chối một tệp bị giả mạo là một pipeline mà việc xác minh của nó chưa thực sự được kiểm thử

Cả ba tầng ký đều đi kèm với HotPDF Delphi Component cho Delphi và C++Builder; trang sản phẩm liên kết đến tài liệu tham khảo API chữ ký đầy đủ