Technický článek

Parsování CMS podpisu v Delphi: hranice DER a oblouky OID

PDF Library for Delphi (PDFlibPas) vytahuje certifikáty uvnitř PDF podpisu čistým procházením DER přes CMS SignedData uložené v /Contents, bez zapojení CryptoAPI. Od v3.539.10 je každé vnořené čtení ohraničené rodičovským prvkem, nulová výplň za CMS se ořeže na délku, kterou CMS deklaruje, a objektové identifikátory kódují svůj sloučený první subidentifikátor v base-128. Pravidlo hranic i oprava OID nahradily kód, který vydával špatné výsledky bez jakékoli chyby, a pravidlo výplně drží přísnějšího čtenáře od odmítání skutečných podpisů

Čtecí strana je důležitější, než vypadá. Nástroje pro dlouhodobou validaci musí vytáhnout certifikát podepisujícího a jeho vystavitele z existujícího podpisu, než stáhnou revokační data, auditní zpráva musí říct, kdo podepsal, a Lazarus build na Linuxu se nemá o co opřít — žádné Windows message funkce tam nejsou. Parser v takové pozici na špatném vstupu málokdy spadne. Bolavý režim selhání je počet certifikátů, do kterého se připlatí bajty souseda, shoda podepisujícího provedená proti špatnému poli nebo OID, které se potichu promění v úplně jiné OID. Podpisová pipeline postavená na takovém základu hlásí sebejistý nesmysl

Čtení certifikátů podepisujícího z podepsaného PDF

Čtecí stranu pokrývá pět metod TPDFlib a všechny berou InputFile, Password, FieldName: každé volání otevře soubor jen pro čtení, odpoví a zase ho zavře. GetSignatureEmbeddedCertificateCount a GetSignatureEmbeddedCertificateDER vypisují certifikáty v sadě v pořadí kódování, GetSignatureSignerCertificateDER vrátí certifikát, který vyrobil dané SignerInfo, a GetSignatureCertificateChainLength / GetSignatureCertificateChainDER jdou od podepisujícího k nejvzdálenějšímu vystaviteli, který podpis sám nese. Indexy se číslují od nuly. Výsledky držte v AnsiStringu, a proto je knihovna taky takhle vrací: DER blob puštěný přes string nebo TStrings projde konverzí znakové sady a vrátí se poškozený

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;

Dvě věci v tomhle výstupu chtějí pozornost. Počet 0 není diagnóza: chybějící pole, špatné heslo, blob, který není DER, i SignedData, které volitelnou sadu certifikátů prostě vynechá, všechno se vrátí jako 0 nebo prázdný řetězec, takže si vedle čísla logujte název pole. A řetěz, který skončí dřív než u sebepodepsaného certifikátu, taky není chyba. Stavitel řetězu používá jen certifikáty vložené v podpisu, takže zbývající vystavitele je potřeba stáhnout přes adresy, které hlásí GetCertificateIssuerURLs

Kolik z /Contents je doopravdy CMS?

CMS patří jen prefix, který vnější SEQUENCE deklaruje, a PLTrimCMSPadding všechno za ním ořeže. Podepisující si rezervuje hex string /Contents dřív, než CMS vůbec existuje, protože /ByteRange popsaný v ISO 32000-1 §12.8.1 se musí zafixovat nejdřív, takže slot má velkorysou velikost a nepoužitý ocas jsou nuly. PLTrimCMSPadding přečte první TLV, vyžaduje tag $30 a vrátí bajty až do konce toho prvku; cokoli, co nezačíná korektně poskládanou SEQUENCE, se vrátí prázdné. Ta nejvyšší úroveň je jediné místo, kde jsou koncové bajty legální, a tenhle rozdíl je důležitý pro další sekci: přísné pravidlo „prvek musí spotřebovat celý buffer“ by odmítlo každý reálný podpis, zatímco laxní pravidlo aplikované v každé hloubce nechá vnořená pole číst bajty, které nevlastní

PLTrimCMSPadding v PDFlibPas přečte první TLV rezervovaného hex stringu /Contents, vyžaduje tag $30 a ořeže nulovou výplň na délku, kterou deklaruje vnější SEQUENCE, a prázdný výsledek vrátí, když buffer nezačíná korektně poskládanou SEQUENCE
Koncové bajty jsou legální jen na nejvyšší úrovni, kde musí rezervovaný slot zůstat pevný kvůli /ByteRange — hlubší čtení dostávají pravidlo omezené rodičem

Proč potřebuje DER čtenář koncový offset rodiče?

Vnořený prvek je validní jen tehdy, když končí uvnitř svého rodiče, a kontrola proti konci bufferu to nedokáže. Nízkoúrovňové DERReadTLV v PDFlibASN1 ohraničuje každý prvek celým řetězcem, což je správná kontrola pro nejvnější objekt a špatná pro všechno pod ním. Představte si SignerInfo, jehož issuerAndSerialNumber deklaruje 40 bajtů, zatímco issuer Name uvnitř si nárokuje 60. Každý bajt je pořád v bufferu, takže čtenář ohraničený bufferem Name přijme, sériové číslo pak přečte z navazujícího digest algoritmu a tenhle pár pak porovnává s vloženými certifikáty. Před v3.539.10 četl CMS walker přesně takhle. Opravou je malý wrapper, který nese koncovou pozici rodiče do každého čtení

PDFlibPas ohraničuje každé vnořené DER čtení rodičovským prvkem: 60bajtový issuer Name uvnitř 40bajtového issuerAndSerialNumber přijme staré DERReadTLV ohraničené bufferem, které pak přečte sériové číslo z digestAlgorithm, zatímco ReadTLVWithin odmítne každý prvek končící za ParentEnd
Uvnitř bufferu je memory safety, uvnitř rodiče je správnost — PDFlibPas protahuje koncový offset rodiče každou CMS úrovní, aby zákeřná délka nemohla vypůjčit bajty souseda
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // uvnitř rodiče už nic nezbývá: čtení nezačínat
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset teď stojí jeden krok za prvkem; nesmí přesáhnout rodiče
  Result := Offset <= ParentEnd;
end;

// každá úroveň si zapsala vlastní konec a předává ho dál:
//   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

Unit PDFlibCMSRead teď protahuje tyhle konce přes ContentInfo, wrapper [0] EXPLICIT, pole SignedData až po signerInfos, SignerIdentifier v obou podobách issuerAndSerialNumber i [0] subjectKeyIdentifier (RFC 5652 §5.3) a pole tbsCertificate čtená z každého vloženého certifikátu při párování podepisujícího. Uvnitř sady certifikátů i sady signerInfos prvek, který přeběhne za konec sady, zastaví smyčku: PLExtractCMSCertificates vrátí certifikáty, které už přijal, a bajty navazujících crls nebo signerInfos už nikdy neslepí na poslední z nich. Párování issuer-and-serial taky vyžaduje obě poloviny, protože sériové číslo je unikátní jen v rámci jednoho vystavitele

Proč vyšlo 2.999.3 jako 1.15.3?

První dva oblouky OID (arc) se sloučí do jednoho subidentifikátoru, ne do jednoho bajtu, a tenhle subidentifikátor se kóduje v base-128 jako každý jiný arc. X.690 §8.19.4 ho definuje jako 40 * arc1 + arc2; dřívější DER_OID zapsal tuhle hodnotu přes Byte(...), což je správně jen do 127, tedy do hodnoty 2.47. Pro 2.999 je součet 1079, přetypování na bajt ponechá 55 a 55 se dekóduje jako 1.15, takže identifikátor potichu pojmenuje úplně jinou větev stromu. Hodnoty od 128 do 255 selžou jinak — vypustí jeden bajt s nastaveným continuation bitem, který spolkne další arc. Většina PKI identifikátorů (1.2.840..., 2.5.29..., 0.4.0...) se hranice nikdy nedotkne, a proto vada přežila; oblouky joint-iso-itu-t od 2.48 výš se jí už dotknou. DER_OID slouží i kodéru signed attributes, i matcheru v DERFindExtensionByOID a kontrole content-type SignedData, takže špatné kódování rozbilo zápis i vyhledávání naráz

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 sloučí první dva oblouky OID jako 40 * arc1 + arc2 a součet kóduje v base-128 do UInt64, takže 2.999.3 vyleze jako 06 03 88 37 03, zatímco staré přetypování na Byte ponechalo 55 a potichu dekódovalo identifikátor jako 1.15.3
Většina PKI oblouků se hranice nikdy nedotkne, a proto bug přežil — oblouky joint-iso-itu-t od 2.48 výš potřebují dva bajty a test drží 2.47 a 2.48 po obou stranách

Sloučená hodnota se schovává do UInt64 úmyslně. DER_OID parsuje oblouky do Int64, takže legální druhý arc může být klidně Int64.MaxValue a přičtení 80 pro arc1 = 2 přeteče znaménkové 64bitové celé číslo. UInt64 unese Int64.MaxValue + 80 bez přeložení a desetibajtový pomocný buffer pojme těch deset 7bitových skupin, které 64bitová hodnota potřebuje. Testovací vektory, které stojí za držení, jsou ty po obou stranách hranice: 2.47 musí zůstat jeden bajt a 2.48 se musí stát dvěma

Co čtecí CMS walker garantuje?

PDFlibCMSRead garantuje strukturu a nic víc: vrací bajty tam, kde je RFC 5652 chce mít, a neověřuje žádný podpis, digest ani dobu platnosti. Walker bere jen DER, takže DERReadTLV odmítne neurčité délky a vícebajtová čísla tagů a BER kódované CMS od nekonformního podepisujícího nahlásí nulový počet certifikátů místo částečného odhadu. Attribute certificates a ostatní alternativy CertificateChoices se přeskočí, protože žádný navazující kód je neumí použít. Kryptografické ověření necháváme kódu, kterému patří — začíná kontrolami pokrytí bajtů popsanými v PAdES podepisování a validaci ByteRange v Delphi a pokračuje tříděním toho, co se po podepsání PDF změnilo

Širší ponaučení platí pro jakýkoli binární formát: „uvnitř bufferu“ je vlastnost memory safety, „uvnitř rodiče“ je vlastnost správnosti a parser potřebuje obojí. Stejné myšlení o zákeřných délkách provází otužování Pascal parseru PDF proti zákeřným souborům. Extrakce certifikátů, stavba řetězu a API pro dlouhodobou validaci popsané tady sjíždějí s losLab PDF Library for Delphi pro Delphi, C++Builder a Lazarus