Odborný článok

Parsovanie CMS podpisov v Delphi: hranice DER a vetvy OID

PDF Library for Delphi (PDFlibPas) vytiahne certifikáty vnútri PDF podpisu čistým DER prechodom cez CMS SignedData uložené v /Contents, bez akéhokoľvek zapojenia CryptoAPI. Od v3.539.10 je každé vnorené čítanie ohraničené svojím rodičovským elementom, nulový padding za CMS sa oreže na dĺžku, ktorú CMS deklaruje, a objektové identifikátory kódujú svoj zlúčený prvý subidentifikátor v base-128. Pravidlo hraníc aj oprava OID nahradili kód, ktorý vyrábal nesprávne výsledky bez vyhodenia chyby, a pravidlo paddingu bráni prísnejšiemu čítaču, aby odmietol reálne podpisy

Čítacia strana je dôležitejšia, než vyzerá. Nástroje dlhodobej validácie musia vytiahnuť certifikát podpisovateľa a jeho vystaviteľov z existujúceho podpisu skôr, než siahnu po revokačných dátach, audit report musí povedať, kto podpísal, a Lazarus zostavenie na Linuxe sa nemá o čo oprieť v podobe Windows message funkcií. Parser v takejto pozícii na zlom vstupe málokedy spadne. Režim zlyhania, ktorý naozaj bolí, je počet certifikátov zahŕňajúci bajty patriace susedovi, zhoda podpisovateľa nájdená v nesprávnom poli alebo OID, ktoré sa potichu zmení na iné OID. Podpisová pipeline postavená na takomto základe hlási sebaistý nesmysel

Čítanie certifikátov podpisovateľa z podpísaného PDF

Čítaciu stranu pokrýva päť metód TPDFlib a všetky berú InputFile, Password, FieldName: každé volanie otvorí súbor len na čítanie, odpovie a znovu ho zavrie. GetSignatureEmbeddedCertificateCount a GetSignatureEmbeddedCertificateDER vymenúvajú certifikáty v sade v poradí kódovania, GetSignatureSignerCertificateDER vráti certifikát, ktorý vytvoril daný SignerInfo, a GetSignatureCertificateChainLength / GetSignatureCertificateChainDER prechádzajú od podpisovateľa k najvzdialenejšiemu vystaviteľovi, ktorého podpis sám nesie. Indexy sa číslujú od nuly. Výsledky držte v AnsiString, presne preto ich knižnica takto aj vracia: DER blob putujúci cez string alebo TStrings prejde konverziou znakovej sady a vráti sa poškodený

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;

Dve veci v tomto výstupe si zaslúžia pozornosť. Počet 0 nie je diagnóza: chýbajúce pole, nesprávne heslo, blob, ktorý nie je DER, aj SignedData, ktoré jednoducho vynechá voliteľnú sadu certifikátov, sa vrátia všetky ako 0 alebo prázdny reťazec, takže si vedľa čísla logujte názov poľa. Ani reťaz, ktorá skončí skôr než narazí na self-issued certifikát, nie je chyba. Staviteľ reťazu používa len certifikáty vložené v podpise, takže zostávajúcich vystaviteľov treba dohľadať cez adresy, ktoré nahlási GetCertificateIssuerURLs

Koľko z /Contents je vlastne CMS?

CMS patrí len prefix, ktorý deklaruje vonkajší SEQUENCE, a PLTrimCMSPadding odreže všetko za ním. Podpisovateľ rezervuje hex reťazec /Contents skôr, než CMS vôbec existuje, pretože /ByteRange popísaný v ISO 32000-1 §12.8.1 sa musí stanoviť ako prvý, takže slot má veľkorysú veľkosť a nepoužitý chvost tvoria nuly. PLTrimCMSPadding prečíta prvú TLV, vyžaduje tag $30 a vráti bajty až po koniec tohto elementu; čokoľvek, čo nezačína korektne utvoreným SEQUENCE, sa vráti prázdne. Táto najvyššia úroveň je jediné miesto, kde sú koncové bajty legálne, a toto rozlíšenie má význam pre ďalšiu sekciu: prísne pravidlo „element musí spotrebovať celý buffer“ by odmietlo každý reálny podpis, zatiaľ čo laxné pravidlo aplikované v každej hĺbke nechá vnorené polia čítať bajty, ktoré nevlastnia

PLTrimCMSPadding v PDFlibPas prečíta prvú TLV rezervovaného hex reťazca /Contents, vyžaduje tag $30 a oreže nulový padding na dĺžku, ktorú deklaruje vonkajší SEQUENCE, pričom vráti prázdny výsledok, keď buffer nezačína korektne utvoreným SEQUENCE
Koncové bajty sú legálne len na najvyššej úrovni, kde musí rezervovaný slot ostať pevný kvôli /ByteRange — hlbšie čítania dostanú namiesto toho pravidlo ohraničené rodičom

Prečo potrebuje DER čítač koncový offset rodiča?

Vnorený element je platný len vtedy, keď končí vnútri svojho rodiča, a kontrola proti koncu bufferu to nedokáže dokázať. Nízkourovnňový DERReadTLV v PDFlibASN1 ohraničuje každý element celým reťazcom, čo je správna kontrola pre najvonkajší objekt a nesprávna pre všetko pod ním. Predstavte si SignerInfo, ktorého issuerAndSerialNumber deklaruje 40 bajtov, kým issuer Name vnútri neho nárokuje 60. Každý bajt je stále v bufferi, takže čítač ohraničený bufferom Name prijme, sériové číslo prečíta z digest algoritmu, ktorý nasleduje, a túto dvojicu potom porovná s vloženými certifikátmi. Pred v3.539.10 čítal CMS walker presne takto. Opravou je malý wrapper, ktorý vnáša koncovú pozíciu rodiča do každého čítania

PDFlibPas ohraničuje každé vnorené DER čítanie rodičovským elementom: 60-bajtový issuer Name vnútri 40-bajtového issuerAndSerialNumber prijme starý čítač DERReadTLV ohraničený bufferom, ktorý potom prečíta sériové číslo z digestAlgorithm, zatiaľ čo ReadTLVWithin odmietne každý element končiaci za ParentEnd
Vnútri bufferu je memory safety, vnútri rodiča je korektnosť — PDFlibPas prevlecie koncový offset rodiča každou úrovňou CMS, aby nepriateľská dĺžka nemohla požičať si bajty suseda
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // v rodičovi nič neostalo: čítanie nezačínať
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset je teraz jeden za elementom; nesmie prekročiť rodiča
  Result := Offset <= ParentEnd;
end;

// každá úroveň si zaznamená svoj koniec a odovzdá ho ďalej:
//   OuterEnd    := koniec ContentInfo         (RFC 5652 sekcia 3)
//   ExplicitEnd := koniec obsahu [0] EXPLICIT
//   ContentEnd  := koniec SignedData          (RFC 5652 sekcia 5.1)
//   SignerEnd / InnerEnd pre SignerInfo a issuerAndSerialNumber

Jednotka PDFlibCMSRead teraz prevlecie tie konce cez ContentInfo, wrapper [0] EXPLICIT, polia SignedData až po signerInfos, SignerIdentifier v oboch podobách issuerAndSerialNumber aj [0] subjectKeyIdentifier (RFC 5652 §5.3) a polia tbsCertificate čítané z každého vloženého certifikátu pri pátraní po podpisovateľovi. Vnútri sady certifikátov a sady signerInfos zastaví slučku element prebiehajúci za koniec sady: PLExtractCMSCertificates vráti certifikáty, ktoré už prijala, a nasledujúce bajty crls či signerInfos nikdy nalepí na posledný. Zhoda issuer-and-serial navyše vyžaduje obe polovice, keďže sériové číslo je unikátne len v rámci jedného vystaviteľa

Prečo vyšlo 2.999.3 ako 1.15.3?

Prvé dve vetvy OID sa zlučujú do jedného subidentifikátora, nie do jedného bajtu, a ten sa kóduje v base-128 ako každá iná vetva. X.690 §8.19.4 ho definuje ako 40 * arc1 + arc2; skorší DER_OID zapisoval túto hodnotu cez Byte(...), čo sedí len do 127, teda po hodnotu 2.47. Pre 2.999 vyjde súčet 1079, pretypovanie na bajt nechá 55 a 55 sa dekóduje ako 1.15, takže identifikátor potichu pomenová inú vetvu stromu. Hodnoty od 128 do 255 zlyhávajú inak: emitujú jeden bajt s nastaveným continuation bitom, ktorý prehltne ďalšiu vetvu. Väčšina PKI identifikátorov (1.2.840..., 2.5.29..., 0.4.0...) sa k hranici nikdy nedostane, preto defekt prežil; vetvy joint-iso-itu-t od 2.48 nahor sa dostanú. DER_OID obsluhuje aj kódovač podpísaných atribútov, aj vyhľadávač v DERFindExtensionByOID a kontrolu content-type SignedData, takže zlé kódovanie rozbilo zápis aj vyhľadávanie naraz

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 v PDFlibPas zlučuje prvé dve vetvy OID ako 40 * arc1 + arc2 a kóduje súčet v base-128 do UInt64, takže 2.999.3 vyjde ako 06 03 88 37 03, zatiaľ čo staré pretypovanie na Byte nechalo 55 a potichu dekódovalo identifikátor ako 1.15.3
Väčšina PKI vetiev sa k hranici nikdy nedostane, preto defekt prežil — vetvy joint-iso-itu-t od 2.48 nahor potrebujú dva bajty a test drží 2.47 a 2.48 po oboch stranách

Zlúčená hodnota sa v UInt64 drží zámerne. DER_OID parsuje vetvy do Int64, takže legálna druhá vetva môže mať až Int64.MaxValue a pripočítanie 80 pre arc1 = 2 pretečie znamienkové 64-bitové celé číslo. UInt64 unesie Int64.MaxValue + 80 bez pretičenia a desaťbajtový pracovný buffer pojme desať 7-bitových skupín, ktoré 64-bitová hodnota potrebuje. Testovacie vektory, ktoré stoja za udržanie, sú tie po oboch stranách hranice: 2.47 musí ostať jeden bajt a 2.48 sa musí stať dvoma

Čo garantuje čítací CMS walker?

PDFlibCMSRead garantuje štruktúru a nič viac: vracia bajty tam, kde ich RFC 5652 očakáva, a neoveruje žiadny podpis, digest ani dobu platnosti. Walker prijíma len DER, takže DERReadTLV odmietne neurčité dĺžky a viacbajtové čísla tagov a BER kódované CMS od nekonformného podpisovateľa nahlási ako nula certifikátov, nie čiastočný tip. Certifikáty atribútov a ďalšie alternatívy CertificateChoices sa preskočia, lebo nič downstream ich nevyužije. Kryptografické overenie ostáva pri kóde, ktorému patrí: začína kontrolami pokrytia bajtov popísanými v článku o PAdES podpisovaní a validácii ByteRange v Delphi a pokračuje triedením toho, čo sa po podpísaní PDF zmenilo

Širšie poučenie platí pre každý binárny formát: „vnútri bufferu“ je vlastnosť memory safety, „vnútri rodiča“ je vlastnosť korektnosti a parser potrebuje oboje. Rovnaké uvažovanie o nepriateľských dĺžkach prebieha článkom o posilňovaní Pascal PDF parsera proti škodlivým súborom. Extrakcia certifikátov, stavba reťazov a API dlhodobej validácie popísané tu sa dodávajú s losLab PDF Library for Delphi pre Delphi, C++Builder aj Lazarus