PDF Library for Delphi (PDFlibPas) витягує сертифікати всередині PDF-підпису чистим проходом DER по CMS SignedData, що зберігається в /Contents, без жодного залучення CryptoAPI. З v3.539.10 кожне вкладене читання обмежене батьківським елементом, нульове доповнення після CMS обрізається на довжині, яку оголошує сам CMS, а ідентифікатори об'єктів кодують свій об'єднаний перший субідентифікатор у base-128. Правило меж і виправлення OID замінили код, який видавав неправильні відповіді без жодної помилки, а правило доповнення не дає строгішому читачеві відхиляти реальні підписи
Читальна сторона важливіша, ніж здається. Інструментам довгострокової валідації спершу треба витягнути сертифікат підписувача та його емітентів із наявного підпису, перш ніж збирати дані про відкликання, аудиторський звіт мусить вказати, хто підписав, а у збірки Lazarus на Linux немає функцій повідомлень Windows, на які можна спертися. Парсер у такій позиції рідко падає на поганому вводі. Болючий режим відмови — це кількість сертифікатів, що зарахувала байти сусіда, збіг підписувача не з тим полем чи OID, який тихо стає іншим OID. Конвеєр підписів, збудований поверх того, впевнено звітує нісенітницю
Читання сертифікатів підписувача з підписаного PDF
П'ять методів TPDFlib покривають читальну сторону, і всі вони беруть InputFile, Password, FieldName: кожен виклик відкриває файл лише для читання, відповідає і знову його закриває. GetSignatureEmbeddedCertificateCount і GetSignatureEmbeddedCertificateDER перелічують сертифікати набору в порядку кодування, GetSignatureSignerCertificateDER повертає сертифікат, який створив даний SignerInfo, а GetSignatureCertificateChainLength / GetSignatureCertificateChainDER ідуть від того підписувача до найвіддаленішого емітента, якого сам підпис несе. Індекси від нуля. Тримайте результати в AnsiString — саме тому бібліотека їх так і повертає: DER-блоб, пропущений через string чи TStrings, проходить конверсію кодування і повертається пошкодженим
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;
Дві речі в тому виводі потребують уваги. Кількість 0 — не діагноз: відсутнє поле, неправильний пароль, блоб, що не є DER, і SignedData, який просто пропускає необов'язковий набір сертифікатів, усе повертається як 0 чи порожній рядок, тож логуйте ім'я поля поруч із числом. І ланцюг, що закінчується до самовиданого сертифіката, теж не помилка. Будівник ланцюга користується лише сертифікатами, вбудованими в підпис, тож решту емітентів доведеться отримувати за адресами, які звітує GetCertificateIssuerURLs
Скільки з /Contents насправді є CMS?
CMS належить лише префікс, який оголошує зовнішній SEQUENCE, і PLTrimCMSPadding відрізає все після нього. Підписувач резервує hex-рядок /Contents ще до того, як CMS взагалі існує, бо /ByteRange, описаний в ISO 32000-1 §12.8.1, має бути зафіксований першим, тож слот роблять із запасом, а невикористаний хвіст — нулі. PLTrimCMSPadding читає перший TLV, вимагає тег $30 і повертає байти аж до кінця цього елемента; усе, що не починається з коректного SEQUENCE, повертається порожнім. Той верхній рівень — єдине місце, де хвостові байти легальні, і ця відмінність важлива для наступного розділу: строге правило «елемент мусить з'їсти весь буфер» відхилило б кожен реальний підпис, тоді як lax-правило, застосоване на кожній глибині, дозволяє вкладеним полям читати чужі байти
Чому читачеві DER потрібне кінцеве зміщення батька?
Вкладений елемент коректний, лише якщо закінчується всередині батька, і перевірка проти кінця буфера цього не доводить. Низькорівневий DERReadTLV у PDFlibASN1 обмежує кожен елемент усім рядком — це правильна перевірка для найзагальнішого об'єкта і неправильна для всього нижче. Уявіть SignerInfo, чий issuerAndSerialNumber оголошує 40 байтів, тоді як Name емітента всередині нього заявляє 60. Кожен байт досі в буфері, тож читач, обмежений буфером, приймає Name, читає серійний номер з алгоритму дайджесту, що йде далі, а потім звіряє цю пару з вбудованими сертифікатами. До v3.539.10 обхідник CMS читав саме так. Виправлення — маленька обгортка, яка проносить кінцеву позицію батька в кожне читання
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
Result := False;
// всередині батька нічого не лишилося: відмовляємося починати читання
if (Offset < 1) or (Offset >= ParentEnd) then
Exit;
if not DERReadTLV(Data, Offset, Tag, Start, Len) then
Exit;
// Offset тепер на один за елементом; він не має виходити за батька
Result := Offset <= ParentEnd;
end;
// кожен рівень записує свій кінець і передає його далі:
// 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
Юніт PDFlibCMSRead тепер проносить ті кінці крізь ContentInfo, обгортку [0] EXPLICIT, поля SignedData аж до signerInfos, SignerIdentifier в обох його формах — issuerAndSerialNumber і [0] subjectKeyIdentifier (RFC 5652 §5.3) — та поля tbsCertificate, які читаються з кожного вбудованого сертифіката під час зіставлення підписувача. Усередині набору сертифікатів і набору signerInfos елемент, що вибігає за кінець набору, зупиняє цикл: PLExtractCMSCertificates повертає сертифікати, які вже прийняла, і ніколи не приклеює наступні байти crls чи signerInfos до останнього. Збіг за issuer-and-serial вимагає обох половин, бо серійний номер унікальний лише в межах одного емітента
Чому 2.999.3 вийшло як 1.15.3?
Перші дві дуги OID поєднуються в один субідентифікатор, а не в один байт, і цей субідентифікатор кодується в base-128, як і кожна інша дуга. X.690 §8.19.4 визначає його як 40 * arc1 + arc2; ранній DER_OID писав це значення через Byte(...), що коректно лише до 127 — значення 2.47. Для 2.999 сума дорівнює 1079, приведення до байта лишає 55, а 55 декодується як 1.15, тож ідентифікатор тихо називає іншу гілку дерева. Значення від 128 до 255 ламаються інакше: видається один байт із продовжувальним бітом, який ковтає наступну дугу. Більшість PKI-ідентифікаторів (1.2.840..., 2.5.29..., 0.4.0...) межі ніколи не досягають — тому баг і вижив; дуги joint-iso-itu-t від 2.48 вгору — досягають. DER_OID обслуговує і кодувальник signed attributes, і пошук у DERFindExtensionByOID, і перевірку content-type SignedData, тож неправильне кодування ламало і запис, і зчитування
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.
Об'єднане значення тримається в UInt64 невипадково. DER_OID розбирає дуги в Int64, тож легальна друга дуга може бути завбільшки з Int64.MaxValue, і додавання 80 для arc1 = 2 переповнює знакове 64-бітне ціле. UInt64 несе Int64.MaxValue + 80 без загортання, а десятцифровий робочий буфер вміщує десять 7-бітних груп, які потрібні 64-бітному значенню. Тестові вектори, які варто тримати, — ті, що по обидва боки межі: 2.47 мусить лишитися одним байтом, а 2.48 мусить стати двома
Що гарантує читальний обхідник CMS?
PDFlibCMSRead гарантує структуру і нічого більше: він повертає байти, які лежать там, де каже RFC 5652, і не перевіряє ані підпис, ані дайджест, ані строк дії. Обхідник приймає лише DER, тож DERReadTLV відхиляє невизначені довжини та багатобайтові номери тегів, а BER-закодований CMS від неконформного підписувача звітує нуль сертифікатів замість часткової здогадки. Атрибутні сертифікати та інші альтернативи CertificateChoices пропускаються, бо ніщо нижче за течією не може їх ужити. Криптографічна перевірка лишається коду, якому вона належить: вона починається з перевірок покриття байтів, описаних у PAdES-підписанні та валідації ByteRange у Delphi, і продовжується класифікацією того, що змінилося після підписання PDF
Ширший урок стосується будь-якого бінарного формату: «всередині буфера» — властивість безпеки пам'яті, «всередині батька» — властивість коректності, і парсеру потрібні обидві. Та сама думка про ворожі довжини проходить крізь загартування Pascal-парсера PDF проти зловмисних файлів. API витягання сертифікатів, побудови ланцюга та довгострокової валідації, про які тут ідеться, постачаються з losLab PDF Library for Delphi — для Delphi, C++Builder і Lazarus