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
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
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.
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