Teknisk artikkel

Aldri gå baklengs over DER-lengdeoktetter i PDFium

PDFium Component finner starten på en nestet CMS-struktur ut fra innholdslengden, aldri ved å gå baklengs over lengdeoktettene, fordi byten rett før innholdet er den siste lengdeoktetten og ikke sier noe om hvor mange som kommer før den. CmsHeaderStart i FPdfCms.pas utleder i stedet headerlengden fra ContentLen, noe DER gjør eksakt, og det er dette som hindrer AddSignatureTimestampToCms i å ødelegge hver CMS der sertifikatsettet er lengre enn 127 byte

Utgangspunktet er PAdES B-T-oppgraderingen. Et signaturtidsstempel-attributt, det som ETSI EN 319 122-1 punkt 5.3 definerer under OID 1.2.840.113549.1.9.16.2.14, må havne i unsignedAttrs til SignerInfo slik den er beskrevet i RFC 5652 punkt 5.3, og per definisjon kan det bare legges til etter at signaturverdien finnes, fordi tidsstempeltokenet beregnes over den verdien. CMS-en er altså allerede bygget og allerede signert når tokenet kommer. Å legge til ett attributt endrer lengden på SignerInfo, som endrer lengden på signerInfos-SET-en, deretter på SignedData, så på [0]-EXPLICIT-innpakningen, så på den ytre ContentInfo. Hver omsluttende header må sendes ut på nytt, og alt som ikke ligger på den veien, må føres over byte for byte. Gjennomgangen av B-LT og B-LTA dekker hva tokenet gir deg; denne artikkelen handler om de fire bytene foran sertifikatsettet som gjenoppbyggingen fortsatte å ta feil av

Hvorfor trenger innsetting av et tidsstempel taggforskyvningen til en søsken?

Fordi gjenoppbyggingen gjenbruker fire søsken av signerInfos-SET-en ordrett, og leseren rapporterer hvor innholdet deres er, ikke hvor taggen deres er. TDerReader.ReadTlv gir tilbake taggbyten, innholdsforskyvningen, innholdslengden og forskyvningen til neste TLV. Det er riktig flate å gå ned i en struktur med, men for å kopiere et helt element trenger du oktetten der taggen sitter, og det eneste kalleren holder, er ContentOffs. CmsSliceTlv finnes for å bygge bro over det gapet: gitt en innholdsforskyvning og en lengde returnerer den tagg, lengdeoktetter og innhold som én buffer, og AddSignatureTimestampToCms kaller den for contentType-OID-en, version-INTEGER-en, digestAlgorithms-SET-en, encapContentInfo-SEQUENCE-en og, når den finnes, certificates [0]-settet

// Inne i AddSignatureTimestampToCms: gå ned, skjær søsknene ordrett
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;
// valgfritt 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);   // tagg + lengdeoktetter + innhold
  R.Position:= CN;
end;

Av de fem utsnittene er fire bittesmå: en OID på elleve byte, en INTEGER på tre byte, et sett med digest-algoritmer på sytten byte, en løsrevet encapContentInfo på tretten byte. Sertifikatsettet er det ene som bærer signeringssertifikatet og kjeden bak det, og et ekte X.509-sertifikat går i det minste over flere hundre byte. Sertifikatsettet er derfor det eneste utsnittet der lengdeoktettene noen gang er i lang form, og det er utsnittet den gamle hjelperen ikke klarte å finne

Hva innsetting av et PAdES B-T-tidsstempel bygger på nytt i en CMS: CmsSliceTlv kopierer contentType, version, digestAlgorithms, encapContentInfo og sertifikatsettet byte for byte, sertifikatsettet er det eneste utsnittet som er langt nok til å forlate den korte lengdeformen, og hver omsluttende header fra SignerInfo opp til ContentInfo sendes ut på nytt
Den signerte delen er urørt ved konstruksjon, fordi prefikset i SignerInfo fram til signatur-OCTET STRING kopieres ordrett, så en validator som regner signedAttrs på nytt, ser identiske byte før og etter at tidsstemplet lander

Hvorfor kan DER-lengdeoktetter ikke leses baklengs?

Fordi antallet lengdeoktetter er lagret i den første av dem, og når du leser bakover fra innholdet, møter du den siste først. X.690 punkt 8.1.3.4 definerer kortform: én oktett, bit 8 klart, bit 7 til 1 holder en lengde fra 0 til 127. Punkt 8.1.3.5 definerer langform: en innledende oktett med bit 8 satt, der bit 7 til 1 gir antallet påfølgende oktetter, etterfulgt av de oktettene som bærer lengden som et usignert big-endian-heltall. Ingenting i regelen markerer en påfølgende oktett som påfølgende. Bit 8 er en størrelsesbit som enhver annen, så en baklengs gjennomgang som tester toppbiten i Buf[ContentOffs- 1], tester en databit og leser deretter de lave sju bitene som et antall

// Den gamle hjelperen, som bare fikk innholdsforskyvningen
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // lander på den SISTE lengdeoktetten
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // bare meningsfull for den FØRSTE
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Headeren til et sertifikatsett på 1500 byte:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 satt, $DC and $7F= 92
//   Result= ContentOffs- 94     (taggen ligger på ContentOffs- 4)

Ta headeren til et sertifikatsett som holder 1500 byte med sertifikater, A0 82 05 DC. Gjennomgangen lander på DC, ser en satt toppbit, trekker ut 92 fra de lave sju bitene og rapporterer taggen 94 byte før innholdet, når den ligger 4 byte før det. I en SignedData bygget av BuildSignedData ligger sertifikatsettets innhold bare noen titalls byte inn i CMS-en, så den beregnede forskyvningen var ikke bare for tidlig, men negativ, og den gamle koden voktet ContentOffs- 1 mot å gå under null, ikke sitt endelige resultat. CmsSliceTlv tok da et utsnitt som var nitti-og-noen byte lengre enn elementet, med start før bufferen, og den gjenoppbygde SignedData bar det utsnittet der sertifikatsettet skulle ha vært. En lengde på tre oktetter der den siste oktetten tilfeldigvis falt under $80, for eksempel A0 82 05 10, feilet den andre veien: gjennomgangen tok den for en kortformoktett og startet utsnittet på 05, to byte for sent og inne i lengdeoktettene, helt uten tagg. Resultatet var galt uansett, bare retningen varierte

Hvorfor DER-lengdeoktetter ikke kan leses baklengs: å lese A0 82 05 DC fra slutten lander på den siste oktetten DC, der den satte toppbiten gir et falskt antall på 92 og plasserer taggen 94 oktetter for tidlig, mens A0 82 05 10 feiler den andre veien og starter utsnittet to oktetter for sent inne i lengdeoktettene
Den gamle CmsHeaderStart voktet den mellomliggende subtraksjonen i stedet for sitt endelige resultat, så et utsnitt kunne til og med begynne før bufferen, og den gjenoppbygde SignedData bar det utsnittet der sertifikatsettet hørte hjemme

Hva garanterer DER som gjør den forlengs utledningen eksakt?

DER garanterer at lengdekodingen er en ren funksjon av lengden. X.690 punkt 10.1 begrenser DER til den bestemte formen og krever minste antall oktetter, noe som fjerner de to frihetene BER tillater: den ubestemte formen, og å fylle ut en langformslengde med ledende nulloktetter. Under den regelen har en innholdslengde under 128 nøyaktig én lengdeoktett, og enhver annen lengde har én innledende oktett pluss nøyaktig så mange påfølgende oktetter som lengden trenger signifikante byte for. Kalleren av CmsHeaderStart holder allerede ContentLen, fordi ReadTlv nettopp returnerte den, så headerlengden kan regnes ut uten å se på en eneste byte i bufferen

Den forlengs utledningen som DER garanterer: en innholdslengde på 127 kodes som A0 7F, 128 som A0 81 80, 255 som A0 81 FF, 256 som A0 82 01 00 og 1500 som A0 82 05 DC, så headerlengden følger av ContentLen alene, og TryReadTlvAt har allerede avvist enhver ikke-minimal BER-form
Fixtures bygget av sertifikater på 32 og 64 byte holdt seg innenfor kortformen, der den baklengse gjennomgangen svarer riktig av feil grunn, og derfor krysser grensetesten nå 127, 128, 255 og 256 byte
// Hjelperen som ble levert: utled headeren fra innholdslengden.
// Under X.690 10.1 er lengdeoktettene en funksjon av ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // kortform, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // den innledende oktetten, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // én per signifikante byte
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

To detaljer gjør dette trygt og ikke bare sannsynlig. For det første håndheves forutsetningen om at inndataene er DER, oppstrøms: TDerReader.TryReadTlvAt, som ReadTlv er bygget på, avviser den ubestemte formen, avviser en langformslengde der den første påfølgende oktetten er null, og avviser en enkelt påfølgende oktett under $80. En TLV som når CmsSliceTlv, har allerede passert disse sjekkene, så en ikke-minimal BER-lengde kan ikke nå utledningen og få den til å lyve. For det andre vokter reserveveien for et negativt resultat nå det virkelige svaret, ikke en mellomverdi. Det er verdt å si at leseren kjente taggforskyvningen hele tiden: TDerTlv bærer både Offset og HeaderLength, og bare flaten ReadTlv med fire ut-parametere dropper dem. Å returnere dem ville være det renere grensesnittet på lang sikt; rettelsen som ble levert, holder den flaten intakt og gjør hjelperen korrekt på egne premisser

Hvorfor passerte tidsstempeltestene med feilen på plass?

Fordi hvert fixturesertifikat var kort nok til å bruke kortformen, og den baklengse gjennomgangen er korrekt for nøyaktig det tilfellet. Tests.PadesTimestamp.pas bygger signeringssertifikatet sitt med SetLength(SignerCertDer, 32) i én test og 64 i en annen, fylt med en byte-rampe. Et sertifikatsett på 32 byte kodes som A0 20 og ett på 64 byte som A0 40, én enkelt lengdeoktett hver. Å gå baklengs fra innholdet lander på den ene oktetten, toppbiten er klar fordi den er den første og eneste lengdeoktetten, og hjelperen svarer riktig av feil grunn. Settet på 1414 saker var grønt, den tidsstemplede CMS-en lot seg parse, validatoren i trinn 1 rapporterte B-T, og hver eneste av de sjekkene ble kjørt mot et sertifikatsett som ingen ekte dokumenter noen gang har inneholdt

Den generelle regelen er den nyttige delen. Hver gang en kodevei avhenger av hvordan en lengde er kodet, må fixturen krysse kodingsgrensen, og for DER betyr det innhold lengre enn 127 byte, som tvinger langformen, og helst også lengre enn 255 byte, som tvinger en andre påfølgende oktett. Den samme disiplinen gjelder det andre tilfellet i den gjennomgangen der egenverifisering ikke kunne se et DER-avvik: den usorterte SET OF i signedAttrs var usynlig for en tur-retur i samme kodebase av en strukturelt identisk grunn, testen øvde bare på inndata der den gale koden og den riktige koden er enige. Skissen nedenfor kaller utsnittshjelperen direkte, noe som betyr at den eksporteres fra FPdfCms.pas for testbygget; den samme grensen kan nås gjennom den offentlige flaten ved å gi BuildSignedData et kjedessertifikat av hver størrelse og parse det tidsstemplede resultatet på nytt

// Fest grensen: et utsnitt gjennom en langformsheader må starte på taggen
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;
      // innholdet begynner rett etter headeren; utsnittet må være hele TLV-en
      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;

Hvor gjenoppbyggingen fortsatt setter grensene sine

AddSignatureTimestampToCms er skrevet for CMS-en som BuildSignedData sender ut, og grensene følger av det. Gjennomgangen forventer én enkelt SignerInfo og sender bare den ut på nytt, så en fremmed CMS med flere signaturer vil komme tilbake med én signatur; den gjenkjenner et valgfritt certificates [0]-sett, men ikke et crls [1]-sett, og en CMS som bærer et slikt, feiler høylytt med unntaket signerInfos SET expected i stedet for å skjære stille feil. Den nye unsignedAttrs holder ett attributt, så sorteringsregelen for SET OF i X.690 punkt 11.6 er trivielt oppfylt og krever ingen sortering. Og den signerte delen er urørt ved konstruksjon: prefikset i SignerInfo fram til signatur-OCTET STRING kopieres ordrett, og derfor ser en validator som regner signedAttrs på nytt, de samme bytene før og etter at tidsstemplet legges til. Når en likevel avviser dokumentet, ligger årsakene som regel andre steder og er verdt sin egen sjekkliste

DER-leseren, skriveren, CMS-byggeren og denne tidsstempel-innsettingen leveres alle som Pascal-kilde med PDFium Delphi-komponenten, og en feil av denne formen er argumentet for det: når en gjenoppbygd SignedData kommer ut nitti byte for lang, vil du lese hjelperen som skar utsnittet, og paragrafen i X.690 den leste feil, ikke en stakkutskrift fra en svart boks