Bài viết kỹ thuật

Xác thực chuỗi bản sửa đổi PDF MAC trong Delphi (ISO 32004)

HotPDF xác thực PDF MAC ISO/TS 32004 theo từng bản sửa đổi chứ không theo tệp. THotPDF.ValidatePDFMACChain đi qua mọi incremental update tính từ anchor của chuỗi trở đi và xác minh từng MAC dựa trên một prefix stream chỉ đọc kết thúc tại startxref%%EOF của chính bản sửa đổi đó. Một MAC hợp lệ trên bản sửa đổi mới nhất chẳng chứng minh gì về các bản bên dưới

Đây là kịch bản thúc đẩy tất cả những điều này. Bạn giao một PDF mã hoá AES-256 kèm PDF MAC. Ai đó mở tệp trong một hex editor, lật một byte bên trong bản sửa đổi đầu tiên được MAC bảo vệ, rồi gắn thêm một bản sửa đổi hoàn toàn mới mang một MAC hợp lệ tuyệt đối của chính họ. Mọi viewer mở tệp không một lời phàn nàn, và một trình kiểm tra ngây thơ băm dải byte hiện tại đối chiếu với MAC trong trailer đang hoạt động sẽ báo thành công — vì MAC đó thực sự đúng với các byte nó phủ. Thiệt hại nằm cách hai bản sửa đổi, ở một vùng không ai soát lại

Vì sao một MAC cấp cao nhất hợp lệ lại không chứng minh tệp còn nguyên vẹn?

Vì một PDF MAC phủ một tiền tố, chứ không phủ một tài liệu. Incremental update là một phần hạng nhất của định dạng: mỗi lần lưu nối thêm một body mới, một phần cross-reference mới và một trailer mới, trong khi các byte cũ nằm nguyên tại chỗ. ISO/TS 32004 bám trên mô hình đó, nên mỗi bản sửa đổi mang dictionary /AuthCode riêng xác thực tệp đúng như trạng thái tại thời điểm đó, và chỉ xác minh bản mới nhất là bỏ mặc mọi bản sửa đổi trước đó không hề được soát. HotPDF vì thế bộc lộ hai câu hỏi đó thành hai lời gọi, và sự khác biệt giữa chúng chính là toàn bộ ý nghĩa của bài viết. ValidatePDFMAC trả lời "bản sửa đổi hiện tại có xác thực không", điền một record THPDFPDFMACValidationInfo; ValidatePDFMACChain trả lời "mọi bản sửa đổi được MAC bảo vệ trong tệp này có xác thực không", điền THPDFPDFMACChainValidationInfo với một mảng theo từng bản sửa đổi cùng một lý do thất bại máy đọc được. Trên tệp bị can thiệp rồi gắn MAC lại ở trên, lời gọi đầu trả về True còn lời gọi sau trả về False tại chỉ số bản sửa đổi 1

var
  Pdf: THotPDF;
  Chain: THPDFPDFMACChainValidationInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
    begin
      // Thất bại là một trong pmcfRevisionBoundary, pmcfNoPDFMAC,
      // pmcfRequiredRevisionMissing, pmcfRevisionInvalid,
      // pmcfKDFSaltChanged, pmcfDigestDowngrade, pmcfPermissionDowngrade
      Writeln('chain rejected: ', Chain.Message);
      Writeln('revision ', Chain.FailureRevisionIndex,
              ' at xref offset ', Chain.FailureXRefOffset);
      Exit;
    end;
    for I := 0 to High(Chain.Revisions) do
      Writeln(I, ' len=', Chain.Revisions[I].RevisionLength,
              ' mac=', Chain.Revisions[I].HasPDFMAC,
              ' perms=', Chain.Revisions[I].PermissionsAuthenticated);
  finally
    Pdf.Free;
  end;
end;

Mỗi MAC được xác minh trên prefix stream riêng của nó, không bao giờ trên độ dài tệp cuối

Lỗi đắt giá nhất trong lĩnh vực này là dùng kích thước tệp cuối làm cận trên khi băm lại một bản sửa đổi cũ hơn, điều này gộp các byte đuôi vào mọi digest trừ bản mới nhất và báo giả mạo trên một tệp lành lặn. HotPDF thay vào đó dựng lại, cho từng bản sửa đổi, một stream chỉ đọc có biên kết thúc tại giá trị startxref của chính bản sửa đổi đó theo sau là %%EOF của nó, và chỉ băm đúng phần đó. Việc định vị ranh giới khó tính hơn vẻ ngoài của nó: literal %%EOF có thể xuất hiện bên trong một content stream hoặc một chuỗi, nên một ứng viên chỉ được chấp nhận khi startxref ngay trước nó phân tích ra một số bằng đúng offset cross-reference của phần đang được xác minh, và giữa chúng không có gì ngoài khoảng trắng. Bản sửa đổi sau đó hấp thụ đúng một chuỗi kết thúc dòng sau marker — một CR đơn, một LF đơn, hoặc một cặp CRLF — và không hơn. Luật cuối đó cắn thật trong thực tế, vì một writer phát hành thêm một dòng trống giữa hai bản sửa đổi đã sinh ra các byte thuộc về bản sửa đổi tiếp theo, và nuốt toàn bộ khoảng trắng đuôi vào bản trước sẽ lặng lẽ đổi cả hai digest. Việc liệt kê các phần theo cùng kỷ luật đó: HotPDF đi qua các phần cross-reference từ cũ đến mới đúng một lần, phát lại các entry free, direct và object-stream để phần sau ghi đè trạng thái phần trước, ngược với ngữ nghĩa người-thắng-là-người-được-thấy-trước mà một parser active-xref áp dụng

HotPDF xác minh từng PDF MAC ISO 32004 dựa trên một prefix stream kết thúc tại startxref và marker kết thúc tệp của chính bản sửa đổi đó, nên một byte bị lật bên trong bản sửa đổi 1 làm gãy chuỗi dù MAC mới nhất vẫn xác minh sạch sẽ
MAC của mỗi bản sửa đổi được băm lại trên tiền tố có biên của chính nó, nên sửa bản sửa đổi 1 rồi gắn thêm một bản sửa đổi mới-MAC-hoàn-toàn vẫn làm ValidatePDFMAC hài lòng trong khi ValidatePDFMACChain đáp xuống bản sửa đổi 1

Chuỗi neo ở đâu, và cái gì làm gãy nó?

Bản sửa đổi đầu tiên mang /AuthCode hợp lệ là anchor, và FirstMACRevisionIndex báo chỗ bảo vệ bắt đầu; mọi thứ trước nó không được bảo vệ do cấu trúc, điều đó là bình thường. Mọi thứ sau nó phải được MAC bảo vệ, nên nối thêm một incremental update trơn vào một tệp được MAC bảo vệ sẽ fail với pmcfRequiredRevisionMissing cùng chỉ số bản sửa đổi vi phạm — dung cho một khoảng trống sẽ để kẻ tấn công lột bỏ bảo vệ chỉ bằng cách lưu thêm một lần nữa. Ba bất biến nữa giữ vững dọc chuỗi, mỗi bất biến mang một mã thất bại riêng

  • pmcfKDFSaltChanged/KDFSalt phải giữ ổn định tính từ anchor trở đi, vì một salt xoay vòng sẽ cho kẻ giả mạo tái sinh khoá dưới các tham số do họ tự chọn
  • pmcfDigestDowngrade — độ mạnh của digest được so với MAC đã xác minh cuối cùng chứ không phải bản sửa đổi liền trước, nên một chuỗi khởi đầu dưới profile Modern ở SHA-384 không thể lặng lẽ tiếp tục với SHA-256
  • pmcfPermissionDowngrade — một bản sửa đổi không được xoá bỏ yêu cầu PDF MAC mà một bản sửa đổi trước đó đã xác thực

Hệ quả đáng ghi nhớ là các MAC lịch sử được xác minh độc lập kể cả khi chúng không còn là trailer đang hoạt động. Đó là lý do cuộc tấn công sửa-một-bản-sửa-đổi-cũ-rồi-gắn-một-MAC-mới ở phần mở đầu không sống sót: MAC mới nhất tự kiểm tra đạt, ValidatePDFMAC hài lòng, và chuỗi vẫn đáp xuống bản sửa đổi 1 với pmcfRevisionInvalid

Thứ tự chữ ký: khoá trailer trước, signatureDigest sau

Khi MAC gắn vào một chữ ký CMS thay vì đứng độc lập, thứ tự ghi ngừng là câu hỏi phong cách. HotPDF yêu cầu /AuthCode, /KDFSalt, developer extension ISO 32004 và /SigObjRef được ghi vào cùng bản sửa đổi trước khi /ByteRange của chữ ký được tính; nối thêm bất kỳ cái nào sau đó và các byte đó rơi ra ngoài dải mà chữ ký phủ, sinh ra một tệp mà chữ ký xác minh được trong khi ràng buộc MAC thì không được ký. Hai digest sau đó chạy theo hướng ngược lại, nhìn thoáng thì tưởng vòng tròn mà không phải. signatureDigest của PDF MAC ràng buộc các content octet thô của CMS SignerInfo.signature OCTET STRING — không phải toàn bộ CMS DER, và không phải các signed attribute — nên nó được dựng sau khi giá trị chữ ký thô tồn tại và được tiêm như một unsigned attribute id-attr-pdfMacData. Vì /Contents bị loại khỏi ByteRange của chữ ký và unsigned attribute không bao giờ tham gia tính toán chữ ký, chuỗi sinh-chữ-ký, dựng-MAC, bọc-CMS khép lại sạch sẽ mà không có vòng lặp mật mã. Hai hệ luận đi theo: sentinel /ByteRange và placeholder /Contents phải giữ dạng plaintext và nằm ngoài object stream kể cả trong tệp mã hoá, nếu không bộ vá bề rộng cố định không thể tìm thấy chúng; và khi digest MAC cũng là SHA-256 thì digest ký được dùng lại nguyên vẹn, nếu không cả hai ngữ cảnh digest đều được cập nhật trong một lượt đi qua output stream

Thứ tự ghi của HotPDF cho một PDF MAC gắn vào chữ ký CMS: các khoá MAC vào bản sửa đổi trước khi ByteRange được đo, và digest chữ ký được dựng sau đó từ các octet chữ ký SignerInfo thô
Việc ghi AuthCode, KDFSalt, SigObjRef và developer extension trước khi ByteRange được đo chính là điều giữ ràng buộc MAC nằm trong dải mà chữ ký phủ
var
  Pdf: THotPDF;
  Options: THPDFPDFMACOptions;
  Info: THPDFPDFMACValidationInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'unsigned.pdf';
    Pdf.ActivateProtection := True;
    Pdf.CryptKeyLength := aesgcm;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.ProtectOptions := [prPrint, prExtractContent];
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.AddSignedSignatureField('Approval',
      Rect(72, 120, 280, 160), 16384);
    Pdf.EndDoc;

    Options := THPDFPDFMACOptions.Modern;      // digest tài liệu SHA-384
    if THotPDF.SignPDFWithPFXAndAttachedPDFMAC('unsigned.pdf',
         'signed.pdf', 'signer.pfx', 'pfx-secret', 'user', Options) then
      if Pdf.ValidatePDFMAC('signed.pdf', 'user', Options, Info) then
      begin
        // Location = pmlAttachedToSignature, và hai digest
        // được báo riêng
        Writeln('signature object  : ', Info.SignatureObjectNumber);
        Writeln('signature digest  : ', Info.SignatureDigestMatched);
        Writeln('full file coverage: ', Info.FullFileCoverage);
        Writeln('perms authentic   : ', Info.PermissionsAuthenticated);
      end;
  finally
    Pdf.Free;
  end;
end;

Xác minh đi lại cùng con đường đó từ đầu kia: đọc /AuthCode trực tiếp từ trailer cross-reference cổ điển đang hoạt động, đi theo /SigObjRef indirect có nhận biết generation, xác nhận nó ràng buộc /V của trường chữ ký duy nhất, và báo một thất bại digest tài liệu tách bạch với một thất bại digest chữ ký. Đó là hai chẩn đoán khác nhau, và gộp chúng thành một boolean duy nhất vứt bỏ thông tin duy nhất cho biết nội dung trang hay giá trị chữ ký đã bị đụng tới. Nếu bạn đang làm việc với CMS, nội dung này nằm cạnh bài viết ký PAdEShướng dẫn xác minh chữ ký trong tài liệu đã nạp

Đừng bao giờ tin /P: giải mã /Perms 16 byte trước

ISO/TS 32004 phát tín hiệu "tài liệu này yêu cầu một PDF MAC" qua bit quyền 13, và cách đọc hiển nhiên là cách đọc sai, vì số nguyên /P trong dictionary mã hoá là plaintext và chưa được xác thực — ai đó cũng có thể lật bit đó trong một text editor và hạ cấp yêu cầu. ISO 32000-2 §7.6 đưa ra câu trả lời trong entry /Perms, và HotPDF dùng nó: giải mã chuỗi /Perms 16 byte bằng khoá mã hoá tệp dưới AES-256 CBC, IV không, không padding, rồi soát mọi trường của plaintext trước khi tin bất cứ điều gì. Byte 1 đến 4 giữ giá trị quyền theo thứ tự little-endian và phải bằng đúng số nguyên /P; byte 5 đến 8 là 0xFF; byte 9 là cờ mã hoá metadata T hay F; byte 10 đến 12 là marker literal adb. Chỉ khi tất cả điều đó đạt được thì PermissionsAuthenticated mới thành True và bit 13 mới được đọc — và lưu ý cực tính của nó, vì yêu cầu MAC được khẳng định khi bit 0x1000 . Một sự không khớp giữa /P và các quyền đã giải mã không phải một cảnh báo để ghi log rồi đi tiếp; đó là một tập quyền bị giả mạo, và phản ứng đúng là fail closed

HotPDF xác thực quyền PDF bằng cách giải mã chuỗi Perms mười sáu byte bằng khoá mã hoá tệp và soát giá trị quyền little-endian, các byte đệm FF, cờ metadata và marker adb trước khi đọc bit 13
Số nguyên /P dạng plaintext chưa được xác thực, nên yêu cầu PDF MAC chỉ được đọc sau khi mọi trường của /Perms đã giải mã được soát

Tính linh hoạt thuật toán dừng ở digest

ISO/TS 32004 cho bạn chọn digest tài liệu, và chỉ digest tài liệu. HotPDF giữ HMAC-SHA-256 cho xác thực, HKDF-SHA-256 theo RFC 5869 cho sinh khoá và AES-256 key wrap theo RFC 3394 cố định bên dưới một biến THPDFPDFMACDigestAlgorithm trải từ pmdaSHA256 đến pmdaSHA3_512, vì sai lầm tự nhiên là coi một "profile SHA3-512" như giấy phép để đổi luôn HMAC, thứ sinh ra một tệp không còn là PDF MAC theo bất kỳ nghĩa tương tác nào. Một chi tiết triển khai đáng chép lại nếu bạn tự viết verifier: đọc OID digest ra khỏi AuthenticatedData của CMS trước khi băm dải byte, vì hardcoded SHA-256 rồi đối chiếu sau biến tính linh hoạt thành một cái nhãn và để một tệp thù địch khiến bạn stream toàn bộ tài liệu trước khi phát hiện thuật toán chưa bao giờ được hỗ trợ. CMSAlgorithmProtection, thuật toán digest của AuthenticatedData, messageDigest của integrity-info và digest dải byte phải cùng nêu một thuật toán, và bất kỳ bất đồng nào đều fail closed

var
  Options: THPDFPDFMACOptions;
begin
  Options := THPDFPDFMACOptions.Compatibility;  // SHA-256, chấp nhận cả sáu
  Options := THPDFPDFMACOptions.Modern;         // SHA-384, từ chối 256-bit
  Options := THPDFPDFMACOptions.HighAssurance;  // chỉ SHA3-512, AES-GCM

  // Một profile tuỳ biến là hợp lệ, nhưng thuật toán nó dùng để sinh
  // phải cũng xuất hiện trong allowlist xác thực, nếu không cấu hình
  // bị từ chối trước khi một byte nào được ghi
  Options.Profile := pmppCustom;
  Options.DigestAlgorithm := pmdaSHA512;
  Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
  Options.RequireAESGCM := True;
end;

Một PDF MAC chứng minh điều gì và không chứng minh điều gì

Một chuỗi PDF MAC đã xác minh chứng minh rằng mọi bản sửa đổi được bảo vệ đều giống hệt từng byte với những gì được ghi bởi người giữ khoá mã hoá tệp, rằng không bản sửa đổi được bảo vệ nào bị bỏ đi hay xáo trộn thứ tự, và rằng không bản sửa đổi không được bảo vệ nào được nối vào sau anchor — chính là lớp tấn công mà một mã hoá AES-256 thuần để hở, vì bảo mật không nói gì về tính toàn vẹn và một PDF mã hoá có một bản sửa đổi bị cắm ghép giải mã thoải mái không kém một bản nguyên vẹn. Điều nó không chứng minh là tác giả. Khoá MAC sinh ra từ khoá mã hoá tệp, nên bất kỳ ai mở được tài liệu cũng sinh được một MAC hợp lệ trên một phiên bản đã sửa, kể cả mọi người nhận chính đáng; đó là một primitive đối xứng, và primitive đối xứng không quy kết được ai. Nếu bạn cần biết ai đã đổi một thứ gì đó, bạn cần một chữ ký số có chứng thư chống lưng, và PDF MAC khi đó bổ trợ nó bằng cách bảo vệ cấu trúc incremental mà một mình chữ ký không phủ. Hãy coi chúng là các tầng và để hai phán quyết được báo cáo độc lập thay vì gộp vào một icon trạng thái

Các entry point PDF MAC được mô tả ở đây — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMACValidatePDFMACChain — đi kèm với HotPDF Delphi Component chuẩn cho Delphi và C++Builder, nơi trang sản phẩm mang tài liệu tham khảo đầy đủ cho record tuỳ chọn, các enumeration trạng thái và mảng xác thực theo từng bản sửa đổi