Технічна стаття

Розбір CMS-підпису PDF у Delphi: межі DER і дуги OID

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-правило, застосоване на кожній глибині, дозволяє вкладеним полям читати чужі байти

PDFlibPas PLTrimCMSPadding читає перший TLV зарезервованого hex-рядка /Contents, вимагає тег $30 і обрізає нульове доповнення на довжині, яку оголошує зовнішній SEQUENCE, а повертає порожній результат, коли буфер не починається з коректного SEQUENCE
Хвостові байти легальні лише на верхньому рівні, де зарезервований слот мусить лишатися фіксованим для /ByteRange — глибші читання отримують натомість правило меж батька

Чому читачеві DER потрібне кінцеве зміщення батька?

Вкладений елемент коректний, лише якщо закінчується всередині батька, і перевірка проти кінця буфера цього не доводить. Низькорівневий DERReadTLV у PDFlibASN1 обмежує кожен елемент усім рядком — це правильна перевірка для найзагальнішого об'єкта і неправильна для всього нижче. Уявіть SignerInfo, чий issuerAndSerialNumber оголошує 40 байтів, тоді як Name емітента всередині нього заявляє 60. Кожен байт досі в буфері, тож читач, обмежений буфером, приймає Name, читає серійний номер з алгоритму дайджесту, що йде далі, а потім звіряє цю пару з вбудованими сертифікатами. До v3.539.10 обхідник CMS читав саме так. Виправлення — маленька обгортка, яка проносить кінцеву позицію батька в кожне читання

PDFlibPas обмежує кожне вкладене читання DER батьківським елементом: 60-байтовий Name емітента всередині 40-байтового issuerAndSerialNumber старий обмежений буфером DERReadTLV приймає, а потім читає серійний номер з digestAlgorithm, тоді як ReadTLVWithin відмовляє будь-якому елементу, що закінчується за ParentEnd
Всередині буфера — безпека пам'яті, всередині батька — коректність: PDFlibPas проносить кінцеве зміщення батька крізь кожен рівень 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.
PDFlibPas DER_OID поєднує перші дві дуги OID як 40 * arc1 + arc2 і кодує суму в base-128 у UInt64, тож 2.999.3 стає 06 03 88 37 03, тоді як старе приведення до Byte лишало 55 і тихо декодувало ідентифікатор як 1.15.3
Більшість дуг PKI межі ніколи не досягає, тому баг і вижив — дугам joint-iso-itu-t від 2.48 вгору потрібно два байти, а тест тримає 2.47 і 2.48 по обидва боки

Об'єднане значення тримається в 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