PDFium Component lokaliserar början av en nästlad CMS-struktur ur dess innehållslängd, aldrig genom att gå baklänges över längdoctetterna, för byten precis före innehållet är den sista längdoctetten och säger ingenting om hur många som föregår den. CmsHeaderStart i FPdfCms.pas härleder i stället huvudlängden ur ContentLen, vilket DER gör exakt, och det är vad som hindrar AddSignatureTimestampToCms från att förstöra varje CMS vars certifikatmängd är längre än 127 byte
Sammanhanget är PAdES B-T-uppgraderingen. Ett signature-time-stamp-attribut, det som ETSI EN 319 122-1 klausul 5.3 definierar under OID 1.2.840.113549.1.9.16.2.14, måste hamna i unsignedAttrs hos den SignerInfo som beskrivs i RFC 5652 klausul 5.3, och per definition kan det bara läggas till efter att signaturvärdet finns, eftersom tidsstämpelstoken beräknas över just det värdet. CMS:en är alltså redan byggd och redan signerad när token anländer. Att lägga till ett attribut ändrar längden på SignerInfo, vilket ändrar längden på signerInfos-SET:en, sedan på SignedData, sedan på den [0] EXPLICIT-omslutningen, sedan på den yttre ContentInfo. Varje omslutande huvud måste emitteras om, och allt som inte ligger på den vägen måste bäras över byte för byte. Genomgången av B-LT och B-LTA tar upp vad token ger dig; den här artikeln handlar om de fyra byten framför certifikatmängden som ombyggnaden gång på gång fick fel
Varför kräver en tidsstämpel att ett syskons taggoffset hittas?
För att ombyggnaden återanvänder fyra syskon till signerInfos-SET:en ordagrant, och läsaren rapporterar var deras innehåll finns, inte var deras tagg finns. TDerReader.ReadTlv ger tillbaka taggbyten, innehållsoffset, innehållslängd och offseten för nästa TLV. Det är rätt yta för att gå ned i en struktur, men för att kopiera ett helt element behöver du byten där taggen sitter, och det enda en anropare håller är ContentOffs. CmsSliceTlv finns för att överbrygga gapet: given ett innehållsoffset och en längd ger den tagg, längdoctetter och innehåll som en enda buffert, och AddSignatureTimestampToCms anropar den för contentType-OID:n, version-INTEGER:n, digestAlgorithms-SET:en, encapContentInfo-SEQUENCE:n och, när den finns, certificates [0]-mängden
// Inuti AddSignatureTimestampToCms: gå ned, skär ut syskonen ordagrant
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;
// valfria 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 + längdoctetter + innehåll
R.Position:= CN;
end;
Av de fem snitten är fyra små: en elvabyte stor OID, en trebyte stor INTEGER, en sjuttonbyte stor digest-algoritmmängd, en trettonbyte stor fristående encapContentInfo. Certifikatmängden är den som bär signerarcertifikatet och dess kedja, och ett riktigt X.509-certifikat är minst några hundra byte. Certifikatmängden är därför det enda snittet vars längdoctetter någonsin är i lång form, och det är det snitt som den gamla hjälpfunktionen inte kunde lokalisera
Varför kan DER-längdoctetter inte läsas baklänges?
För att antalet längdoctetter lagras i den första av dem, och när du läser baklänges från innehållet möter du den sista först. X.690 klausul 8.1.3.4 definierar den korta formen: en octett, bit 8 nollställd, bitarna 7 till 1 innehållande en längd från 0 till 127. Klausul 8.1.3.5 definierar den långa formen: en inledande octett med bit 8 satt vars bitar 7 till 1 anger antalet efterföljande octetter, följd av de octetter som bär längden som ett unsigned big-endian-heltal. Inget i regeln märker en efterföljande octett som efterföljande. Dess bit 8 är en magnitudbit som vilken annan som helst, så en baklängesgång som testar toppbiten i Buf[ContentOffs- 1] testar en databit och läser sedan dess sju låga bitar som ett antal
// Den gamla hjälpfunktionen, given bara innehållsoffseten
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // landar på den SISTA längdoctetten
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // bara meningsfullt för den FÖRSTA
Result:= P- 1
else
begin
LongLen:= LenByte and $7F;
Result:= P- LongLen- 1;
end;
end;
// Huvud för en certifikatmängd 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 huvudet för en certifikatmängd som rymmer 1500 byte certifikat, A0 82 05 DC. Gången landar på DC, ser en satt toppbit, plockar ut 92 ur de sju låga bitarna och rapporterar taggen 94 byte före innehållet, när den ligger 4 byte före det. I en SignedData byggd av BuildSignedData ligger certifikatmängdens innehåll bara några tiotal byte in i CMS:en, så den beräknade offseten var inte bara för tidig utan negativ, och den gamla koden skyddade ContentOffs- 1 mot att gå under noll, inte sitt slutresultat. CmsSliceTlv tog då ett snitt nittiotal byte längre än elementet, med start före bufferten, och den ombyggda SignedData bar det snittet där dess certifikatmängd borde ha legat. En tre-octettslängd vars sista octett råkade falla under $80, säg A0 82 05 10, föll fel åt andra hållet: gången tog den för en kortforms-octett och startade snittet vid 05, två byte sent och inuti längdoctetterna, utan någon tagg alls. Utfallet var fel åt båda hållen, bara riktningen varierade
Vad garanterar DER som gör framlängeshärledningen exakt?
DER garanterar att längdkodningen är en ren funktion av längden. X.690 klausul 10.1 begränsar DER till den bestämda formen och kräver minsta antal octetter, vilket tar bort de två friheter BER tillåter: den obestämda formen, och att fylla ut en långformslängd med inledande nolloctetter. Under den regeln har en innehållslängd under 128 exakt en längdoctett, och varje annan längd har en inledande octett plus exakt så många efterföljande octetter som längden behöver signifikanta byte. Anroparen av CmsHeaderStart håller redan ContentLen, eftersom ReadTlv just returnerade den, så huvudlängden går att beräkna utan att titta på en enda byte av bufferten
// Den levererade hjälpfunktionen: härled huvudet ur innehållslängden.
// Under X.690 10.1 är längdoctetterna en funktion av 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; // den inledande octetten, X.690 8.1.3.5
Remaining:= ContentLen;
while Remaining> 0 do
begin
Inc(LengthOctets); // en per signifikant byte
Remaining:= Remaining shr 8;
end;
end;
Result:= ContentOffs- LengthOctets- 1;
if Result< 0 then
Result:= ContentOffs;
end;
Två detaljer gör detta säkert i stället för bara rimligt. För det första upprätthålls antagandet att indata är DER uppströms: TDerReader.TryReadTlvAt, som ReadTlv bygger på, avvisar den obestämda formen, avvisar en långformslängd vars första efterföljande octett är noll, och avvisar en enda efterföljande octett under $80. En TLV som når CmsSliceTlv har redan passerat de kontrollerna, så en icke-minimal BER-längd kan inte nå härledningen och få den att ljuga. För det andra skyddar reservvägen för ett negativt resultat nu det verkliga svaret, inte ett mellanvärde. Det är värt att säga att läsaren kände till taggoffseten hela tiden: TDerTlv bär både Offset och HeaderLength, och bara ytan ReadTlv med fyra utparametrar tappar dem. Att returnera dem vore det renare gränssnittet på lång sikt; den levererade fixen behåller den ytan intakt och gör hjälpfunktionen korrekt på sina egna villkor
Varför gick tidsstämpeltesterna igenom med buggen kvar?
För att varje fixturcertifikat var tillräckligt kort för att använda den korta formen, och baklängesgången är korrekt för exakt det fallet. Tests.PadesTimestamp.pas bygger sitt signerarcertifikat med SetLength(SignerCertDer, 32) i ett test och 64 i ett annat, fyllt med en byteramp. En certifikatmängd på 32 byte kodas som A0 20 och en på 64 byte som A0 40, en enda längdoctett vardera. Att gå baklänges från innehållet landar på den enda octetten, dess toppbit är nollställd eftersom den är den första och enda längdoctetten, och hjälpfunktionen svarar rätt av fel skäl. Sviten på 1414 fall var grön, den tidsstämplade CMS:en parsades, steg-1-validatorn rapporterade B-T, och varenda en av de kontrollerna kördes mot en certifikatmängd som inget riktigt dokument någonsin har innehållit
Den allmänna regeln är det användbara. Närhelst en kodväg beror på hur en längd kodas måste fixturen korsa kodningsgränsen, och för DER betyder det innehåll längre än 127 byte, vilket tvingar fram den långa formen, och helst också längre än 255 byte, vilket tvingar fram en andra efterföljande octett. Samma disciplin gäller det andra fallet i den granskningen där självverifiering inte kunde se en DER-avvikelse: den osorterade SET OF i signedAttrs var osynlig för en rundresa inom samma källa av en strukturellt identisk anledning, testet rörde bara indata där den felaktiga koden och den korrekta koden är ense. Skissen nedan anropar snitthjälpfunktionen direkt, vilket innebär att den exporteras från FPdfCms.pas för testbygget; samma gräns går att nå via den publika ytan genom att ge BuildSignedData ett kedjecertifikat av varje storlek och parsa om det tidsstämplade resultatet
// Lås gränsen: ett snitt genom ett långformshuvud måste börja vid 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;
// innehållet börjar direkt efter huvudet; snittet måste vara hela TLV:n
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;
Var ombyggnaden fortfarande drar sina gränser
AddSignatureTimestampToCms är skriven för den CMS som BuildSignedData ger ifrån sig, och dess gränser följer av det. Gången förväntar sig en enda SignerInfo och emitterar bara den, så en främmande CMS med flera signerare skulle komma tillbaka med en signerare; den känner igen en valfri certificates [0]-mängd men inte en crls [1]-mängd, och en CMS som bär en sådan faller högljutt med undantaget signerInfos SET expected i stället för att tyst skära fel. Den nya unsignedAttrs håller ett attribut, så SET OF-ordningsregeln i X.690 klausul 11.6 uppfylls trivialt och behöver ingen sortering. Och den signerade delen är orörd genom konstruktion: SignerInfo-prefixet fram till signaturens OCTET STRING kopieras ordagrant, vilket är varför en validator som räknar om digesten över signedAttrs ser samma byten före och efter att tidsstämpeln läggs till. När en ändå underkänner dokumentet ligger orsakerna oftast någon annanstans och förtjänar sin egen checklista
DER-läsaren, skrivaren, CMS-byggaren och den här tidsstämpelinjektionen levereras alla som Pascal-källkod med PDFium Delphi-komponenten, och en bugg av den här formen är argumentet för det: när en ombyggd SignedData kommer ut nitti byte för lång vill du läsa hjälpfunktionen som skar snittet och den klausul i X.690 som den missläste, inte en stacktrace från en svart låda