Technisch artikel

DER-lengteoctetten nooit achterstevoren doorlopen in Delphi

De vier bytes A0 82 05 DC openen een contextspecifiek [0]-element met 1500 bytes content. Van links naar rechts gelezen zijn ze ondubbelzinnig: tag A0, een eerste lengteoctet 82 dat er twee meer aankondigt, dan 05 DC. Van rechts naar links gelezen, startend bij de eerste contentbyte, is het eerste octet dat je tegenkomt DC. Zijn topbit staat aan en zijn laagste zeven bits zeggen 92, dus een helper die hem vertrouwt meldt de tag 94 bytes vóór de content. De echte afstand is 4

Niemand loopt met opzet achterstevoren. Het gebeurt omdat de meeste DER-lezers de content-offset en lengte van elk element teruggeven, precies wat je nodig hebt om erin af te dalen, terwijl het ongewijzigd kopiëren van een sibling-element de offset van zijn tag vraagt. Van de content-offset aftrekken lijkt een klus van twee regels, en het blijft correct zolang elke lengte in één octet past. De lengte van een echte certificaatset past nooit in één

Wat een signature-timestamp doet met een ondertekende CMS

Het toevoegen van een PAdES B-T-timestamp forceert dat elke omhullende header van de CMS opnieuw wordt gecodeerd, terwijl vijf sibling-elements byte voor byte moeten meekomen. Het attribuut signature-time-stamp dat ETSI EN 319 122-1 clausule 5.3 definieert onder OID 1.2.840.113549.1.9.16.2.14 woont in de unsignedAttrs van de SignerInfo uit RFC 5652 clausule 5.3, en het RFC 3161-token erin wordt berekend over de handtekeningswaarde. De CMS staat er dus al en is al ondertekend wanneer het token van de TSA komt. Eén extra attribuut maakt de SignerInfo langer, wat de signerInfos-SET langer maakt, dan SignedData, dan de [0] EXPLICIT-wrapper, dan de buitenste ContentInfo

In PDFium Component is die sequentie drie geëxporteerde aanroepen in FPdfCms.pas en FPdfTsa.pas. SignPadesBytes draait ze voor je wanneer TPadesSignOptions.Level op plB_T of hoger staat en TsaUrl is gezet, en hij gooit EPadesCrypto wanneer een B-T-niveau wordt gevraagd met een lege TsaUrl in plaats van een kale B-B-handtekening weg te schrijven. Bouw je de CMS zelf met BuildSignedData, dan ziet de keten er zo uit

// CmsDer bevat de ContentInfo die BuildSignedData teruggeeft.
// SignPadesBytes voert dezelfde drie stappen uit wanneer Options.Level >= plB_T
var
  SigValue: TBytes;
  Ts: TPadesTimestamp;
begin
  if not ExtractSignatureValue(CmsDer, SigValue) then
    raise Exception.Create('CMS carries no signature value');
  // De messageImprint van de TSA wordt berekend over de handtekeningswaarde-octetten
  Ts:= RequestTimestamp(TsaUrl, SigValue, Nonce);
  // Codeert SignerInfo tot ContentInfo opnieuw, kopieert de siblings ongewijzigd
  AddSignatureTimestampToCms(CmsDer, Ts.TokenDer);
end;

AddSignatureTimestampToCms laat vijf elementen onaangetast meekomen: de contentType-OID, de SignedData-versie-INTEGER, de digestAlgorithms-SET, de encapContentInfo-SEQUENCE en, indien aanwezig, de certificates [0]-set. In de CMS die BuildSignedData met SHA-256 uitzendt, zijn vier daarvan piepklein: 11 bytes voor de OID, 3 voor de versie, 17 voor de digest-algoritmeset, 13 voor de detached encapContentInfo. De certificaatset bevat het ondertekenaarscertificaat gevolgd door elk ketencertificaat dat je doorgaf. Een echt X.509-certificaat loopt op tot honderden bytes. Dit is de enige slice waarvan de lengteoctetten altijd de lange vorm gebruiken

Wat het toevoegen van een PAdES B-T-timestamp in een CMS herbouwt: AddSignatureTimestampToCms kopieert contentType, versie, 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 omhullende header van SignerInfo tot ContentInfo wordt opnieuw uitgezonden
Het ondertekende deel is uit overwegingen onaangetast, want het SignerInfo-prefix tot aan de signature OCTET STRING wordt letterlijk gekopieerd, dus een validator die signedAttrs opnieuw digeert, ziet identieke bytes voor en nadat de timestamp landt

Waarom kun je DER-lengteoctetten niet achterstevoren doorlopen?

Het aantal lengteoctetten staat opgeslagen in het eerste ervan, en een achterwaartse wandeling ontmoet het laatste eerst. X.690 clausule 8.1.3.4 definieert de korte vorm: één octet, bit 8 uit, bits 7 tot 1 bevattend een lengte van 0 tot 127. Clausule 8.1.3.5 definieert de lange vorm. Zijn eerste octet heeft bit 8 aan, en bits 7 tot 1 tellen de volgende octetten die de lengte als unsigned big-endian integer bevatten. Niets markeert een volgend octet als volgend. Zijn bit 8 is een magnituden-bit net als de andere zeven. Dus een helper die de topbit van Buf[ContentOffs- 1] test, test eerst data en leest dan data als telling

// De verleidelijke helper, met alleen de content-offset. Niet verscheepen
function NaiveHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte: Integer;
begin
  P:= ContentOffs- 1;            // landt op het LAATSTE lengteoctet
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // alleen zinvol voor het EERSTE octet
    Result:= P- 1
  else
    Result:= P- (LenByte and $7F)- 1;
end;

// A0 82 05 DC (1500 bytes): Buf[ContentOffs- 1]= $DC, $DC and $7F= 92
//   Result= ContentOffs- 94, de tag staat op ContentOffs- 4
// A0 82 05 10 (1296 bytes): $10 heeft bit 8 uit, gelezen als korte vorm
//   Result= ContentOffs- 2, wat wijst naar 05 binnenin de lengteoctetten

Beide faalrichtingen komen naar boven bij gewone groottes. Een certificaatset van 1500 bytes eindigt zijn header in DC, en de helper start de slice 90 bytes te vroeg. Een set van 1296 bytes eindigt in 10, de helper houdt hem voor een korte-vorm-lengte aan, en de slice begint twee bytes te laat zonder enige tag. Of een gegeven keten in het eerste of het tweede geval belandt, hangt van niets meer af dan de lage byte van zijn totale lengte

Het vroege geval kan ook van de voorkant van de buffer af lopen. Voor een CMS die is opgebouwd zoals BuildSignedData hem opbouwt, met elke omhullende lengte op twee octetten, begint de content van de certificaatset 60 bytes diep: 4 voor de ContentInfo-header, 11 voor de OID, 4 elk voor de headers van [0] en SignedData, dan 3, 17 en 13 voor de kleine siblings en 4 voor de header van de certificaatset. Trek 94 af van 60 en de helper geeft -34 terug. Zijn enige guard zit op ContentOffs- 1, dus het negatieve resultaat gaat rechtstreeks de kopie in

Waarom DER-lengteoctetten niet achterstevoren te doorlopen zijn: het lezen van A0 82 05 DC vanaf het einde landt op het laatste octet DC, waarvan de gezette topbit een onechte 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 start
Een achterwaartse wandeling die de tussenliggende aftrekking gardet in plaats van zijn eindresultaat kan zelfs een slice vóór de buffer starten, en de herbouwde SignedData houdt dan die slice over waar zijn certificaatset hoort

Wat X.690 clausule 10.1 vastpint

DER maakt van de lengtecodering een zuivere functie van de lengte. X.690 clausule 10.1 beperkt DER tot de definite vorm en eist het minimum aantal octetten, wat de twee vrijheden wegneemt die BER toestaat: de indefinite vorm, en een lange-vorm-lengte opgevuld met voorloop-nuloctetten. Onder die regel neemt een contentlengte onder 128 altijd exact één lengteoctet in. Elke andere lengte neemt één eerste octet plus exact zoveel volgende octetten als de waarde significante bytes heeft. Dat geeft 81 80 voor 128. Op 256 groeit de header naar 82 01 00, en 65536 heeft 83 01 00 00 nodig. Code die de contentlengte al vasthoudt, kan de headersize uitrekenen zonder één byte van de buffer te lezen

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 uit ContentLen alleen en TryReadTlvAt heeft elke niet-minimale BER-vorm al geweigerd
Content korter dan 128 bytes blijft binnen de korte vorm, waar een achterwaartse wandeling om de verkeerde reden correct antwoordt, dus grenstests moeten over 127, 128, 255 en 256 bytes heen

De afleiding is net zo goed als de claim dat de input echt DER is. TDerReader.TryReadTlvAt dwingt die claim af voordat er ook maar gesliced wordt. Hij weigert een eerste lengteoctet van $80 (de indefinite vorm), een lange-vorm-lengte waarvan het eerste volgende octet nul is, een enkel volgend octet onder $80 dat een korte vorm had moeten zijn, en meer dan vier volgende octetten. ReadTlv is gebouwd op TryReadTlvAt, dus elk element dat AddSignatureTimestampToCms doorloopt heeft die checks al gepasseerd. Een opgevulde BER-lengte bereikt de rekenkunst nooit

Binnenin AddSignatureTimestampToCms wordt de tag-offset van elke sibling zo afgeleid uit zijn content-offset en contentlengte. Wanneer de afgeleide start vóór het begin van de buffer valt, wat betekent dat de offsets niet bij elkaar kunnen horen, gooit de procedure een exception met de melding CMS: TLV header precedes the buffer start en produceert hij helemaal geen CMS. Een misvormde herbouw die nog steeds parst, is veel moeilijker te traceren dan een exception op het punt waar de offsets uit elkaar gingen

De afleiding draagt nog één aanname mee: de identifier neemt één octet. X.690 staat multi-octet-identifiers toe voor tagnummers van 31 en hoger, en TryReadTlvAt parst ze. CMS SignedData gebruikt er nooit één, want zijn tags zijn universele SEQUENCE, SET, INTEGER en OID plus de contextspecifieke [0] en [1], dus de aanname geldt voor elk element dat de timestamp-rebuild aanraakt

Sibling-elements kopiëren in je eigen DER-code

Schrijf je code tegen TDerReader, sla de afleiding dan helemaal over. PeekTlv en ReadNextTlv vullen een TDerTlv-record waarvan Offset al naar de tag wijst en waarvan HeaderLength de gemeten grootte van tag plus lengteoctetten is, en EncodedBytes geeft het element terug van Offset tot NextOffset. Dat pad omgaat ook met multi-octet-identifiers, die geen enkele hoeveelheid lengteberekening kan terugwinnen. De ReadTlv met vier out-parameters is handig om af te dalen. Hij meldt alleen de contentkant, dus pak hem wanneer je content nodig hebt en de recordvorm wanneer je het hele element nodig hebt

// Elk kind van SignedData als complete TLV: tag, lengteoctetten, content.
// TDerTlv.Offset wijst naar de tag, dus geen header-rekenkunst aan te pas
function SignedDataChildren(const CmsDer: TBytes): TDerByteArrays;
var
  Root, Ci, Explicit0, Sd: TDerReader;
  Tlv: TDerTlv;
begin
  Result:= nil;
  Root.Initialize(CmsDer);
  if not Root.ReadNextTlv(Tlv) or not Root.EnterConstructed(Tlv, Ci) then
    raise Exception.Create('ContentInfo SEQUENCE expected');
  if not Ci.SkipTlv then                            // contentType OID
    raise Exception.Create('contentType OID expected');
  if not Ci.ReadNextTlv(Tlv) or not Ci.EnterConstructed(Tlv, Explicit0) then
    raise Exception.Create('[0] EXPLICIT content expected');
  if not Explicit0.ReadNextTlv(Tlv) or not Explicit0.EnterConstructed(Tlv, Sd) then
    raise Exception.Create('SignedData SEQUENCE expected');
  while not Sd.AtEnd do
  begin
    if not Sd.ReadNextTlv(Tlv) then
      raise Exception.Create('SignedData child malformed');
    SetLength(Result, Length(Result)+ 1);
    Result[High(Result)]:= Sd.EncodedBytes(Tlv);   // bijv. A0 82 05 DC ...
  end;
end;

Voor de gevallen waarin een andere API je niets geeft dan een content-offset en een lengte, is de voorwaartse afleiding kort. DerHeaderStart, getoond met zijn grenscheck in de volgende sectie, is je eigen code; hij spiegelt de regel uit clausule 10.1 en geeft -1 terug voor offsets die niet consistent kunnen zijn, zodat de aanroeper een waarde heeft die hij kan weigeren in plaats van een geloofwaardige startbyte

Waarom verbergen korte testcertificaten de fout?

Een klein placeholder-certificaat houdt de certificaatset in de korte vorm, en de achterwaartse wandeling is exact voor dat geval correct. Een set van 32 bytes codeert als A0 20 en één van 64 bytes als A0 40. Teruglopen vanaf de content landt op dat ene lengteoctet, en zijn topbit is uit omdat hij de eerste en enige is. Juist antwoord, verkeerde reden. Een timestamp-test gebouwd op zo'n kleine certificaten kan het resultaat opnieuw parsen, het attribuut vinden, B-T melden, en nooit eens een certificaatset van realistische grootte door de slice duwen

Elke coderoute die afhangt van hoe een lengte is gecodeerd, heeft testinput nodig die de codeergrens oversteekt. Voor DER betekent dat content van 128 bytes of meer, wat de lange vorm forceert, en van 256 bytes of meer, wat een tweede volgend octet forceert. De check hieronder vergelijkt DerHeaderStart met de header die de reader mat tijdens het parsen, op beide grenzen en op het geval van 1500 bytes uit de opening. Een ongesorteerde SET OF in signedAttrs glipt langs roundtrips met dezelfde bron, om dezelfde structurele reden: de test voert alleen inputs waarop verkeerde code en goede code akkoord zijn

// Je eigen helper voor API's die alleen content-offset en lengte melden.
// Gaat uit van een één-octet-identifier, waar voor elke tag in CMS SignedData
function DerHeaderStart(ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  LengthOctets:= 1;                  // korte vorm, X.690 8.1.3.4
  if ContentLen>= 128 then
  begin
    Remaining:= ContentLen;          // lange vorm, X.690 8.1.3.5
    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:= -1;                     // inconsistente offsets: laat de aanroeper gooien
end;

// Grenscheck, geschreven als je eigen testcode: de afgeleide tag-offset
// moet gelijk zijn aan de offset die TDerReader mat tijdens het parsen van de header
procedure CheckHeaderDerivation;
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
var
  W: TDerWriter;
  R: TDerReader;
  Tlv: TDerTlv;
  Content: TBytes;
  I: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      SetLength(Content, Lens[I]);
      R.Initialize(W.Wrap($A0, Content));   // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      if not R.PeekTlv(Tlv) then
        raise Exception.CreateFmt('unreadable TLV for %d bytes', [Lens[I]]);
      if DerHeaderStart(Tlv.ContentOffset, Tlv.ContentLength)<> Tlv.Offset then
        raise Exception.CreateFmt('header start wrong for %d bytes', [Lens[I]]);
    end;
  finally
    W.Free;
  end;
end;

Dezelfde grens is end-to-end bereikbaar via de geëxporteerde functies. Geef BuildSignedData ketencertificaten waarvan de gecombineerde grootte de certificaatset net onder en net over 128 en 256 bytes zet, draai AddSignatureTimestampToCms op elk resultaat, loop dan de uitvoer met SignedDataChildren af en vergelijk het certificaatset-element byte voor byte met het exemplaar in de CMS voordat de timestamp werd toegevoegd

Wat AddSignatureTimestampToCms accepteert

AddSignatureTimestampToCms is geschreven voor de CMS die BuildSignedData uitzendt: één SignerInfo, een optionele certificates [0]-set, geen crls [1] en nog geen unsignedAttrs. Voer hem een signerInfos-SET met meer dan één SignerInfo en hij gooit een exception in plaats van de eerste ondertekenaar te houden en de rest te laten vallen. Een CMS die een crls [1]-set draagt, stopt bij de exception CMS: signerInfos SET expected, want het element na de certificaten is niet de SET die de wandeling zoekt. Beide weigeringen gebeuren voordat er iets terug in CmsDer wordt geschreven

De nieuwe unsignedAttrs bevat exact één attribuut, dus de SET OF-ordeningsregel van X.690 clausule 11.6 is zonder sortering voldaan. Het ondertekende deel van de SignerInfo, van zijn versie tot de signature OCTET STRING, wordt als één blok gekopieerd, wat betekent dat een validator die signedAttrs opnieuw digeert dezelfde bytes ziet voor en nadat het token is toegevoegd. Als een validator het document alsnog afwijst, zitten de oorzaken meestal elders en staan ze in deze checklist van PAdES-weigeringsredenen. Wat het token je over tijd oplevert, en hoe B-LT en B-LTA erop voortbouwen, staat in de walkthrough van RFC 3161-timestamps en de DSS

De DER-reader en -writer in FPdfAsn1.pas, de CMS-builder en de timestamp-stap in FPdfCms.pas zijn allemaal Pascal-bron die bij de PDFium Component for Delphi komt. Wanneer een herbouwde SignedData weigert te parsen in een validator, kun je door de code stappen die elk element sneed en hem checken tegen de X.690-clausule die hij citeert