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

CMS подписи в Delphi: DER граници и OID клонове

PDF Library for Delphi (PDFlibPas) извлича сертификатите вътре в PDF подписа с чисто DER обхождане на CMS SignedData, съхранен в /Contents, без никакво участие на CryptoAPI. От v3.539.10 насам всяко вложено четене е ограничено от родителския си елемент, нулевият padding след CMS-а се отрязва на дължината, която CMS-ът декларира, а object identifier-ите кодират своя обединен първи subidentifier в base-128. Правилото за граници и поправката на OID-а заменят код, който даваше грешни резултати, без да вдига грешка, а правилото за padding-а не позволява на по-строгия четец да отхвърля истински подписи

Страната на четенето има по-голямо значение, отколкото изглежда. Инструментите за long-term validation трябва да извадят сертификата на подписващия и неговите издатели от съществуващ подпис, преди да стигнат до данните за отмяна, одитен доклад трябва да каже кой е подписал, а Lazarus build под Linux няма Windows message функции, на които да се опре. Парсер в това положение рядко се срива от лош вход. Болезненият режим на отказ е брой сертификати, включващ чужди байтове от съсед, съвпадение на подписващия по грешното поле или OID, който тихо се превръща в друг OID. Подписов pipeline, издигнат върху такава основа, докладва уверени безсмислици

Извличане на сертификатите на подписващия от подписан PDF

Пет метода на TPDFlib покриват страната на четенето, и всичките приемат InputFile, Password, FieldName: всяко извикване отваря файла само за четене, отговаря и го затваря отново. GetSignatureEmbeddedCertificateCount и GetSignatureEmbeddedCertificateDER изброяват множеството сертификати в реда на кодиране, GetSignatureSignerCertificateDER връща сертификата, произвел даден SignerInfo, а GetSignatureCertificateChainLength / GetSignatureCertificateChainDER тръгват от този подписващ към най-далечния издател, който самият подпис носи. Индексите са от нулата. Пазете резултатите в AnsiString — затова библиотеката ги връща така: DER blob, минал през 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 не е диагноза: липсващо поле, грешна парола, blob, който не е DER, и SignedData, който просто пропуска незадължителното множество сертификати, всички връщат 0 или празен низ, затова логвайте името на полето до числото. И верига, приключила преди самоиздаден сертификат, също не е грешка. Строителят на вериги ползва само сертификатите, вградени в подписа, така че останалите издатели трябва да се доставят чрез адресите, които GetCertificateIssuerURLs докладва

Колко от /Contents всъщност е CMS-ът?

Само префиксът, който външният SEQUENCE декларира, принадлежи на CMS-а, а PLTrimCMSPadding отрязва всичко след него. Подписващият резервира hex низа /Contents, преди CMS-ът да съществува, защото /ByteRange, описан в ISO 32000-1 §12.8.1, трябва да бъде фиксиран първо, така че слотът е оразмерен щедро, а неизползваната опашка са нули. PLTrimCMSPadding чете първия TLV, изисква таг $30 и връща байтовете до края на този елемент; всичко, което не започва с добре оформен SEQUENCE, се връща празно. Това най-високо ниво е единственото място, където байтове в края са легални, а разликата има значение за следващия раздел: строго правило „елементът трябва да изчерпи целия буфер" би отхвърлило всеки реален подпис, докато слабо правило, приложено на всяка дълбочина, позволява на вложените полета да четат байтове, които не са техни

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

Защо DER четец се нуждае от крайното отместване на родителя?

Вложен елемент е валиден само ако свършва вътре в родителя си, а проверката срещу края на буфера не доказва това. Нисконивовият DERReadTLV в PDFlibASN1 ограничава всеки елемент с целия низ, което е правилната проверка за най-външния обект и грешната за всичко под него. Представете си SignerInfo, чийто issuerAndSerialNumber декларира 40 байта, докато issuer Name вътре в него претендира 60. Всеки байт пак е в буфера, така че четец, ограничен от буфера, приема Name-а, чете серийния номер от следващия дайджест алгоритъм и после сравнява тази двойка срещу вградените сертификати. Преди 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    := 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 се обединяват в един subidentifier, не в един байт, а този subidentifier е кодиран в base-128 като всеки друг клон. X.690 §8.19.4 го дефинира като 40 * arc1 + arc2; по-старият DER_OID записваше тази стойност с Byte(...), което е коректно само до 127 — стойността на 2.47. За 2.999 сборът е 1079, cast-ът към байт запазва 55, а 55 се декодира като 1.15, така че идентификаторът тихо наименува друг клон на дървото. Стойностите от 128 до 255 се провалят различно, излъчвайки един байт с вдигнат continuation бит, който поглъща следващия клон. Повечето 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, докато старият cast към 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 от неконформен подписващ докладва нула сертификати вместо да отгатне частично. Attribute сертификатите и останалите алтернативи на CertificateChoices се прескачат, защото нищо надолу по веригата не може да ги използва. Криптографската проверка остава при кода, който я притежава — започва с проверките за byte покритие, описани в PAdES подписване и ByteRange валидация в Delphi, и продължава с класифицирането на промените след подписването на PDF

По-широкият урок важи за всеки двоичен формат: „вътре в буфера" е свойство на memory safety, „вътре в родителя" е свойство на коректността, а парсер се нуждае и от двете. Същото мислене за враждебни дължини минава през закаляването на Pascal PDF парсер срещу зловредни файлове. Обсъдените тук API за извличане на сертификати, строене на вериги и long-term validation идват с losLab PDF Library for Delphi, за Delphi, C++Builder и Lazarus