PDFium Component finder starten af en indlejret CMS-struktur ud fra dens content length, aldrig ved at gå baglæns hen over length-octets, fordi byten lige før indholdet er den sidste length-octet og ikke siger noget om, hvor mange der går forud. CmsHeaderStart i FPdfCms.pas udleder header-længden af ContentLen i stedet, hvilket DER gør eksakt muligt, og det er det, der holder AddSignatureTimestampToCms fra at korruptere enhver CMS, hvis certifikatsæt er længere end 127 bytes
Scenariet er PAdES B-T-opgraderingen. En signature-time-stamp-attribut, den, ETSI EN 319 122-1 klausul 5.3 definerer under OID 1.2.840.113549.1.9.16.2.14, skal lande i unsignedAttrs hos den SignerInfo, RFC 5652 klausul 5.3 beskriver, og per definition kan den kun tilføjes, efter signaturens værdi findes, fordi timestamp-tokenet beregnes over den værdi. Så CMS'en er allerede bygget og allerede signeret, når tokenet ankommer. At tilføje én attribut ændrer længden af SignerInfo, hvilket ændrer længden af signerInfos-SET'en, så af SignedData, så af [0]-EXPLICIT-wrapperen og så af den ydre ContentInfo. Hvert omsluttende header skal gen-emiteres, og alt, der ikke er på den vej, skal bæres over byte for byte. B-LT- og B-LTA-gennemgangen dækker, hvad tokenet giver dig; denne artikel handler om de fire bytes foran certifikatsættet, som genopbygningen vedvarende fik forkert
Hvorfor kræver et timestamp tag-offsettet for et søsterelement?
For genopbygningen genbruger fire søsterelementer til signerInfos-SET'en ordret, og readeren rapporterer, hvor deres indhold er, ikke hvor deres tag er. TDerReader.ReadTlv giver tilbage tag-byten, content-offset, content-længden og offsettet for den næste TLV. Det er den rigtige flade at gå ned i en struktur på, men for at kopiere et helt element behøver du den octet, hvor dets tag sidder, og det eneste, en caller holder, er ContentOffs. CmsSliceTlv findes for at brobygge det hul: Givet et content-offset og en længde returnerer den tag, length-octets og indhold som én buffer, og AddSignatureTimestampToCms kalder den for contentType-OID'en, version-INTEGER'et, digestAlgorithms-SET'en, encapContentInfo-SEQUENCE'en og, når til stede, certificates [0]-sættet
// Indeni AddSignatureTimestampToCms: Gå ned, skær søsterelementerne ordret
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;
// valgfrit 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 + length-octets + indhold
R.Position:= CN;
end;
Af de fem snit er fire små: En elleve-byte OID, et tre-byte INTEGER, et sytten-byte digest-algoritmesæt, en tretten-byte detached encapContentInfo. Certifikatsættet er det, der bærer signeringscertifikatet og dets kæde, og et rigtigt X.509-certifikat løber op i flere hundrede bytes som minimum. Certifikatsættet er derfor det eneste snit, hvis length-octets nogensinde er i den lange form, og det er snittet, den gamle helper ikke kunne lokalisere
Hvorfor kan DER length-octets ikke gås baglæns?
For antallet af length-octets gemmes i den første af dem, og når man læser fra indholdet baglæns, møder man den sidste først. X.690 klausul 8.1.3.4 definerer den korte form: Én octet, bit 8 sat til nul, bit 7 til 1 holder en længde fra 0 til 127. Klausul 8.1.3.5 definerer den lange form: En initial-octet med bit 8 sat, hvis bit 7 til 1 angiver antallet af efterfølgende octets, efterfulgt af de octets, der bærer længden som et unsigned big-endian heltal. Intet i reglen markerer en efterfølgende octet som efterfølgende. Dens bit 8 er en størrelsesbit som enhver anden, så en baglæns gang, der tester toppen af Buf[ContentOffs- 1], tester en databit og læser derefter dens syv laveste bits som et antal
// Den gamle helper, givet kun content-offsettet
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // lander på den SIDSTE length-octet
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // kun meningsfuldt for den FØRSTE
Result:= P- 1
else
begin
LongLen:= LenByte and $7F;
Result:= P- LongLen- 1;
end;
end;
// Header for et certifikatsæt på 1500 bytes: A0 82 05 DC
// Buf[ContentOffs- 1]= $DC -> bit 8 sat, $DC and $7F= 92
// Result= ContentOffs- 94 (tagget er ved ContentOffs- 4)
Tag headeren for et certifikatsæt med 1500 bytes af certifikater, A0 82 05 DC. Gangen lander på DC, ser en sat topbit, udpakker 92 fra de syv laveste bits og rapporterer tagget 94 bytes før indholdet, når det er 4 bytes før det. I en SignedData bygget af BuildSignedData ligger certifikatsættets indhold kun nogle få dusin bytes inde i CMS'en, så det beregnede offset var ikke blot for tidligt, men negativt, og den gamle kode bevogtede ContentOffs- 1 mod at gå under nul, ikke sit endelige resultat. CmsSliceTlv tog derefter et snit omkring halvfems bytes længere end elementet, startende før bufferen, og den genopbyggede SignedData bar det snit, hvor dets certifikatsæt skulle have været. En tre-octet-længde, hvis sidste octet tilfældigvis faldt under $80, lad os sige A0 82 05 10, fejlede den anden vej: Gangen tog den for en kortform-octet og startede snittet ved 05, to bytes for sent og inde i length-octets, helt uden tag. Udfaldet var forkert begge veje, kun retningen varierede
Hvad garanterer DER, der gør den fremadrettede udledning eksakt?
DER garanterer, at længdekodningen er en ren funktion af længden. X.690 klausul 10.1 begrænser DER til den definitive form og kræver det minimale antal octets, hvilket fjerner de to friheder, BER tillader: Den indefinitte form og at polstre en langform-længde med indledende nul-octets. Under den regel har en content length under 128 præcis én length-octet, og enhver anden længde har én initial-octet plus præcis så mange efterfølgende octets, som længden behøver signifikante bytes. Calleren af CmsHeaderStart holder allerede ContentLen, fordi ReadTlv netop returnerede den, så header-længden kan beregnes uden at se på en eneste byte af bufferen
// Den shippede helper: Udled headeren af content length.
// Under X.690 10.1 er length-octets en funktion af ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
LengthOctets, Remaining: Integer;
begin
if ContentLen< 128 then
LengthOctets:= 1 // kort form, X.690 8.1.3.4
else
begin
LengthOctets:= 1; // initial-octetten, X.690 8.1.3.5
Remaining:= ContentLen;
while Remaining> 0 do
begin
Inc(LengthOctets); // én pr. signifikant byte
Remaining:= Remaining shr 8;
end;
end;
Result:= ContentOffs- LengthOctets- 1;
if Result< 0 then
Result:= ContentOffs;
end;
To detaljer gør det sikkert frem for blot plausibelt. For det første håndhæves antagelsen om, at inputtet er DER, opstrøms: TDerReader.TryReadTlvAt, som ReadTlv er bygget på, afviser den indefinitte form, afviser en langform-længde, hvis første efterfølgende octet er nul, og afviser en enkelt efterfølgende octet under $80. En TLV, der når CmsSliceTlv, har allerede bestået de tjek, så en BER-agtig ikke-minimal længde kan ikke nå udledningen og få den til at lyve. For det andet bevogter fallbacken for et negativt resultat nu det rigtige svar, ikke en mellemværdi. Det er det værd at sige, at readeren kendte tag-offsettet hele tiden: TDerTlv bærer både Offset og HeaderLength, og kun den fire-ud-parametre ReadTlv-flade dropper dem. At returnere dem ville være den renere langsigtede grænseflade; det shippede fix beholder fladen intakt og gør helperen korrekt på egne præmisser
Hvorfor bestod timestamp-testerne med bugen på plads?
For hvert fixture-certifikat var kort nok til at bruge den korte form, og den baglæns gang er korrekt for netop det tilfælde. Tests.PadesTimestamp.pas bygger sit signeringscertifikat med SetLength(SignerCertDer, 32) i én test og 64 i en anden, fyldt med en byte-ramp. Et certifikatsæt på 32 bytes kodes som A0 20 og et på 64 bytes som A0 40, én enkelt length-octet hver. Går man baglæns fra indholdet, lander man på den ene octet, dens topbit er nulstillet, fordi den er den første og eneste length-octet, og helperen svarer korrekt af den forkerte grund. Suitens 1414 cases var grønne, det tidsstemplede CMS parsede, stage-1-validatoren rapporterede B-T, og hvert af de tjek blev kørt mod et certifikatsæt, som intet rigtigt dokument nogensinde har indeholdt
Den generelle regel er den nyttige del. Når en kodevej afhænger af, hvordan en længde er kodet, skal fixturen krydse kodningsgrænsen, og for DER betyder det indhold længere end 127 bytes, som tvinger den lange form frem, og ideelt også længere end 255 bytes, som tvinger en anden efterfølgende octet frem. Samme disciplin gælder den anden case i den gennemgang, hvor selv-verifikation ikke kunne se en DER-afvigelse: den usorterede SET OF i signedAttrs var usynlig for en same-origin round trip af en strukturelt identisk grund — testen øvede kun inputs, hvor den forkerte kode og den rigtige kode er enige. Skitsen nedenfor kalder slice-helperen direkte, hvilket betyder at eksportere den fra FPdfCms.pas til test-builden; samme grænse kan nås gennem den offentlige flade ved at give BuildSignedData et kædecertifikat af hver størrelse og re-parse det tidsstemplede resultat
// Fastgør grænsen: Et snit gennem et langform-header skal starte ved tagget
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;
// Indholdet begynder lige efter headeren; Snittet skal 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 genopbygningen stadig trækker sine grænser
AddSignatureTimestampToCms er skrevet til den CMS, BuildSignedData emitterer, og dets grænser følger deraf. Gangen forventer én enkelt SignerInfo og gen-emiterer kun den ene, så en fremmed multi-signer-CMS ville komme tilbage med én signer; den genkender et valgfrit certificates [0]-sæt, men ikke et crls [1]-sæt, og en CMS med ét fejler højlydt med signerInfos SET expected-exceptionen frem for lydløst at skære forkert. Den nye unsignedAttrs holder én attribut, så SET OF-ordenreglen i X.690 klausul 11.6 er trivielt opfyldt og behøver ingen sortering. Og den signerede del er urørt ved konstruktion: SignerInfo-prefixet frem til signaturens OCTET STRING kopieres ordret, hvilket er grunden til, at en validator, der re-digester signedAttrs, ser de samme bytes før og efter timestampet tilføjes. Når en validator alligevel afviser dokumentet, ligger årsagerne som regel andre steder og er deres egen tjekliste værd
DER-readeren, writeren, CMS-byggeren og denne timestamp-injektion ships alle som Pascal-kildekode med PDFium Delphi-komponenten, og en bug af denne form er argumentet for det: Når en genopbygget SignedData kommer ud omkring halvfems bytes for lang, vil du læse helperen, der skar snittet, og den klausul i X.690, den mislæste, ikke en stack trace fra en sort boks