Техническая статья

Разбор CMS-подписи в Delphi: границы DER и дуги OID

PDF Library for Delphi (PDFlibPas) извлекает сертификаты из PDF-подписи чистым обходом DER по CMS SignedData, лежащему в /Contents, без какого-либо CryptoAPI. Начиная с v3.539.10 каждое вложенное чтение ограничено родительским элементом, нулевое заполнение после CMS обрезается по длине, которую объявляет сам CMS, а объектные идентификаторы кодируют объединённый первый подыидентификатор в base-128. Правило границ и починка OID заменили код, который молча выдавал неверные ответы, а правило заполнения не даёт более строгому читателю браковать настоящие подписи

Читающая сторона важнее, чем кажется. Инструментам long-term validation сперва надо вытащить сертификат подписавшего и его издателей из уже существующей подписи, прежде чем качать данные об отзыве, аудиторскому отчёту надо сказать, кто подписал, а сборке 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, возвращается пустым. Верхний уровень — единственное место, где хвостовые байты легальны, и это различие важно для следующего раздела: строгое правило «элемент должен съесть весь буфер» отвергло бы каждую настоящую подпись, а вольное правило, применённое на каждой глубине, позволяет вложенным полям читать чужие байты

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

Зачем DER-читателю конечное смещение родителя?

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

PDFlibPas ограничивает каждое вложенное DER-чтение родительским элементом: 60-байтный issuer Name внутри 40-байтного issuerAndSerialNumber старый DERReadTLV с границами по буферу принимает и читает серийный номер из digestAlgorithm, а ReadTLVWithin отвергает любой элемент, кончающийся дальше ParentEnd
Внутри буфера — memory safety, внутри родителя — корректность: 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    := конец ContentInfo         (RFC 5652 section 3)
//   ExplicitEnd := конец content [0] EXPLICIT
//   ContentEnd  := конец SignedData          (RFC 5652 section 5.1)
//   SignerEnd / InnerEnd для SignerInfo и 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.
DER_OID в PDFlibPas складывает первые две дуги 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

Более общий урок переносится на любой бинарный формат: «внутри буфера» — свойство memory safety, «внутри родителя» — свойство корректности, и парсеру нужны оба. Та же мысль о враждебных длинах проходит через статью об укреплении Pascal-парсера PDF против злонамеренных файлов. Извлечение сертификатов, построение цепочки и API long-term validation, о которых идёт речь, поставляются с losLab PDF Library for Delphi — для Delphi, C++Builder и Lazarus