Technisch artikel

CMS-handtekeningen in Delphi: DER-grenzen en OID-arcs

PDF Library for Delphi (PDFlibPas) haalt de certificaten binnen een PDF-handtekening op met een pure DER-walk over de CMS SignedData in /Contents, zonder dat CryptoAPI eraan te pas komt. Sinds v3.539.10 wordt elke geneste read begrensd door zijn parent-element, wordt de nulopvulling na de CMS afgekapt op de lengte die de CMS zelf aangeeft, en coderen object identifiers hun gecombineerde eerste subidentifier in base-128. De grensregel en de OID-fix vervingen allebei code die foute antwoorden produceerde zonder een fout te melden, en de opvullingsregel zorgt ervoor dat de strengere lezer echte handtekeningen niet afwijst

De leeszijde is belangrijker dan hij lijkt. Long-term validation-tooling moet het ondertekenaarcertificaat en zijn issuers uit een bestaande handtekening halen voordat het revocation-data kan ophalen, een auditraapport moet kunnen zeggen wie er ondertekend heeft, en een Lazarus-build op Linux heeft geen Windows-messagefuncties om op terug te vallen. Een parser in die positie crasht zelden op slechte invoer. De foutmodus die pijn doet is een certificaattelling die bytes van een buur meeneemt, een ondertekenaar-match tegen het verkeerde veld, of een OID die stilletjes in een andere OID verandert. Een handtekeningpijplijn daar bovenop rapporteert overtuigende onzin

De ondertekenaarcertificaten uit een ondertekende PDF lezen

Vijf TPDFlib-methoden dekken de leeszijde, en ze nemen allemaal InputFile, Password, FieldName: elke aanroep opent het bestand alleen-lezen, geeft antwoord en sluit het weer. GetSignatureEmbeddedCertificateCount en GetSignatureEmbeddedCertificateDER sommeren de certificaten-set op in encoding-volgorde, GetSignatureSignerCertificateDER geeft het certificaat terug dat een gegeven SignerInfo produceerde, en GetSignatureCertificateChainLength / GetSignatureCertificateChainDER lopen vanaf die ondertekenaar richting de verste issuer die de handtekening zelf meedraagt. Indexen zijn zero-based. Bewaar de resultaten in een AnsiString, en dat is ook precies waarom de library ze zo teruggeeft: een DER-blob die via string of een TStrings gaat, passeert een tekensetconversie en komt gecorrumpeerd terug

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;

Aan die uitvoer zitten twee dingen die aandacht vragen. Een telling van 0 is geen diagnose: een ontbrekend veld, een verkeerd wachtwoord, een blob die geen DER is en een SignedData die de optionele certificaten-set simpelweg weglaat, komen allemaal terug als 0 of een lege string, dus log de veldnaam naast het getal. En een keten die eindigt vóór een self-issued certificaat is ook geen fout. De chain builder gebruikt alleen certificaten die in de handtekening zitten, dus de resterende issuers moeten opgehaald worden via de adressen die GetCertificateIssuerURLs meldt

Hoeveel van /Contents is eigenlijk de CMS?

Alleen het prefix dat de buitenste SEQUENCE aangeeft hoort bij de CMS, en PLTrimCMSPadding kapt alles daarna af. Een ondertekenaar reserveert de hex-string van /Contents voordat de CMS bestaat, omdat de /ByteRange uit ISO 32000-1 §12.8.1 eerst vast moet liggen, dus de plek wordt ruim bemeten en de ongebruikte staart bestaat uit nullen. PLTrimCMSPadding leest de eerste TLV, eist tag $30 en geeft de bytes tot het einde van dat element terug; alles wat niet met een goed gevormde SEQUENCE begint, komt leeg terug. Dat topniveau is de enige plek waar bytes aan het einde legaal zijn, en dat onderscheid is belangrijk voor de volgende paragraaf: een strenge "het element moet de hele buffer opeten"-regel zou elke handtekening uit de praktijk afwijzen, terwijl een soepele regel op elke diepte geneste velden bytes laat lezen die niet van hen zijn

PDFlibPas PLTrimCMSPadding leest de eerste TLV van de gereserveerde /Contents-hex-string, eist tag $30 en kapt de nulopvulling af op de lengte die de buitenste SEQUENCE aangeeft, en geeft een leeg resultaat terug wanneer de buffer niet met een goed gevormde SEQUENCE begint
Bytes aan het einde zijn alleen op het topniveau legaal, waar de gereserveerde plek vast moet blijven voor /ByteRange — diepere reads krijgen in plaats daarvan de parent-begrensde regel

Waarom heeft een DER-lezer de eind-offset van de parent nodig?

Een genest element is alleen geldig als het binnen zijn parent eindigt, en controleren tegen het einde van de buffer bewijst dat niet. De low-level DERReadTLV in PDFlibASN1 begrenst elk element tegen de hele string, wat voor het buitenste object de juiste controle is en voor alles eronder de verkeerde. Denk aan een SignerInfo waarvan issuerAndSerialNumber 40 bytes aangeeft terwijl de issuer Name daarbinnen 60 claimt. Elke byte zit nog in de buffer, dus een bufferbegrensde lezer accepteert de Name, leest het serienummer uit het digest-algoritme dat erop volgt, en vergelijkt dat paar vervolgens met de ingesloten certificaten. Vóór v3.539.10 las de CMS-walker precies zo. De fix is een kleine wrapper die de eindpositie van de parent in elke read meedraagt

PDFlibPas begrenst elke geneste DER-read op zijn parent-element: een 60-byte issuer Name binnen een 40-byte issuerAndSerialNumber wordt door de oude bufferbegrensde DERReadTLV geaccepteerd, die daarna het serienummer uit digestAlgorithm leest, terwijl ReadTLVWithin elk element dat voorbij ParentEnd eindigt weigert
Binnen de buffer is memory safety, binnen de parent is correctheid — PDFlibPas voert de eind-offset van de parent door op elk CMS-niveau, zodat een vijandige lengte geen bytes van een buur kan lenen
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // niets meer over binnen de parent: weiger een read te starten
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset staat nu één voorbij het element; hij mag de parent niet passeren
  Result := Offset <= ParentEnd;
end;

// elk niveau noteert zijn eigen einde en geeft dat door:
//   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

De unit, PDFlibCMSRead, voert die eindes nu door via ContentInfo, de [0] EXPLICIT-wrapper, de SignedData-velden tot aan signerInfos, de SignerIdentifier in zowel zijn issuerAndSerialNumber- als [0] subjectKeyIdentifier-vorm (RFC 5652 §5.3), en de tbsCertificate-velden die bij het matchen van de ondertekenaar uit elk ingesloten certificaat gelezen worden. Binnen de certificaten-set en de signerInfos-set stopt een element dat voorbij het einde van de set loopt de lus: PLExtractCMSCertificates geeft de certificaten terug die hij al geaccepteerd had en plakt de daaropvolgende crls- of signerInfos-bytes nooit aan de laatste vast. De issuer-and-serial-match vereist ook allebei de helften, want een serienummer is alleen uniek binnen één issuer

Waarom kwam 2.999.3 eruit als 1.15.3?

De eerste twee arcs van een OID combineren tot één subidentifier, niet tot één byte, en die subidentifier wordt net als elke andere arc in base-128 gecodeerd. X.690 §8.19.4 definieert hem als 40 * arc1 + arc2; de oudere DER_OID schreef die waarde met Byte(...), wat alleen klopt tot 127, de waarde van 2.47. Voor 2.999 is de som 1079, de byte-cast houdt 55 over, en 55 decodeert als 1.15, dus de identifier benoemt stilletjes een andere tak van de boom. Waarden van 128 tot 255 gaan op een andere manier mis: ze sturen één byte uit met de continuation bit gezet, die de volgende arc inslikt. De meeste PKI-identifiers (1.2.840..., 2.5.29..., 0.4.0...) komen de grens nooit tegen, en daarom is de bug overleefd; de joint-iso-itu-t-arcs vanaf 2.48 wel. DER_OID dient zowel de encoder voor signed attributes als de matcher in DERFindExtensionByOID en de SignedData content-type-controle, dus een verkeerde encoding brak zowel het schrijven als het opzoeken

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 combineert de eerste twee OID-arcs als 40 * arc1 + arc2 en codeert de som in base-128 in een UInt64, zodat 2.999.3 uitkomt als 06 03 88 37 03, terwijl de oude Byte-cast 55 overhield en de identifier stilletjes als 1.15.3 decodeerde
De meeste PKI-arcs komen de grens nooit tegen, en daarom overleefde de bug — de joint-iso-itu-t-arcs vanaf 2.48 hebben twee bytes nodig, en de test houdt 2.47 en 2.48 aan weerszijden van de grens

De gecombineerde waarde zit met opzet in een UInt64. DER_OID parst arcs in Int64, dus een legale tweede arc kan net zo groot zijn als Int64.MaxValue, en er 80 bij optellen voor arc1 = 2 laat een signed 64-bit integer overlopen. UInt64 draagt Int64.MaxValue + 80 zonder te wrappen, en de scratch-buffer van tien bytes herbergt de tien 7-bit groepen die een 64-bit waarde nodig heeft. Testvectoren die de moeite van bewaren waard zijn, staan aan weerszijden van de grens: 2.47 moet één byte blijven, en 2.48 moet er twee worden

Wat garandeert de CMS-walker aan de leeszijde?

PDFlibCMSRead garandeert structuur en niets anders: hij geeft bytes terug die zitten waar RFC 5652 zegt dat ze horen, en verifieert geen handtekening, digest of geldigheidsperiode. De walker accepteert alleen DER, dus DERReadTLV wijst indefinite lengths en multi-byte tagnummers af, en een BER-gecodeerde CMS van een niet-conforme ondertekenaar meldt nul certificaten in plaats van een half gegokt antwoord. Attribute certificates en de andere CertificateChoices-alternatieven worden overgeslagen, omdat er stroomafwaarts niets is dat ze kan gebruiken. Cryptografische verificatie blijft bij de code die ervoor verantwoordelijk is, te beginnen met de byte coverage-controles uit PAdES-ondertekening en ByteRange-validatie in Delphi en daarna het classificeren van wat er verandert nadat een PDF is ondertekend

De bredere les geldt voor elk binair formaat: "binnen de buffer" is een memory-safety-eigenschap, "binnen de parent" is een correctheidseigenschap, en een parser heeft allebei nodig. Hetzelfde denken over vijandige lengtes loopt door het verharden van een Pascal PDF-parser tegen kwaadaardige bestanden. De certificaatextractie, de chain building en de long-term validation-API's die hier behandeld worden, schepen mee met losLab PDF Library for Delphi, voor Delphi, C++Builder en Lazarus