Artykuł techniczny

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

PDFium Component lokalizuje początek zagnieżdżonej struktury CMS z jej długości treści, nigdy nie chodząc wstecz po bajtach długości, bo bajt tuż przed treścią to ostatni bajt długości i nie mówi nic o tym, ile ich go poprzedza. CmsHeaderStart w FPdfCms.pas wyprowadza długość nagłówka z ContentLen, co DER czyni dokładnym, i to właśnie powstrzymuje AddSignatureTimestampToCms przed psuciem każdego CMS, którego zbiór certyfikatów jest dłuższy niż 127 bajtów

To ustawienie to aktualizacja PAdES B-T. Atrybut znacznika czasu podpisu, ten, który ETSI EN 319 122-1 klauzula 5.3 definiuje pod OID 1.2.840.113549.1.9.16.2.14, musi wylądować w unsignedAttrs elementu SignerInfo opisanego w RFC 5652 klauzula 5.3, a z definicji można go dodać dopiero wtedy, gdy istnieje już wartość podpisu, bo token znacznika czasu liczy się po tej wartości. CMS jest więc już zbudowany i już podpisany, gdy token nadchodzi. Dodanie jednego atrybutu zmienia długość SignerInfo, co zmienia długość zbioru signerInfos, potem SignedData, potem opakowania [0] EXPLICIT, a na końcu zewnętrznego ContentInfo. Każdy obejmujący nagłówek trzeba wyemitować od nowa, a wszystko, co nie leży na tej ścieżce, trzeba przenieść bajt w bajt. Przewodnik po B-LT i B-LTA omawia, co daje ci ten token; ten artykuł jest o czterech bajtach przed zbiorem certyfikatów, które przebudowa wciąż psuła

Dlaczego dodanie znacznika czasu wymaga offsetu tagu sąsiada?

Bo przebudowa używa ponownie czterech sąsiadów zbioru signerInfos dosłownie, a czytnik podaje, gdzie jest ich treść, a nie gdzie jest ich tag. TDerReader.ReadTlv zwraca bajt tagu, offset treści, długość treści i offset następnego TLV. To właściwy interfejs do schodzenia w głąb struktury, ale żeby skopiować cały element, potrzebujesz bajtu, na którym siedzi jego tag, a wołający trzyma tylko ContentOffs. CmsSliceTlv istnieje, by tę lukę zasypać: dostając offset i długość treści, zwraca tag, bajty długości i treść jako jeden bufor, a AddSignatureTimestampToCms woła go dla OID contentType, liczby całkowitej version, zbioru digestAlgorithms, sekwencji encapContentInfo oraz, gdy jest obecny, zbioru certificates [0]

// Wewnątrz AddSignatureTimestampToCms: zejdź w głąb, wytnij sąsiadów dosłownie
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;
// opcjonalne 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 + bajty długości + treść
  R.Position:= CN;
end;

Z tych pięciu wycinków cztery są malutkie: jedenastobajtowy OID, trzybajtowa liczba całkowita, siedemnastobajtowy zbiór algorytmów skrótu, trzynastobajtowe odłączone encapContentInfo. To zbiór certyfikatów niesie certyfikat podpisującego i jego łańcuch, a prawdziwy certyfikat X.509 ma co najmniej kilkaset bajtów. Zbiór certyfikatów jest więc jedynym wycinkiem, którego bajty długości są kiedykolwiek w formie długiej, i to jego stary pomocnik nie potrafił zlokalizować

Co przebudowuje dodanie znacznika czasu PAdES B-T w CMS: CmsSliceTlv kopiuje contentType, version, digestAlgorithms, encapContentInfo i zbiór certyfikatów bajt w bajt, zbiór certyfikatów to jedyny wycinek dość długi, by wyjść z krótkiej formy długości, a każdy obejmujący nagłówek od SignerInfo aż po ContentInfo jest emitowany od nowa
Część podpisana pozostaje z założenia nietknięta, bo prefiks SignerInfo aż po OCTET STRING podpisu jest kopiowany dosłownie, więc walidator, który liczy skrót z signedAttrs ponownie, widzi identyczne bajty przed dodaniem znacznika czasu i po nim

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

Bo liczba bajtów długości jest zapisana w pierwszym z nich, a czytając od treści wstecz, napotykasz najpierw ostatni. X.690 klauzula 8.1.3.4 definiuje formę krótką: jeden bajt, bit 8 wyzerowany, bity od 7 do 1 niosą długość od 0 do 127. Klauzula 8.1.3.5 definiuje formę długą: bajt początkowy z ustawionym bitem 8, którego bity od 7 do 1 podają liczbę kolejnych bajtów, a po nim te bajty niosące długość jako liczbę całkowitą bez znaku w kolejności big-endian. Nic w tej regule nie oznacza kolejnego bajtu jako kolejnego. Jego bit 8 to bit wartości, taki sam jak każdy inny, więc chodzenie wstecz, które testuje górny bit Buf[ContentOffs- 1], testuje bit danych, a potem czyta jego siedem dolnych bitów jako liczbę

// Stary pomocnik, dostający tylko offset treści
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: 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   // sensowne tylko dla PIERWSZEGO
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Nagłówek 1500-bajtowego zbioru certyfikatów:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 ustawiony, $DC and $7F= 92
//   Result= ContentOffs- 94     (tag jest na ContentOffs- 4)

Weźmy nagłówek zbioru certyfikatów mieszczącego 1500 bajtów certyfikatów, A0 82 05 DC. Przejście ląduje na DC, widzi ustawiony górny bit, wyciąga 92 z siedmiu dolnych bitów i podaje tag 94 bajty przed treścią, gdy jest on 4 bajty przed nią. W SignedData budowanym przez BuildSignedData treść zbioru certyfikatów leży zaledwie kilkadziesiąt bajtów w głąb CMS, więc wyliczony offset był nie tylko za wczesny, ale wręcz ujemny, a stary kod chronił ContentOffs- 1 przed zejściem poniżej zera, a nie swój wynik końcowy. CmsSliceTlv brał potem wycinek dłuższy od elementu o jakieś dziewięćdziesiąt bajtów, zaczynający się przed buforem, a przebudowany SignedData niósł ten wycinek tam, gdzie powinien być jego zbiór certyfikatów. Trzybajtowa długość, której ostatni bajt przypadkiem wypadł poniżej $80, powiedzmy A0 82 05 10, zawodziła w drugą stronę: przejście brało go za bajt formy krótkiej i zaczynało wycinek na 05, dwa bajty za późno i wewnątrz bajtów długości, bez żadnego tagu. Wynik był błędny tak czy tak, tylko kierunek się zmieniał

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
Stary CmsHeaderStart chronił pośrednie odejmowanie zamiast swojego wyniku końcowego, więc wycinek mógł zaczynać się nawet przed buforem, a przebudowany SignedData niósł go tam, gdzie należał jego zbiór certyfikatów

Co gwarantuje DER, że wyprowadzenie w przód jest dokładne?

DER gwarantuje, że kodowanie długości jest czystą funkcją długości. X.690 klauzula 10.1 ogranicza DER do formy określonej i wymaga minimalnej liczby bajtów, co usuwa dwie swobody, na które pozwala BER: formę nieokreśloną i dopełnianie długości w formie długiej wiodącymi zerami. Zgodnie z tą regułą długość treści poniżej 128 ma dokładnie jeden bajt długości, a każda inna długość ma jeden bajt początkowy plus dokładnie tyle kolejnych bajtów, ile bajtów znaczących potrzebuje ta długość. Wołający CmsHeaderStart trzyma już ContentLen, bo ReadTlv właśnie je zwróciło, więc długość nagłówka jest obliczalna bez patrzenia na choćby jeden bajt 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
Próbki budowane z certyfikatów 32- i 64-bajtowych zostawały w formie krótkiej, gdzie chodzenie wstecz odpowiada poprawnie z niewłaściwego powodu, dlatego zestaw przypadków brzegowych przekracza teraz 127, 128, 255 i 256 bajtów
// Dostarczony pomocnik: wyprowadź nagłówek z długości treści.
// W X.690 10.1 bajty długości są funkcją ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // forma krótka, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // bajt początkowy, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // po jednym na bajt znaczący
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Dwa szczegóły czynią to bezpiecznym, a nie tylko wiarygodnym. Po pierwsze, założenie, że wejściem jest DER, jest egzekwowane wyżej: TDerReader.TryReadTlvAt, na którym zbudowane jest ReadTlv, odrzuca formę nieokreśloną, odrzuca długość w formie długiej, której pierwszy kolejny bajt jest zerem, i odrzuca pojedynczy kolejny bajt poniżej $80. TLV, który dociera do CmsSliceTlv, przeszedł już te sprawdzenia, więc nieminimalna długość w stylu BER nie może dotrzeć do wyprowadzenia i zmusić go do kłamstwa. Po drugie, ścieżka awaryjna dla wyniku ujemnego pilnuje teraz prawdziwej odpowiedzi, a nie wartości pośredniej. Warto powiedzieć, że czytnik przez cały czas znał offset tagu: TDerTlv niesie i Offset, i HeaderLength, a gubi je tylko interfejs ReadTlv z czterema parametrami wyjściowymi. Zwracanie ich byłoby czystszym interfejsem na dłuższą metę; dostarczona poprawka zachowuje ten interfejs bez zmian i czyni pomocnika poprawnym na jego własnych zasadach

Dlaczego testy znacznika czasu przechodziły z tym błędem w kodzie?

Bo każdy certyfikat w próbkach był dość krótki, by użyć formy krótkiej, a chodzenie wstecz jest poprawne dokładnie w tym przypadku. Tests.PadesTimestamp.pas buduje swój certyfikat podpisującego przez SetLength(SignerCertDer, 32) w jednym teście i 64 w drugim, wypełniając go rampą bajtów. Zbiór certyfikatów 32-bajtowy koduje się jako A0 20, a 64-bajtowy jako A0 40, każdy z jednym bajtem długości. Chodzenie wstecz od treści ląduje na tym jednym bajcie, jego górny bit jest wyzerowany, bo to pierwszy i jedyny bajt długości, i pomocnik odpowiada poprawnie z niewłaściwego powodu. Zestaw 1414 przypadków był zielony, CMS ze znacznikiem czasu się parsował, walidator pierwszego etapu raportował B-T, a każde z tych sprawdzeń biegło przeciw zbiorowi certyfikatów, jakiego nie zawierał nigdy żaden prawdziwy dokument

Ogólna reguła jest tu częścią pożyteczną. Ilekroć jakaś ścieżka kodu zależy od sposobu zakodowania długości, próbka musi przekroczyć granicę kodowania, a dla DER oznacza to treść dłuższą niż 127 bajtów, co wymusza formę długą, i najlepiej dłuższą niż 255 bajtów, co wymusza drugi kolejny bajt. Ta sama dyscyplina dotyczy drugiego przypadku z tego przeglądu, w którym samoweryfikacja nie mogła dostrzec odstępstwa od DER: niesortowany SET OF w signedAttrs był niewidoczny dla round-tripu w obrębie tego samego źródła ze strukturalnie identycznego powodu — test ćwiczył wyłącznie wejścia, na których błędny kod i poprawny kod zgadzają się ze sobą. Szkic poniżej woła pomocnika wycinającego bezpośrednio, co oznacza wyeksportowanie go z FPdfCms.pas na potrzeby kompilacji testowej; tę samą granicę da się osiągnąć przez interfejs publiczny, podając BuildSignedData certyfikat łańcucha każdego rozmiaru i parsując ponownie wynik ze znacznikiem czasu

// Przypnij granicę: wycinek przez nagłówek w formie długiej musi zaczynać się na tagu
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;
      // treść zaczyna się zaraz po nagłówku; wycinek musi być całym TLV
      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;

Gdzie przebudowa wciąż wyznacza granice

AddSignatureTimestampToCms jest napisany pod CMS, który emituje BuildSignedData, i stąd wynikają jego granice. Przejście oczekuje jednego SignerInfo i tylko ten jeden emituje od nowa, więc obcy CMS z wieloma podpisującymi wróciłby z jednym podpisującym; rozpoznaje opcjonalny zbiór certificates [0], ale nie zbiór crls [1], a CMS niosący taki zbiór zawodzi głośno wyjątkiem signerInfos SET expected, zamiast po cichu źle wycinać. Nowy unsignedAttrs trzyma jeden atrybut, więc reguła kolejności SET OF z X.690 klauzula 11.6 jest spełniona trywialnie i nie wymaga sortowania. A część podpisana pozostaje z założenia nietknięta: prefiks SignerInfo aż po OCTET STRING podpisu jest kopiowany dosłownie, dlatego walidator, który liczy skrót z signedAttrs ponownie, widzi te same bajty przed dodaniem znacznika czasu i po nim. Gdy mimo to odrzuca dokument, przyczyny są zwykle gdzie indziej i zasługują na własną listę kontrolną

Czytnik DER, piszący, budowniczy CMS i ta injekcja znacznika czasu są dostarczane jako źródło Pascala razem z PDFium Delphi component, i błąd tego kształtu jest argumentem za tym: gdy przebudowany SignedData wychodzi o dziewięćdziesiąt bajtów za długi, chcesz przeczytać pomocnika, który ciął wycinek, i klauzulę X.690, którą źle odczytał, a nie stos wywołań z czarnej skrzynki