Teknisk artikel

CMS-signaturer i Delphi: DER-gränser och OID-arcs

PDF Library for Delphi (PDFlibPas) plockar ut certifikaten inuti en PDF-signatur genom en ren DER-genomgång av den CMS-SignedData som lagras i /Contents, helt utan CryptoAPI. Sedan v3.539.10 avgränsas varje nästlad läsning av sitt förälderelement, nollutfyllnaden efter CMS:en kapas vid den längd CMS:en själv deklarerar, och objektidentifierare kodar sin sammanslagna första subidentifierare i base-128. Både gränsregeln och OID-fixen ersatte kod som gav fel svar utan att kasta något fel, och utfyllnadsregeln ser till att den strängare läsaren inte avvisar riktiga signaturer

Lässidan spelar större roll än den verkar. Verktyg för long-term validation måste plocka ut signerarcertifikatet och dess utfärdare ur en befintlig signatur innan de kan hämta spärrdata, en granskningsrapport måste kunna säga vem som har signerat, och ett Lazarus-bygge på Linux har inga Windows-meddelandefunktioner att luta sig mot. En parser i den situationen kraschar sällan på dålig indata. Det feltläge som gör ont är ett certifikatantal som räknar med byte som tillhör en granne, en signerarmatchning mot fel fält, eller en OID som tyst byts ut mot en annan OID. En signaturpipeline byggd ovanpå det rapporterar självsäkert nonsens

Läsa ut signerarcertifikaten ur en signerad PDF

Fem TPDFlib-metoder täcker lässidan, och samtliga tar InputFile, Password, FieldName: varje anrop öppnar filen skrivskyddat, svarar och stänger den igen. GetSignatureEmbeddedCertificateCount och GetSignatureEmbeddedCertificateDER räknar upp certifikatsmängden i kodningsordning, GetSignatureSignerCertificateDER returnerar certifikatet som gav upphov till en given SignerInfo, och GetSignatureCertificateChainLength / GetSignatureCertificateChainDER går från den signeraren mot den mest avlägsna utfärdare som signaturen själv bär med sig. Indexen är nollbaserade. Lägg resultatet i AnsiString, vilket är därför biblioteket returnerar dem just så: en DER-blob som routas genom string eller en TStrings går via en teckenuppsättningskonvertering och kommer tillbaka korrumperad

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;

Två saker i det där resultatet behöver varsamhet. Ett antal 0 är ingen diagnos: ett saknat fält, ett fel lösenord, en blob som inte är DER, och en SignedData som helt enkelt utelämnar den valfria certifikatsmängden kommer alla tillbaka som 0 eller en tom sträng, så logga fältnamnet bredvid siffran. Och en kedja som slutar före ett självutfärdat certifikat är inte heller något fel. Kedjebyggaren använder bara certifikat som är inbäddade i signaturen, så de återstående utfärdarna måste hämtas via de adresser GetCertificateIssuerURLs rapporterar

Hur mycket av /Contents är egentligen CMS?

Bara prefixet som den yttre SEQUENCE:en deklarerar tillhör CMS:en, och PLTrimCMSPadding kapar allt efter den. En signerare reserverar hex-strängen i /Contents innan CMS:en finns, eftersom /ByteRange som beskrivs i ISO 32000-1 §12.8.1 måste vara fast först, så facket dimensioneras generöst och den oanvända svansen är nollor. PLTrimCMSPadding läser första TLV:n, kräver tagg $30 och returnerar bytena fram till slutet av elementet; allt som inte börjar med en välbildad SEQUENCE kommer tillbaka tomt. Bara den toppnivån är platsen där avslutande byte är lagliga, och distinktionen spelar roll för nästa avsnitt: en strikt regel om att "elementet måste konsumera hela bufferten" skulle avvisa varje signatur ur verkligheten, medan en slapp regel tillämpad på varje djup låter nästlade fält läsa byte de inte äger

PDFlibPas PLTrimCMSPadding läser första TLV:n i den reserverade /Contents-hex-strängen, kräver tagg $30 och kapar nollutfyllnaden vid längden som den yttre SEQUENCE:en deklarerar, och returnerar tomt resultat när bufferten inte börjar med en välbildad SEQUENCE
Avslutande byte är lagliga bara på toppnivån, där det reserverade facket måste förbli fast för /ByteRange — djupare läsningar får i stället den föräldrabundna regeln

Varför behöver en DER-läsare förälderns slutoffset?

Ett nästlat element är bara giltigt om det slutar inuti sin förälder, och en kontroll mot buffertens slut bevisar inte det. Den lågnivå-DERReadTLV som finns i PDFlibASN1 avgränsar varje element mot hela strängen, vilket är rätt kontroll för yttersta objektet och fel kontroll för allt under det. Föreställ dig en SignerInfo vars issuerAndSerialNumber deklarerar 40 byte medan issuer-Name:n inuti den påstår 60. Varje byte ligger fortfarande i bufferten, så en buffertbunden läsare accepterar Name:n, läser serienumret ur den digest-algoritm som följer och jämför sedan det paret mot de inbäddade certifikaten. Före v3.539.10 läste CMS-walkern på exakt det sättet. Fixen är en liten wrapper som bär förälderns slutposition in i varje läsning

PDFlibPas avgränsar varje nästlad DER-läsning med sitt förälderelement: en issuer-Name på 60 byte inuti ett issuerAndSerialNumber på 40 byte accepteras av den gamla buffertbundna DERReadTLV, som sedan läser serienumret ur digestAlgorithm, medan ReadTLVWithin vägrar varje element som slutar efter ParentEnd
Inuti bufferten är minnessäkerhet, inuti föräldern är korrekthet — PDFlibPas trär förälderns slutoffset genom varje CMS-nivå så att en fientlig längd inte kan låna en grannes byte
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // inget kvar inuti föräldern: vägra att påbörja en läsning
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset ligger nu ett steg efter elementet; det får inte passera föräldern
  Result := Offset <= ParentEnd;
end;

// varje nivå antecknar sitt eget slut och skickar det vidare:
//   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

Uniten PDFlibCMSRead bär nu de där ändpunkterna genom ContentInfo, [0] EXPLICIT-wrappern, SignedData-fälten upp till signerInfos, SignerIdentifier i båda sina former issuerAndSerialNumber och [0] subjectKeyIdentifier (RFC 5652 §5.3), samt tbsCertificate-fälten som läses ur varje inbäddat certifikat när signeraren matchas. Inuti certifikatsmängden och signerInfos-mängden stannar loopen för ett element som passerar mängdens slut: PLExtractCMSCertificates returnerar certifikaten den redan accepterat och limmar aldrig fast följande crls- eller signerInfos-byte på den sista. Matchningen issuer plus serienummer kräver också båda halvorna, eftersom ett serienummer bara är unikt inom en utfärdare

Varför blev 2.999.3 till 1.15.3?

En OIDs två första arcs slås ihop till en subidentifierare, inte till en byte, och den subidentifieraren kodas i base-128 som alla andra arcs. X.690 §8.19.4 definierar den som 40 * arc1 + arc2; den tidigare DER_OID skrev det värdet med Byte(...), vilket bara stämmer upp till 127, värdet för 2.47. För 2.999 är summan 1079, byte-casten behåller 55, och 55 avkods som 1.15, så identifieraren namnger i tysthet en annan gren av trädet. Värden från 128 till 255 fallerar på ett annat sätt: en byte med continuation-bitten satt sänds ut och sväljer nästa arc. De flesta PKI-identifierare (1.2.840..., 2.5.29..., 0.4.0...) når aldrig gränsen, vilket är därför buggen överlevde; joint-iso-itu-t-arcs från 2.48 och uppåt gör det. DER_OID står i tjänst både hos kodaren för signed attributes och hos matcharen i DERFindExtensionByOID och kontrollen av SignedData content-type, så en felaktig kodning bröt både skrivning och uppslagning

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 slår ihop en OIDs två första arcs som 40 * arc1 + arc2 och kodar summan i base-128 i en UInt64, så 2.999.3 blir 06 03 88 37 03, medan den gamla Byte-casten behöll 55 och tyst avkodde identifieraren som 1.15.3
De flesta PKI-arcs når aldrig gränsen, vilket är därför buggen överlevde — joint-iso-itu-t-arcs från 2.48 och uppåt behöver två byte, och testet placerar 2.47 och 2.48 på varsin sida om den

Det sammanslagna värdet ligger avsiktligt i en UInt64. DER_OID parsar arcs till Int64, så en giltig andra arc kan vara lika stor som Int64.MaxValue, och att addera 80 för arc1 = 2 spiller över ett signed 64-bitars heltal. En UInt64 bär Int64.MaxValue + 80 utan att wrappa runt, och tiobytes-scratchbufferten rymmer de tio 7-bitarsgrupper som ett 64-bitars värde behöver. Testvektorer värda att behålla är de på varsin sida om gränsen: 2.47 måste förbli en byte, och 2.48 måste bli två

Vad garanterar CMS-walkern på lässidan?

PDFlibCMSRead garanterar strukturen och inget annat: den returnerar byte som ligger där RFC 5652 säger att de ska ligga och verifierar varken signatur, digest eller giltighetstid. Walkern accepterar bara DER, så DERReadTLV avvisar obestämda längder och flerbyttaggnummer, och en BER-kodad CMS från en regelvidrig signerare rapporterar noll certifikat i stället för en partiell gissning. Attributcertifikat och de övriga CertificateChoices-alternativen hoppas över eftersom ingenting nedströms kan använda dem. Den kryptografiska verifieringen stannar hos koden som äger den, vilket börjar med byte-täckningskontrollerna som beskrivs i PAdES-signering och ByteRange-validering i Delphi och fortsätter med att klassificera vad som ändrats efter att en PDF signerats

Den vidare lärdomen gäller alla binära format: "inuti bufferten" är en minnessäkerhetsegenskap, "inuti föräldern" är en korrekthetsegenskap, och en parser behöver båda. Samma tänk kring fientliga längder löper genom att härda en Pascal-PDF-parser mot skadliga filer. Certifikatextraktionen, kedjebyggandet och long-term validation-API:erna som tas upp här följer med losLab PDF Library for Delphi, för Delphi, C++Builder och Lazarus