Artykuł techniczny

Nie chodź wstecz po bajtach długości DER: PDFium Component

Cztery bajty A0 82 05 DC otwierają element kontekstowy [0] niosący 1500 bajtów treści. Czytane od lewej do prawej są jednoznaczne: tag A0, wstępny bajt długości 82 zapowiadający dwa kolejne, potem 05 DC. Czytane od prawej do lewej, zaczynając od pierwszego bajtu treści, pierwszym bajtem, jaki spotykasz, jest DC. Ma ustawiony górny bit, a jego dolne siedem bitów mówi 92, więc pomocnik, który mu ufa, zgłasza tag 94 bajty przed treścią. Prawdziwa odległość to 4

Nikt nie chodzi wstecz naumieńście. To się zdarza, bo większość czytników DER oddaje offset treści i długość każdego elementu — dokładnie to, czego potrzebujesz, by w niego zejść — podczas gdy skopiowanie elementu sąsiedniego bez zmian potrzebuje offsetu jego tagu. Odejmowanie od offsetu treści wygląda jak robota na dwie linie i pozostaje poprawne, dopóki każda długość mieści się w jednym bajcie. Długość prawdziwego zbioru certyfikatów nigdy w jednym się nie mieści

Co znacznik czasu podpisu robi z podpisanym CMS

Dodanie znacznika czasu PAdES B-T wymusza rekodowanie każdego otaczającego nagłówka CMS, przy czym pięć elementów sąsiednich musi przejść bajt po bajcie. Atrybut znacznika czasu podpisu, który ETSI EN 319 122-1, punkt 5.3, definiuje pod OID-em 1.2.840.113549.1.9.16.2.14, mieszka w unsignedAttrs SignerInfo z RFC 5652, punktu 5.3, a token RFC 3161 wewnątrz niego jest liczony po wartości podpisu. CMS jest więc już zbudowany i już podpisany, gdy token przychodzi od TSA. Jeden dodatkowy atrybut wydłuża SignerInfo, co wydłuża zbiór signerInfos, potem SignedData, potem opakowanie [0] EXPLICIT, potem zewnętrzne ContentInfo

W PDFium Component ta sekwencja to trzy eksportowane wywołania w FPdfCms.pas i FPdfTsa.pas. SignPadesBytes puszczają za ciebie, gdy TPadesSignOptions.Level to plB_T lub wyżej i TsaUrl jest ustawiony, a podnosi EPadesCrypto, gdy poziom B-T jest żądany z pustym TsaUrl, zamiast zapisywać zwykły podpis B-B. Jeśli budujesz CMS sam przez BuildSignedData, łańcuch wygląda tak

// CmsDer trzyma ContentInfo zwrócony przez BuildSignedData.
// SignPadesBytes wykonuje te same trzy kroki, gdy Options.Level >= plB_T
var
  SigValue: TBytes;
  Ts: TPadesTimestamp;
begin
  if not ExtractSignatureValue(CmsDer, SigValue) then
    raise Exception.Create('CMS carries no signature value');
  // messageImprint TSA jest liczony po bajtach wartości podpisu
  Ts:= RequestTimestamp(TsaUrl, SigValue, Nonce);
  // Rekoduje SignerInfo aż po ContentInfo, kopiuje sąsiadów bez zmian
  AddSignatureTimestampToCms(CmsDer, Ts.TokenDer);
end;

AddSignatureTimestampToCms przenosi pięć elementów nietkniętych: OID contentType, INTEGER wersji SignedData, zbiór digestAlgorithms, SEQUENCE encapContentInfo i, gdy jest, zbiór certificates [0]. W CMS, który BuildSignedData emituje z SHA-256, cztery z nich są malutkie: 11 bajtów na OID, 3 na wersję, 17 na zbiór algorytmów digestu, 13 na odłączony encapContentInfo. Zbiór certyfikatów trzyma certyfikat podpisującego, po którym idzie każdy certyfikat łańcucha, który podałeś. Prawdziwy certyfikat X.509 sięga kilkuset bajtów. To jedyny wycinek, którego bajty długości zawsze używają formy długiej

Co przebudowuje dodanie znacznika czasu PAdES B-T w CMS: AddSignatureTimestampToCms kopiuje contentType, wersję, digestAlgorithms, encapContentInfo i zbiór certyfikatów bajt po bajcie, zbiór certyfikatów jest jedynym wycinkiem na tyle długim, by opuścić krótką formę długości, a każdy otaczający nagłówek od SignerInfo po ContentInfo jest emitowany od nowa
Podpisana część jest z konstrukcji nietknięta, bo prefiks SignerInfo aż po OCTET STRING podpisu jest kopiowany dosłownie, więc walidator przeliczający signedAttrs od nowa widzi identyczne bajty przed i po tym, jak znacznik czasu spłynie

Dlaczego po bajtach długości DER nie można chodzić wstecz?

Liczba bajtów długości jest zapisana w pierwszym z nich, a chód wstecz spotyka najpierw ostatni. X.690, punkt 8.1.3.4, definiuje formę krótką: jeden bajt, bit 8 wyzerowany, bity 7 do 1 trzymające długość od 0 do 127. Punkt 8.1.3.5 definiuje formę długą. Jej wstępny bajt ma ustawiony bit 8, a bity 7 do 1 liczą kolejne bajty trzymające długość jako bezznakową liczbę big-endian. Nic nie oznacza bajtu kolejnego jako kolejnego. Jego bit 8 to bit wartości jak pozostałe siedem. Pomocnik testujący więc górny bit Buf[ContentOffs- 1] testuje dane, a potem czyta dane jako liczbę

// Kuszący pomocnik, dostający wyłącznie offset treści. Nie wysyłaj tego w świat
function NaiveHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte: Integer;
begin
  P:= ContentOffs- 1;            // ląduje na OSTATNIM bajcie długości
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // ma sens tylko dla PIERWSZEGO
    Result:= P- 1
  else
    Result:= P- (LenByte and $7F)- 1;
end;

// A0 82 05 DC (1500 bajtów): Buf[ContentOffs- 1]= $DC, $DC and $7F= 92
//   Result= ContentOffs- 94, tag siedzi na ContentOffs- 4
// A0 82 05 10 (1296 bajtów): $10 ma wyzerowany bit 8, czytane jako forma krótka
//   Result= ContentOffs- 2, co wskazuje na 05 wewnątrz bajtów długości

Oba kierunki porażki wychodzą przy zwykłych rozmiarach. Zbiór certyfikatów 1500 bajtów kończy nagłówek na DC i pomocnik zaczyna wycinek 90 bajtów za wcześnie. Zbiór 1296 bajtów kończy na 10, pomocnik bierze to za długość w formie krótkiej i wycinek zaczyna się dwa bajty za późno, bez żadnego tagu. O tym, czy dany łańcuch wpadnie w pierwszy czy drugi przypadek, rozstrzyga nic poza niskim bajtem jego całkowitej długości

Wczesny przypadek potrafi też przebiec przed początek bufora. Dla CMS ułożonego tak, jak układa go BuildSignedData — z każdym otaczającym lengthiem na dwóch bajtach — treść zbioru certyfikatów zaczyna się na 60 bajcie: 4 na nagłówek ContentInfo, 11 na OID, po 4 na nagłówki [0] i SignedData, potem 3, 17 i 13 na małych sąsiadów i 4 na nagłówek zbioru certyfikatów. Odejmij 94 od 60, a pomocnik zwróci -34. Jego jedyna straż siedzi na ContentOffs- 1, więc ujemny wynik leci wprost do kopiowania

Dlaczego po bajtach długości DER nie można chodzić wstecz: czytanie A0 82 05 DC od końca ląduje na ostatnim bajcie DC, którego ustawiony górny bit daje fałszywą liczbę 92 i umieszcza tag 94 bajty za wcześnie, natomiast A0 82 05 10 zawodzi w drugą stronę i zaczyna wycinek dwa bajty za późno, wewnątrz bajtów długości
A chodzenie wstecz, które chroni pośrednie odejmowanie zamiast swojego wyniku końcowego, potrafi nawet zacząć wycinek przed buforem, zostawiając przebudowany SignedData z tym wycinkiem tam, gdzie należy jego zbiór certyfikatów

Co punkt 10.1 X.690 przybija

DER czyni kodowanie długości czystą funkcją długości. X.690, punkt 10.1, ogranicza DER do formy określonej i wymaga minimalnej liczby bajtów, co usuwa dwie wolności, na które pozwala BER: formę nieokreśloną i długość w formie długiej wyściełaną zerami wiodącymi. Pod tą regułą długość treści poniżej 128 zawsze bierze dokładnie jeden bajt długości. Jakakolwiek inna długość bierze jeden bajt wstępny plus dokładnie tyle bajtów kolejnych, ile znaczących bajtów ma wartość. To daje 81 80 dla 128. Przy 256 nagłówek rośnie do 82 01 00, a 65536 potrzebuje 83 01 00 00. Kod, który już trzyma długość treści, potrafi wyliczyć rozmiar nagłówka, nie czytając ani bajtu bufora

Wyprowadzenie w przód, które gwarantuje DER: długość treści 127 koduje się jako A0 7F, 128 jako A0 81 80, 255 jako A0 81 FF, 256 jako A0 82 01 00, a 1500 jako A0 82 05 DC, więc długość nagłówka wynika z samego ContentLen, a TryReadTlvAt odrzucił już każdą nieminimalną formę BER
Treść krótsza niż 128 bajtów zostaje w formie krótkiej, gdzie chodzenie wstecz odpowiada poprawnie z niewłaściwego powodu, więc testy brzegowe muszą przekroczyć 127, 128, 255 i 256 bajtów

Wyprowadzenie jest tylko tak dobre, jak twierdzenie, że wejście naprawdę jest DER. TDerReader.TryReadTlvAt egzekwuje to twierdzenie, zanim dojdzie do jakiegokolwiek cięcia. Odrzuca wstępny bajt długości $80 (formę nieokreśloną), długość w formie długiej, której pierwszy bajt kolejny jest zerem, pojedynczy bajt kolejny poniżej $80, który powinien był być formą krótką, i więcej niż cztery bajty kolejne. ReadTlv jest zbudowany na TryReadTlvAt, więc każdy element, po którym chodzi AddSignatureTimestampToCms, zdążył już przejść te kontrole. Wyściełany w stylu BER length nigdy nie dociera do arytmetyki

Wewnątrz AddSignatureTimestampToCms offset tagu każdego sąsiada jest wyprowadzany z jego offsetu treści i długości treści właśnie tak. Gdy wyprowadzony start wypada przed początkiem bufora — czyli offsety nie mogą do siebie należeć — procedura podnosi wyjątek z komunikatem CMS: TLV header precedes the buffer start i nie produkuje żadnego CMS. Zniekształcona przebudowa, która nadal się parsuje, jest dużo trudniejsza do wyśledzenia niż wyjątek w miejscu, gdzie offsety przestały się zgadzać

Wyprowadzenie niesie jedno założenie więcej: identyfikator bierze jeden bajt. X.690 pozwala na identyfikatory wielobajtowe dla numerów tagów od 31 wzwyż i TryReadTlvAt umie je parsować. CMS SignedData nigdy takiego nie używa, bo jego tagi to uniwersalne SEQUENCE, SET, INTEGER i OID plus kontekstowe [0] i [1], więc założenie trzyma dla każdego elementu, którego dotyka przebudowa ze znacznikiem czasu

Kopiowanie elementów sąsiednich we własnym kodzie DER

Jeśli piszesz kod względem TDerReader, pomiń wyprowadzanie w ogóle. PeekTlv i ReadNextTlv wypełniają rekord TDerTlv, którego Offset już wskazuje tag, a HeaderLength to zmierzony rozmiar tagu plus bajtów długości, i EncodedBytes zwraca element od Offset do NextOffset. Ta droga radzi sobie też z identyfikatorami wielobajtowymi, których żadna arytmetyka długości nie odzyska. ReadTlv z czterema parametrami wyjściowymi jest wygodny do schodzenia w głąb. Zgłasza jednak tylko stronę treści, więc sięgaj po niego, gdy potrzebujesz treści, a po formę rekordu, gdy potrzebujesz całego elementu

// Każde dziecko SignedData jako kompletny TLV: tag, bajty długości, treść.
// TDerTlv.Offset wskazuje tag, więc żadna arytmetyka nagłówków nie uczestniczy
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);   // np. A0 82 05 DC ...
  end;
end;

Dla przypadków, gdy jakieś inne API daje ci nic poza offsetem treści i długością, wyprowadzenie w przód jest krótkie. DerHeaderStart — pokazany ze swoją kontrolą brzegową w następnej sekcji — to twój własny kod; odwzorowuje regułę z punktu 10.1 i zwraca -1 dla offsetów, które nie mogą być spójne, więc wołający dostaje wartość, którą może odrzucić, a nie wyglądająco wiarygodny bajt startu

Dlaczego krótkie testowe certyfikaty chowają błąd?

Mały certyfikat zastępczy trzyma zbiór certyfikatów w formie krótkiej, a chód wstecz jest poprawny dokładnie dla tego przypadku. Zbiór 32 bajtów koduje się jako A0 20, a 64 bajty jako A0 40. Krok wstecz od treści ląduje na tym pojedynczym bajcie długości, a jego górny bit jest wyzerowany, bo jest pierwszym i jedynym. Poprawna odpowiedź, niewłaściwy powód. Test znacznika czasu zbudowany na tak małych certyfikatach umie sparsować wynik od nowa, znaleźć atrybut, zgłosić B-T i ani razu nie przepuścić przez wycinek zbioru certyfikatów realistycznego rozmiaru

Każda ścieżka kodu zależna od tego, jak długość jest zakodowana, potrzebuje wejścia testowego przekraczającego granicę kodowania. Dla DER znaczy to treść od 128 bajtów wzwyż, która wymusza formę długą, i od 256 bajtów wzwyż, która wymusza drugi bajt kolejny. Kontrola poniżej porównuje DerHeaderStart z nagłówkiem zmierzonym przez czytnik podczas parsowania — na obu granicach i na przypadku 1500 bajtów z otwarcia. Niesortowany SET OF w signedAttrs przeskakuje rundy same-pochodzenia z tego samego powodu strukturalnego: test podaje wyłącznie wejścia, na których zły kod i dobry kod się zgadzają

// Twój własny pomocnik dla API zgłaszających tylko offset treści i długość.
// Zakłada jednobajtowy identyfikator — prawda dla każdego tagu w CMS SignedData
function DerHeaderStart(ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  LengthOctets:= 1;                  // forma krótka, X.690 8.1.3.4
  if ContentLen>= 128 then
  begin
    Remaining:= ContentLen;          // forma długa, X.690 8.1.3.5
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // jeden na znaczący bajt
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= -1;                     // niespójne offsety: niech wołający podniesie
end;

// Kontrola brzegowa, napisana jako twój własny kod testowy: wyprowadzony
// offset tagu musi równać się offsetowi zmierzonemu przez TDerReader przy parsowaniu nagłówka
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;

Ta sama granica jest osiągalna od końca do końca przez funkcje eksportowane. Podaj BuildSignedData certyfikaty łańcucha, których łączny rozmiar stawia zbiór certyfikatów tuż pod i tuż ponad 128 i 256 bajty, puść AddSignatureTimestampToCms na każdym wyniku, potem obchódź wyjście przez SignedDataChildren i porównaj element zbioru certyfikatów bajt po bajcie z tym z CMS sprzed dodania znacznika czasu

Co przyjmuje AddSignatureTimestampToCms

AddSignatureTimestampToCms jest napisany pod CMS, który emituje BuildSignedData: jeden SignerInfo, opcjonalny zbiór certificates [0], brak crls [1] i jeszcze brak unsignedAttrs. Podaj mu zbiór signerInfos trzymający więcej niż jeden SignerInfo, a podniesie wyjątek zamiast zatrzymać pierwszego podpisującego i wyrzucić resztę. CMS niosący zbiór crls [1] zatrzymuje się na wyjątku CMS: signerInfos SET expected, bo element po certyfikatach nie jest zbiorem, którego szuka chód. Obie odmowy dzieją się, nim cokolwiek zostanie zapisane z powrotem do CmsDer

Nowy unsignedAttrs trzyma dokładnie jeden atrybut, więc reguła porządkowania SET OF z punktu 11.6 X.690 jest spełniona bez sortowania. Podpisana część SignerInfo — od wersji aż po OCTET STRING podpisu — jest kopiowana jako jeden blok, co znaczy, że walidator przeliczający signedAttrs widzi te same bajty przed i po dodaniu tokenu. Gdy walidator i tak odrzuca dokument, przyczyny zwykle siedzą gdzie indziej i pokrywa je ta checklista powodów odrzuceń PAdES. Co token kupuje ci w czasie i jak na nim budują B-LT i B-LTA, jest w przewodniku po znacznikach czasu RFC 3161 i DSS

Czytnik i writer DER w FPdfAsn1.pas, budowniczy CMS i krok znacznika czasu w FPdfCms.pas to wszystko źródła Pascala dostarczane z PDFium Component for Delphi. Gdy przebudowany SignedData odmawia parsowania w walidatorze, możesz przejść krok po kroku kod, który wyciął każdy element, i zestawić go z punktem X.690, na który się powołuje