Teknisk artikel

CMS-signaturparsing i Delphi: DER-grænser og OID-buer

PDF Library for Delphi (PDFlibPas) trækker certifikaterne inde i en PDF-signatur ud med en ren DER-gennemgang af det CMS-SignedData, der ligger i /Contents, helt uden CryptoAPI. Siden v3.539.10 er hver indlejret læsning afgrænset af sit parent-element, zero-paddingen efter CMS'et klippes ved den længde, CMS'et selv deklarerer, og object identifiers koder deres kombinerede første subidentifier i base-128. Både grænsereglen og OID-fixet erstattede kode, der gav forkerte svar uden at rejse en fejl, og padding-reglen holder den strengere reader fra at afvise rigtige signaturer

Læsesiden betyder mere, end den ser ud til. Long-term validation-værktøj skal have underskriverens certifikat og dets udstedere ud af en eksisterende signatur, før det kan hente revokeringsdata, en revisionsrapport skal kunne sige, hvem der har signeret, og en Lazarus-build på Linux har ingen Windows-message-funktioner at støtte sig til. En parser i den position crasher sjældent på dårligt input. Den fejltilstand, der gør ondt, er et certifikatantal, der regner bytes fra en nabo med, et underskriver-match mod det forkerte felt eller en OID, der i stilhed bliver til en anden OID. En signaturpipeline bygget oven på det rapporterer selvsikre nonsens

Sådan læser du underskriverens certifikater ud af en signeret PDF

Fem TPDFlib-metoder dækker læsesiden, og alle tager InputFile, Password, FieldName: hvert kald åbner filen read-only, svarer og lukker den igen. GetSignatureEmbeddedCertificateCount og GetSignatureEmbeddedCertificateDER enumererer certifikatsættet i encoding-rækkefølge, GetSignatureSignerCertificateDER returnerer det certifikat, der producerede en given SignerInfo, og GetSignatureCertificateChainLength / GetSignatureCertificateChainDER går fra underskriveren mod den fjerneste udsteder, signaturen selv bærer. Indekserne er nul-baserede. Gem resultaterne i AnsiString, hvilket også er grunden til, at biblioteket returnerer dem sådan: en DER-blob, der sendes gennem string eller en TStrings, kommer gennem en konvertering af tegnsæt og vender tilbage ødelagt

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;

To ting i det output kræver omtanke. Et tal på 0 er ikke en diagnose: et manglende felt, et forkert kodeord, en blob, der ikke er DER, og et SignedData, der blot udelader det valgfrie certifikatsæt, giver alle sammen 0 eller en tom streng, så log feltnavnet ved siden af tallet. Og en kæde, der slutter før et selvudstedet certifikat, er heller ikke en fejl. Kædebyggeren bruger kun de certifikater, der er indlejret i signaturen, så de resterende udstedere skal hentes gennem de adresser, GetCertificateIssuerURLs rapporterer

Hvor meget af /Contents er egentlig CMS?

Kun det præfiks, som den yderste SEQUENCE deklarerer, hører til CMS'et, og PLTrimCMSPadding klipper alt efter det væk. En underskriver reserverer /Contents-hex-strengen, før CMS'et findes, fordi /ByteRange, som ISO 32000-1 §12.8.1 beskriver, skal være fastlagt først, så pladsen dimensioneres generøst, og den ubrugte hale er nuller. PLTrimCMSPadding læser den første TLV, kræver tag $30 og returnerer bytes op til elementets slutning; alt, der ikke starter med en velformet SEQUENCE, kommer tilbage som tomt. Det topniveau er det eneste sted, hvor trailing-bytes er lovlige, og den skelnen betyder noget for næste afsnit: en streng regel om, at "elementet skal opbruge hele bufferen", ville afvise alle rigtige signaturer, mens en løs regel anvendt i hver dybde lader indlejrede felter læse bytes, de ikke ejer

PDFlibPas PLTrimCMSPadding læser den første TLV i den reserverede /Contents-hex-streng, kræver tag $30 og klipper zero-paddingen ved den længde, den yderste SEQUENCE deklarerer, og den returnerer et tomt resultat, når bufferen ikke starter med en velformet SEQUENCE
Trailing-bytes er kun lovlige på topniveau, hvor den reserverede plads skal holde sig fast for /ByteRange — dybere læsninger får i stedet reglen om parent-afgrænsning

Hvorfor skal en DER-reader kende parent-elementets slut-offset?

Et indlejret element er kun gyldigt, hvis det slutter inde i sit parent-element, og et tjek mod bufferens slutning beviser det ikke. Lavniveau-funktionen DERReadTLV i PDFlibASN1 afgrænser hvert element mod hele strengen, hvilket er det rigtige tjek for det yderste objekt og det forkerte for alt under det. Forestil dig en SignerInfo, hvis issuerAndSerialNumber deklarerer 40 bytes, mens issuer-Name'en indeni gør krav på 60. Hver byte er stadig i bufferen, så en buffer-afgrænset reader accepterer Name'en, læser serienummeret ud af den digest-algoritme, der følger efter, og sammenligner derefter det par med de indlejrede certifikater. Før v3.539.10 læste CMS-walkeren præcis sådan. Fixet er en lille wrapper, der bærer parent-elementets slutposition med ind i hver læsning

PDFlibPas afgrænser hver indlejret DER-læsning med sit parent-element: en issuer-Name på 60 bytes inde i en issuerAndSerialNumber på 40 bytes accepteres af den gamle buffer-afgrænsede DERReadTLV, som derefter læser serienummeret ud af digestAlgorithm, mens ReadTLVWithin afviser ethvert element, der slutter forbi ParentEnd
Inde i bufferen er memory safety, inde i parent-elementet er korrekthed — PDFlibPas trækker parent-slut-offsettet gennem hvert CMS-niveau, så en fjendtlig længde ikke kan låne en nabos bytes
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // intet tilbage inde i parent-elementet: nægt at starte en læsning
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset står nu én forbi elementet; det må ikke passere parent-elementet
  Result := Offset <= ParentEnd;
end;

// hvert niveau registrerer sin egen slutning og giver den videre:
//   OuterEnd    := slutningen af ContentInfo         (RFC 5652 afsnit 3)
//   ExplicitEnd := slutningen af content [0] EXPLICIT
//   ContentEnd  := slutningen af SignedData          (RFC 5652 afsnit 5.1)
//   SignerEnd / InnerEnd for SignerInfo og issuerAndSerialNumber

Uniten PDFlibCMSRead trækker nu disse slutninger gennem ContentInfo, [0] EXPLICIT-wrappern, SignedData-felterne op til signerInfos, SignerIdentifier i både sin issuerAndSerialNumber- og sin [0] subjectKeyIdentifier-form (RFC 5652 §5.3) og de tbsCertificate-felter, der læses fra hvert indlejret certifikat, når underskriveren matches. Inde i certifikatsættet og signerInfos-sættet standser et element, der løber forbi sættets slutning, løkken: PLExtractCMSCertificates returnerer de certifikater, den allerede havde accepteret, og limer aldrig de følgende crls- eller signerInfos-bytes på den sidste. Issuer-and-serial-matchet kræver også begge halvdele, fordi et serienummer kun er unikt inden for én udsteder

Hvorfor kom 2.999.3 ud som 1.15.3?

En OIDs to første buer kombineres til én subidentifier, ikke én byte, og den subidentifier base-128-kodes som enhver anden bue. X.690 §8.19.4 definerer den som 40 * arc1 + arc2; den tidligere DER_OID skrev den værdi med Byte(...), hvilket kun er korrekt op til 127, værdien af 2.47. For 2.999 er summen 1079, byte-castet beholder 55, og 55 afkodes som 1.15, så identifieren navngiver i stilhed en anden gren af træet. Værdier fra 128 til 255 fejler på en anden måde og udsender én byte med continuation-bitten sat, hvilket opsluger næste bue. De fleste PKI-identifiers (1.2.840..., 2.5.29..., 0.4.0...) når aldrig til grænsen, hvilket er grunden til, at fejlen overlevede; joint-iso-itu-t-buerne fra 2.48 og op gør det. DER_OID bruges både af encoderen til signed attributes og af matcheren i DERFindExtensionByOID og SignedData-content-type-tjekket, så en forkert encoding brød både skrivning og opslag

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 kombinerer en OIDs to første buer som 40 * arc1 + arc2 og base-128-koder summen i en UInt64, så 2.999.3 bliver til 06 03 88 37 03, mens det gamle Byte-cast beholdt 55 og i stilhed afkodede identifieren som 1.15.3
De fleste PKI-buer når aldrig til grænsen, hvilket er grunden til, at fejlen overlevede — joint-iso-itu-t-buerne fra 2.48 og op skal bruge to bytes, og testene holder 2.47 og 2.48 på hver sin side

Den kombinerede værdi ligger bevidst i en UInt64. DER_OID parser buer ind i Int64, så en lovlig anden bue kan være så stor som Int64.MaxValue, og at lægge 80 til for arc1 = 2 giver overflow i et signeret 64-bit heltal. En UInt64 bærer Int64.MaxValue + 80 uden at wrappe, og den ti-bytes scratch-buffer rummer de ti 7-bit-grupper, en 64-bit værdi har brug for. Testvektorerne, der er værd at gemme, er dem på hver side af grænsen: 2.47 skal forblive én byte, og 2.48 skal blive to

Hvad garanterer CMS-walkeren på læsesiden?

PDFlibCMSRead garanterer struktur og intet andet: den returnerer de bytes, der ligger, hvor RFC 5652 siger, de skal, og verificerer hverken signatur, digest eller gyldighedsperiode. Walkeren accepterer kun DER, så DERReadTLV afviser indefinite lengths og multi-byte tag-numre, og et BER-kodet CMS fra en ikke-konform underskriver rapporterer nul certifikater frem for et delvist gæt. Attribute certificates og de andre CertificateChoices-alternativer springes over, fordi intet længere nede kan bruge dem. Den kryptografiske verificering bliver hos koden, der ejer den, og den begynder med de byte-coverage-tjek, der er beskrevet i PAdES-signering og ByteRange-validering i Delphi, og fortsætter med at klassificere, hvad der ændrede sig, efter en PDF blev signeret

Den bredere lektie gælder ethvert binært format: "inde i bufferen" er en memory-safety-egenskab, "inde i parent-elementet" er en korrekthedsegenskab, og en parser har brug for begge. Samme tankegang om fjendtlige længder løber gennem hærdning af en Pascal-PDF-parser mod ondsindede filer. Certifikatudtrækket, kædebygningen og de long-term validation-API'er, der er omtalt her, følger med losLab PDF Library for Delphi til Delphi, C++Builder og Lazarus