Technisch artikel

DER-lengteoctetten nooit achterstevoren doorlopen in Delphi

PDFium Component vindt het begin van een geneste CMS-structuur via zijn contentlengte, nooit door achterstevoren over de lengteoctetten te lopen, omdat de byte vlak voor de content het laatste lengteoctet is en niets zegt over hoeveel eraan voorafgaan. CmsHeaderStart in FPdfCms.pas leidt de headerlengte in plaats daarvan af uit ContentLen, wat DER exact maakt, en dat is wat AddSignatureTimestampToCms ervan weerhoudt elke CMS te beschadigen waarvan de certificaatset langer is dan 127 bytes

De context is de PAdES B-T-upgrade. Een signature-time-stamp-attribuut, het attribuut dat ETSI EN 319 122-1 clausule 5.3 definieert onder OID 1.2.840.113549.1.9.16.2.14, moet terechtkomen in de unsignedAttrs van de SignerInfo die RFC 5652 clausule 5.3 beschrijft, en per definitie kan het pas worden toegevoegd nadat de handtekeningwaarde bestaat, want het tijdstempel-token wordt over die waarde berekend. De CMS is dus al opgebouwd en al ondertekend wanneer het token aankomt. Eén attribuut toevoegen verandert de lengte van de SignerInfo, wat de lengte van de signerInfos-SET verandert, dan die van SignedData, dan die van de [0]-EXPLICIT-wrapper, dan die van de buitenste ContentInfo. Elke omvattende header moet opnieuw worden uitgeschreven, en alles wat niet op dat pad ligt moet byte voor byte worden overgedragen. De walkthrough van B-LT en B-LTA behandelt wat het token je oplevert; dit artikel gaat over de vier bytes vóór de certificaatset die de herbouw steeds verkeerd deed

Waarom heeft het toevoegen van een tijdstempel de tag-offset van een buurelement nodig?

Omdat de herbouw vier buurelementen van de signerInfos-SET woordelijk hergebruikt, en de reader meldt waar hun content staat, niet waar hun tag staat. TDerReader.ReadTlv geeft de tagbyte, de contentoffset, de contentlengte en de offset van de volgende TLV terug. Dat is het juiste oppervlak om in een structuur af te dalen, maar om een heel element te kopiëren heb je het octet nodig waar zijn tag staat, en het enige wat een aanroeper heeft is ContentOffs. CmsSliceTlv bestaat om die kloof te overbruggen: gegeven een contentoffset en -lengte geeft het tag, lengteoctetten en content als één buffer terug, en AddSignatureTimestampToCms roept het aan voor de contentType-OID, het version-INTEGER, de digestAlgorithms-SET, de encapContentInfo-SEQUENCE en, wanneer aanwezig, de certificates [0]-set

// In AddSignatureTimestampToCms: daal af, knip de buurelementen woordelijk uit
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
  raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// optionele certificates [0]
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
  if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
    raise Exception.Create('CMS: certificates [0] malformed');
  SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL);   // tag + lengteoctetten + content
  R.Position:= CN;
end;

Van die vijf slices zijn er vier piepklein: een OID van elf bytes, een INTEGER van drie bytes, een digest-algoritmeset van zeventien bytes, een detached encapContentInfo van dertien bytes. De certificaatset is degene die het certificaat van de ondertekenaar en zijn keten draagt, en een echt X.509-certificaat loopt op zijn minst enkele honderden bytes. De certificaatset is dus de enige slice waarvan de lengteoctetten ooit in de lange vorm staan, en het is de slice die de oude helper niet kon vinden

Wat het toevoegen van een PAdES B-T-tijdstempel in een CMS herbouwt: CmsSliceTlv kopieert contentType, version, digestAlgorithms, encapContentInfo en de certificaatset byte voor byte, de certificaatset is de enige slice die lang genoeg is om de korte lengtevorm te verlaten, en elke omvattende header van SignerInfo tot ContentInfo wordt opnieuw uitgeschreven
Het ondertekende deel blijft door zijn constructie onaangeroerd omdat het SignerInfo-voorstuk tot en met de handtekening-OCTET STRING woordelijk wordt gekopieerd, dus een validator die signedAttrs opnieuw hasht ziet identieke bytes voor en na het landen van de tijdstempel

Waarom kun je DER-lengteoctetten niet achterstevoren doorlopen?

Omdat het aantal lengteoctetten in het eerste ervan is opgeslagen, en als je vanaf de content terugleest kom je het laatste als eerste tegen. X.690 clausule 8.1.3.4 definieert de korte vorm: één octet, bit 8 nul, bits 7 tot en met 1 bevatten een lengte van 0 tot 127. Clausule 8.1.3.5 definieert de lange vorm: een eerste octet met bit 8 gezet waarvan bits 7 tot en met 1 het aantal volgende octetten geven, gevolgd door die octetten die de lengte als een unsigned big-endian integer dragen. Niets in de regel markeert een volgend octet als volgend. Zijn bit 8 is een magnitude-bit als elke andere, dus een achterstevorenwandeling die de bovenste bit van Buf[ContentOffs- 1] test, test een databit en leest daarna de onderste zeven bits als telling

// De oude helper, die alleen de contentoffset kreeg
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // komt uit op het LAATSTE lengteoctet
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // alleen zinvol voor het EERSTE
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Header van een certificaatset van 1500 bytes:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 gezet, $DC and $7F= 92
//   Result= ContentOffs- 94     (de tag staat op ContentOffs- 4)

Neem de header van een certificaatset met 1500 bytes aan certificaten, A0 82 05 DC. De wandeling komt uit op DC, ziet een gezette bovenste bit, haalt 92 uit de onderste zeven bits en meldt de tag 94 bytes vóór de content, terwijl hij 4 bytes ervoor staat. In een SignedData die door BuildSignedData is opgebouwd zit de content van de certificaatset pas een paar dozijn bytes in de CMS, dus de berekende offset was niet alleen te vroeg maar negatief, en de oude code beschermde ContentOffs- 1 tegen onder nul gaan, niet zijn eindresultaat. CmsSliceTlv nam daarna een stuk van ruim negentig bytes langer dan het element, beginnend vóór de buffer, en de herbouwde SignedData droeg dat stuk waar zijn certificaatset had moeten staan. Een lengte van drie octetten waarvan het laatste octet toevallig onder $80 viel, zeg A0 82 05 10, faalde de andere kant op: de wandeling zag het aan voor een octet in korte vorm en begon de slice bij 05, twee bytes te laat en binnen de lengteoctetten, zonder enige tag. De uitkomst was hoe dan ook fout, alleen de richting verschilde

Waarom DER-lengteoctetten niet achterstevoren te doorlopen zijn: A0 82 05 DC vanaf het eind lezen komt uit op het laatste octet DC, waarvan de gezette bovenste bit een onzinnige telling van 92 oplevert en de tag 94 octetten te vroeg plaatst, terwijl A0 82 05 10 de andere kant op faalt en de slice twee octetten te laat binnen de lengteoctetten laat beginnen
De oude CmsHeaderStart bewaakte de tussenliggende aftrekking in plaats van zijn eindresultaat, dus een slice kon zelfs vóór de buffer beginnen, en de herbouwde SignedData droeg dat stuk waar zijn certificaatset hoorde

Wat garandeert DER dat de voorwaartse afleiding exact maakt?

DER garandeert dat de lengtecodering een zuivere functie van de lengte is. X.690 clausule 10.1 beperkt DER tot de definitieve vorm en eist het minimum aantal octetten, wat de twee vrijheden die BER toestaat wegneemt: de onbepaalde vorm, en het opvullen van een lengte in lange vorm met leidende nul-octetten. Onder die regel heeft een contentlengte onder 128 precies één lengteoctet, en elke andere lengte heeft één eerste octet plus precies zoveel volgende octetten als de lengte significante bytes nodig heeft. De aanroeper van CmsHeaderStart heeft ContentLen al, want ReadTlv gaf die net terug, dus de headerlengte is te berekenen zonder naar één byte van de buffer te kijken

De voorwaartse afleiding die DER garandeert: een contentlengte van 127 codeert als A0 7F, 128 als A0 81 80, 255 als A0 81 FF, 256 als A0 82 01 00 en 1500 als A0 82 05 DC, dus de headerlengte volgt alleen uit ContentLen en TryReadTlvAt heeft elke niet-minimale BER-vorm al geweigerd
Fixtures opgebouwd uit certificaten van 32 en 64 bytes bleven binnen de korte vorm waar de achterstevorenwandeling om de verkeerde reden correct antwoordt, en daarom kruist de grenssuite nu 127, 128, 255 en 256 bytes
// De uitgebrachte helper: leid de header af uit de contentlengte.
// Onder X.690 10.1 zijn de lengteoctetten een functie van ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // korte vorm, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // het eerste octet, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // één per significante byte
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Twee details maken dit veilig in plaats van alleen aannemelijk. Ten eerste wordt de aanname dat de invoer DER is stroomopwaarts afgedwongen: TDerReader.TryReadTlvAt, waarop ReadTlv is gebouwd, weigert de onbepaalde vorm, weigert een lengte in lange vorm waarvan het eerste volgende octet nul is, en weigert een enkel volgend octet onder $80. Een TLV die CmsSliceTlv bereikt is al door die controles gekomen, dus een niet-minimale lengte in BER-stijl kan de afleiding niet bereiken en hem niet laten liegen. Ten tweede bewaakt de terugval bij een negatief resultaat nu het echte antwoord, niet een tussenwaarde. Het is het vermelden waard dat de reader de tagooffset al die tijd kende: TDerTlv draagt zowel Offset als HeaderLength, en alleen het ReadTlv-oppervlak met vier uit-parameters laat ze vallen. Ze teruggeven zou de schonere interface op lange termijn zijn; de uitgebrachte fix houdt dat oppervlak intact en maakt de helper correct op zijn eigen voorwaarden

Waarom slaagden de tijdstempeltests terwijl de bug er zat?

Omdat elk fixture-certificaat kort genoeg was voor de korte vorm, en de achterstevorenwandeling is precies in dat geval correct. Tests.PadesTimestamp.pas bouwt zijn ondertekenaarcertificaat met SetLength(SignerCertDer, 32) in de ene test en 64 in een andere, gevuld met een byte-ramp. Een certificaatset van 32 bytes codeert als A0 20 en een van 64 bytes als A0 40, elk met één lengteoctet. Achterstevoren vanaf de content kom je op dat ene octet uit, zijn bovenste bit is nul omdat het het eerste en enige lengteoctet is, en de helper antwoordt correct om de verkeerde reden. De suite van 1414 cases was groen, de CMS met tijdstempel parseerde, de validator van fase 1 meldde B-T, en al die controles liepen tegen een certificaatset die nooit in een echt document heeft gezeten

De algemene regel is het nuttige deel. Wanneer een codepad afhangt van hoe een lengte is gecodeerd, moet de fixture de coderingsgrens oversteken, en voor DER betekent dat content langer dan 127 bytes, wat de lange vorm afdwingt, en idealiter ook langer dan 255 bytes, wat een tweede volgend octet afdwingt. Dezelfde discipline geldt voor het andere geval in die review waar zelfverificatie een DER-afwijking niet kon zien: de ongesorteerde SET OF in signedAttrs was onzichtbaar voor een round trip binnen dezelfde bron om een structureel identieke reden, de test oefende alleen invoeren waarop de foute code en de juiste code het eens zijn. Het schetsje hieronder roept de slice-helper rechtstreeks aan, wat betekent dat hij voor de testbuild uit FPdfCms.pas moet worden geëxporteerd; dezelfde grens is via het publieke oppervlak te bereiken door BuildSignedData een kettingcertificaat van elke grootte te geven en het resultaat met tijdstempel opnieuw te parsen

// Leg de grens vast: een slice door een header in lange vorm moet bij de tag beginnen
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);

procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
  W: TDerWriter;
  Content, Tlv: TBytes;
  I, Len: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      Len:= Lens[I];
      SetLength(Content, Len);
      Tlv:= W.Wrap($A0, Content);            // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      // de content begint direct na de header; de slice moet de hele TLV zijn
      Assert.AreEqual(Length(Tlv),
        Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
        'slice through header of a '+ IntToStr(Len)+ '-byte content');
    end;
  finally
    W.Free;
  end;
end;

Waar de herbouw nog steeds zijn grenzen trekt

AddSignatureTimestampToCms is geschreven voor de CMS die BuildSignedData uitspuugt, en zijn grenzen volgen daaruit. De wandeling verwacht één SignerInfo en schrijft alleen die ene opnieuw uit, dus een vreemde CMS met meerdere ondertekenaars zou met één ondertekenaar terugkomen; hij herkent een optionele certificates [0]-set maar geen crls [1]-set, en een CMS die er een draagt faalt luidruchtig met de exception signerInfos SET expected in plaats van stil verkeerd te slicen. De nieuwe unsignedAttrs bevat één attribuut, dus aan de ordeningsregel voor SET OF van X.690 clausule 11.6 is triviaal voldaan en er is geen sortering nodig. En het ondertekende deel blijft door zijn constructie onaangeroerd: het SignerInfo-voorstuk tot en met de handtekening-OCTET STRING wordt woordelijk gekopieerd, en daarom ziet een validator die signedAttrs opnieuw hasht dezelfde bytes voor en na het toevoegen van de tijdstempel. Wanneer er alsnog eentje het document afkeurt, liggen de oorzaken meestal elders en verdienen ze hun eigen checklist

De DER-reader, de writer, de CMS-builder en deze tijdstempelinjectie worden allemaal als Pascal-bron meegeleverd met de PDFium Delphi component, en een bug van deze vorm is het argument daarvoor: wanneer een herbouwde SignedData er negentig bytes te lang uitkomt, wil je de helper lezen die de slice knipte en de clausule van X.690 die hij verkeerd las, niet een stacktrace uit een black box