Bài viết kỹ thuật

Phân tích chữ ký CMS trong Delphi: biên DER và arc OID

PDF Library for Delphi (PDFlibPas) trích các chứng chỉ nằm trong một chữ ký PDF bằng một phép đi DER thuần túy trên CMS SignedData được lưu trong /Contents, chẳng dính dáng gì tới CryptoAPI. Kể từ v3.539.10, mọi phép đọc lồng nhau đều bị phần tử cha chặn biên, phần đệm zero sau CMS bị cắt tại đúng độ dài CMS khai báo, còn object identifier mã hóa subidentifier đầu tiên gộp lại của nó theo base-128. Cả luật biên lẫn bản sửa OID đều thay thế những đoạn code cho ra đáp án sai mà không ném lỗi, còn luật phần đệm giữ cho bộ đọc khắt khe hơn không từ chối các chữ ký thật

Phía đọc quan trọng hơn bề ngoài của nó. Tooling long-term validation phải kéo chứng chỉ người ký cùng các issuer của nó ra khỏi một chữ ký có sẵn trước khi lấy được dữ liệu thu hồi, một báo cáo audit phải nói ra ai đã ký, còn một bản build Lazarus trên Linux chẳng có hàm message nào của Windows để dựa vào. Parser ở vị trí đó hiếm khi văng trên input xấu. Chế độ thất bại đau nhất là một con số đếm chứng chỉ lỡ kèm cả byte của người hàng xóm, một lần khớp signer nhầm sang trường khác, hay một OID lặng lẽ biến thành một OID khác. Pipeline chữ ký dựng trên nền đó sẽ báo lên những điều vô nghĩa đầy tự tin

Đọc các chứng chỉ người ký ra khỏi một PDF đã ký

Năm phương thức của TPDFlib phủ kín phía đọc, và tất cả đều nhận InputFile, Password, FieldName: mỗi lệnh gọi mở tệp ở chế độ chỉ-đọc, trả lời rồi đóng lại. GetSignatureEmbeddedCertificateCount và GetSignatureEmbeddedCertificateDER liệt kê tập chứng chỉ theo thứ tự encoding, GetSignatureSignerCertificateDER trả về chứng chỉ đã sinh ra một SignerInfo cho trước, còn GetSignatureCertificateChainLength / GetSignatureCertificateChainDER đi từ signer đó về phía issuer xa nhất mà chính chữ ký mang theo. Index tính từ 0. Hãy giữ kết quả trong AnsiString, và thư viện trả về theo cách đó cũng là vì thế: một blob DER đi qua string hay TStrings sẽ xuyên qua một bước chuyển bảng ký tự và quay về trong tình trạng hỏng

uses
  SysUtils, Classes, PDFlibrary;

procedure SaveDer(const FileName: string; const Der: AnsiString);
var
  Fs: TFileStream;
begin
  Fs := TFileStream.Create(FileName, fmCreate);
  try
    if Der <> '' then
      Fs.WriteBuffer(Der[1], Length(Der));
  finally
    Fs.Free;
  end;
end;

const
  Src = 'contract-signed.pdf';
  Field = 'Signature1';
var
  Pdf: TPDFlib;
  ChainLen, I: Integer;
  SignerDer, LastDer: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    WriteLn('Certificates in the CMS: ',
      Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
    SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
    if SignerDer = '' then
      raise Exception.Create('signer certificate missing or not matched');
    SaveDer('signer.cer', SignerDer);

    ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
    for I := 0 to ChainLen - 1 do
      SaveDer(Format('chain-%d.cer', [I]),
        Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
    if ChainLen > 0 then
    begin
      LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
      WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
    end;
  finally
    Pdf.Free;
  end;
end;

Hai điều trong output đó cần để tâm. Số đếm 0 không phải là một chẩn đoán: trường bị thiếu, mật khẩu sai, một blob chẳng phải DER, và một SignedData đơn thuần bỏ qua tập certificates tùy chọn đều trả về 0 hay chuỗi rỗng, nên hãy log tên trường ngay cạnh con số. Và một chain kết thúc trước khi chạm tới chứng chỉ tự cấp cũng không phải là lỗi. Bộ dựng chain chỉ dùng các chứng chỉ nhúng trong chữ ký, nên các issuer còn lại phải được lấy qua các địa chỉ mà GetCertificateIssuerURLs báo

/Contents thực sự có bao nhiêu phần là CMS?

Chỉ có phần prefix mà SEQUENCE ngoài cùng khai báo mới thuộc về CMS, và PLTrimCMSPadding cắt bỏ mọi thứ nằm sau nó. Người ký đặt chỗ chuỗi hex /Contents trước khi CMS tồn tại, vì /ByteRange mô tả trong ISO 32000-1 §12.8.1 phải được cố định trước, nên slot được chừa rộng rãi và phần đuôi chưa dùng toàn là zero. PLTrimCMSPadding đọc TLV đầu tiên, đòi tag $30, và trả về các byte tính tới hết phần tử đó; bất cứ thứ gì không mở đầu bằng một SEQUENCE dạng chuẩn đều trả về rỗng. Tầng trên cùng là nơi duy nhất byte thừa ở đuôi là hợp pháp, và sự phân biệt ấy quan trọng cho mục kế tiếp: một luật khắt khe kiểu "phần tử phải ăn trọn buffer" sẽ chối bỏ mọi chữ ký ngoài đời thực, trong khi một luật lỏng lẻo áp dụng ở mọi tầng lại cho các trường lồng nhau đọc những byte không thuộc về mình

PDFlibPas PLTrimCMSPadding đọc TLV đầu tiên của chuỗi hex /Contents đặt chỗ, đòi tag $30 và cắt phần đệm zero tại độ dài SEQUENCE ngoài cùng khai báo, trả về kết quả rỗng khi buffer không mở đầu bằng một SEQUENCE dạng chuẩn
Byte thừa ở đuôi chỉ hợp pháp ở tầng trên cùng, nơi slot đặt chỗ phải đứng yên để giữ /ByteRange — các phép đọc sâu hơn chịu luật bị cha chặn biên

Vì sao một bộ đọc DER cần offset kết thúc của cha?

Một phần tử lồng nhau chỉ hợp lệ nếu nó kết thúc bên trong cha của nó, và kiểm tra với điểm cuối của buffer không chứng minh được điều đó. DERReadTLV cấp thấp trong PDFlibASN1 chặn biên mỗi phần tử theo toàn bộ chuỗi, là phép kiểm tra đúng cho object ngoài cùng và sai cho mọi thứ bên dưới nó. Hãy hình dung một SignerInfo mà issuerAndSerialNumber của nó khai báo 40 byte trong khi issuer Name bên trong lại đòi 60. Vẫn mọi byte đều nằm trong buffer, nên một bộ đọc chặn theo buffer chấp nhận Name, đọc số serial từ digest algorithm nằm ngay sau đó, rồi đem cặp này so với các chứng chỉ nhúng. Trước v3.539.10, walker CMS đọc đúng theo cách đó. Bản sửa là một wrapper nhỏ mang vị trí kết thúc của cha vào trong mọi phép đọc

PDFlibPas chặn biên mọi phép đọc DER lồng nhau bằng phần tử cha: một issuer Name 60 byte nằm trong issuerAndSerialNumber 40 byte được bộ đọc cũ DERReadTLV chặn theo buffer chấp nhận, rồi đọc số serial từ digestAlgorithm, trong khi ReadTLVWithin từ chối mọi phần tử kết thúc vượt quá ParentEnd
Trong buffer là memory safety, trong cha mới là đúng đắn — PDFlibPas xuyên truyền offset kết thúc của cha qua mọi tầng CMS để một độ dài ác ý không thể mượn byte của hàng xóm
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // chẳng còn gì bên trong cha: từ chối bắt đầu một phép đọc
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset giờ nằm ngay sau phần tử; nó không được vượt qua cha
  Result := Offset <= ParentEnd;
end;

// mỗi tầng tự ghi lại điểm kết thúc của mình và trao xuống:
//   OuterEnd    := end of ContentInfo         (RFC 5652 section 3)
//   ExplicitEnd := end of content [0] EXPLICIT
//   ContentEnd  := end of SignedData          (RFC 5652 section 5.1)
//   SignerEnd / InnerEnd for SignerInfo and issuerAndSerialNumber

Unit PDFlibCMSRead giờ xuyên truyền các điểm kết thúc ấy qua ContentInfo, lớp bọc [0] EXPLICIT, các trường SignedData tính tới signerInfos, SignerIdentifier dưới cả hai dạng issuerAndSerialNumber lẫn [0] subjectKeyIdentifier (RFC 5652 §5.3), và các trường tbsCertificate đọc từ từng chứng chỉ nhúng khi khớp signer. Bên trong tập certificates và tập signerInfos, một phần tử chạy tràn qua điểm kết thúc của tập sẽ chặn đứng vòng lặp: PLExtractCMSCertificates trả về những chứng chỉ nó đã chấp nhận và chẳng bao giờ dán các byte crls hay signerInfos kế tiếp vào cái cuối. Phép khớp issuer-and-serial cũng đòi hỏi cả hai nửa, vì một số serial chỉ duy nhất trong phạm vi một issuer

Vì sao 2.999.3 lại ra thành 1.15.3?

Hai arc đầu tiên của một OID gộp thành một subidentifier chứ không phải một byte, và subidentifier đó được mã hóa base-128 như mọi arc khác. X.690 §8.19.4 định nghĩa nó là 40 * arc1 + arc2; bản DER_OID trước đây ghi giá trị này bằng Byte(...), thứ chỉ đúng tới 127, tức giá trị của 2.47. Với 2.999 thì tổng là 1079, phép ép byte giữ lại 55, mà 55 giải mã thành 1.15, nên identifier lặng lẽ gọi tên một nhánh khác của cây. Các giá trị từ 128 tới 255 hỏng theo kiểu khác: phát một byte có bit continuation bật, nuốt luôn arc kế tiếp. Đa số identifier PKI (1.2.840..., 2.5.29..., 0.4.0...) chẳng bao giờ chạm tới biên giới, nên lỗi mới sống sót đến nay; còn các arc joint-iso-itu-t từ 2.48 trở lên thì có. DER_OID phục vụ cả encoder cho signed attributes lẫn matcher trong DERFindExtensionByOID và phép kiểm content-type của SignedData, nên một encoding sai làm hỏng cả việc ghi lẫn tra cứu

uses
  SysUtils, PDFlibASN1;

function Hex(const S: AnsiString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
  Result := Trim(Result);
end;

begin
  WriteLn(Hex(DER_OID('2.999.3')));               // 06 03 88 37 03
  WriteLn(Hex(DER_OID('2.47.1')));                // 06 02 7F 01
  WriteLn(Hex(DER_OID('2.48.1')));                // 06 03 81 00 01
  WriteLn(Hex(DER_OID('2.5.29.14')));             // 06 03 55 1D 0E
  WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2')));  // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
PDFlibPas DER_OID gộp hai arc OID đầu tiên thành 40 * arc1 + arc2 và mã hóa base-128 tổng đó trong một UInt64, nên 2.999.3 trở thành 06 03 88 37 03, trong khi phép ép Byte cũ giữ lại 55 và lặng lẽ giải mã identifier thành 1.15.3
Đa số arc PKI chẳng bao giờ chạm tới biên giới, nên bug mới sống sót — các arc joint-iso-itu-t từ 2.48 trở lên cần hai byte, và bộ test đặt 2.47 cùng 2.48 ở hai bên

Giá trị gộp được giữ trong một UInt64 là có chủ đích. DER_OID parse các arc vào Int64, nên arc thứ hai hợp lệ có thể lớn tới Int64.MaxValue, và cộng thêm 80 cho arc1 = 2 là tràn số nguyên 64-bit có dấu. UInt64 nâng Int64.MaxValue + 80 mà không wrap, còn buffer scratch mười byte chứa được mười nhóm 7-bit mà một giá trị 64-bit cần. Những test vector đáng giữ lại là các vector nằm hai bên biên giới: 2.47 phải đứng nguyên một byte, và 2.48 phải thành hai

Walker CMS phía đọc đảm bảo điều gì?

PDFlibCMSRead đảm bảo cấu trúc và chỉ có vậy: nó trả về các byte nằm đúng chỗ RFC 5652 nói chúng phải nằm, và không xác thực bất kỳ chữ ký, digest hay thời hạn hiệu lực nào. Walker chỉ chấp nhận DER, nên DERReadTLV từ chối độ dài indefinite và số tag nhiều byte, còn một CMS mã hóa BER từ một signer không tuân thủ sẽ báo không có chứng chỉ nào thay vì phỏng đoán nửa vời. Attribute certificate và các phương án CertificateChoices khác bị bỏ qua vì chẳng gì phía hạ lưu dùng được chúng. Xác thực mật mã vẫn ở lại với đoạn code sở hữu việc đó, bắt đầu từ các phép kiểm phủ byte được mô tả trong ký PAdES và xác thực ByteRange trong Delphi và tiếp nối bằng phân loại những gì thay đổi sau khi một PDF được ký

Bài học rộng hơn áp dụng cho mọi định dạng nhị phân: "trong buffer" là tính chất memory-safety, "trong cha" là tính chất đúng đắn, và một parser cần cả hai. Tư duy về độ dài ác ý ấy chạy xuyên suốt củng cố một parser PDF viết bằng Pascal trước các tệp ác ý. Các API trích chứng chỉ, dựng chain và long-term validation bàn trong bài đi kèm losLab PDF Library for Delphi, cho Delphi, C++Builder và Lazarus