Artykuł techniczny

XOF SHAKE256 w Delphi: separacja domen Keccak

PDFlibPas implementuje SHA-3 i SHAKE256 na pojedynczej permutacji Keccak-f[1600] w Object Pascalu. Obie rodziny różnią się w dokładnie dwóch miejscach: bajcie domeny pad10*1, $06 dla SHA-3 i $1F dla SHAKE według FIPS 202 §6.2 i §6.3, oraz regule rate. SHAKE256 przypina swój rate na 136 bajtach niezależnie od długości wyjścia

Ta druga różnica to ta, którą ludzie psują, bo wcale nie wygląda jak różnica. SHA3-256 też działa z rate 136, więc SHAKE256 zbudowane przez skopiowanie ścieżki kodu SHA-3 przechodzi każdy test 32-bajtowy, jaki na nie rzucisz, a potem produkuje śmieci za pierwszym razem, gdy ktoś poprosi o 64. Dalej: jak obie rodziny wylądowały na współdzieleniu jednego rekordu kontekstu w PDFlibDigest, gdzie kończy się one-shotowy punkt wejścia, i dlaczego strumieniowe XOF musiało powstać, zanim Ed448 albo ML-DSA mogły istnieć

Dlaczego SHA-3 i SHAKE256 współdzielą jeden kontekst Keccak?

Bo jedynym stanem per-rodzina jest pojedynczy bajt, a PDFlibPas trzyma go w TSHA3Context jako Suffix. Gdy separator domeny żyje w kontekście zamiast być zahardkodowany w rutynie finalizacji, SHA3AbsorbBlock, SHA3Update i SHA3Final obsługują obie rodziny bez zmian, a w bibliotece jest dokładnie jedna kopia 24-rundowej permutacji. Inicjalizatory to miejsce, w którym obie ścieżki się rozchodzą, i rozchodzą się całkowicie

Jeden kontekst Keccak obsługujący dwie rodziny w PDFlibPas: TSHA3Context trzyma separator domeny w pojedynczym bajcie Suffix, SHA3Init ustawia sufiks na $06 z Rate wyprowadzonym jako 200 minus dwukrotność długości wyjścia, a SHAKE256Init ustawia $1F z rate przypiętym na 136 bajtach, więc SHA3AbsorbBlock, SHA3Update i SHA3Final obsługują obie bez zmian
Bo jedynym stanem per-rodzina jest jeden bajt, obie rodziny hashy współdzielą pojedynczą kopię 24-rundowej permutacji, a dwa inicjalizatory to jedyne miejsce, w którym ścieżki się rozchodzą
Type
  TSHA3Context= Record
    State: Array [0.. 24] Of UInt64;
    Buffer: Array [0.. 135] Of Byte;
    BufferLength: Integer;
    Rate: Integer;
    OutputLength: Integer;
    // Separator domeny: $06 dla SHA3, $1F dla SHAKE (FIPS 202 §6.2/6.3)
    Suffix: Byte;
  End;

Procedure SHA3Init(Var Context: TSHA3Context; OutputBits: Integer);
Begin
  FillChar(Context, SizeOf(Context), 0);
  Context.OutputLength:= OutputBits div 8;
  Context.Rate:= 200- 2* Context.OutputLength;   // rate podąża za rozmiarem digestu
  Context.Suffix:= $06;
End;

// SHAKE256: rate jest stały na 136 bajtach niezależnie od tego, o co prosi
// wywołujący, bo SHAKE to funkcja o rozciągalnym wyjściu
Procedure SHAKE256Init(Var Context: TSHA3Context; OutputBytes: Integer);
Begin
  FillChar(Context, SizeOf(Context), 0);
  Context.OutputLength:= OutputBytes;
  Context.Rate:= 136;
  Context.Suffix:= $1F;
End;

Wzór w SHA3Init daje 136, 104 i 72 bajty rate dla SHA3-256, SHA3-384 i SHA3-512, więc rate i poziom bezpieczeństwa są zespawane dla rodziny o stałej długości. SHAKE256Init celowo zrywa ten związek: OutputLength staje się prośbą o bajty, a nie deklaracją siły bezpieczeństwa, a pojemność gąbki zostaje na 512 bitach niezależnie od tego, ile wyjścia wyciągniesz. To rozprzężenie jest definicją funkcji o rozciągalnym wyjściu, a zapisanie go jako dwóch inicjalizatorów nad jednym kontekstem czyni różnicę widoczną w źródle zamiast zakopanej w komentarzu

Sufit 136 bajtów na one-shotowym punkcie wejścia

Funkcje wygody w PDFlibPas odrzucają wszystko poza 1..136 bajtów, a ten limit jest uczciwy, nie arbitralny. SHAKE256StreamRange i SHAKE256ByteArray obie przechodzą przez SHA3Final, które paddingu ostatni blok, permutuje raz i czyta wyjście wprost ze stanu. Tylko pierwsze Rate bajtów tego stanu to legalne wyjście, więc jedno squeeze daje co najwyżej 136 bajtów; powyżej potrzebujesz świeżej permutacji na blok. Oba prawdziwe przypadki konsumpcji i tak mieszczą się w oknie: profil podpisu PDF digestuje 32 bajty, a Ed448 potrzebuje 114 według RFC 8032 §5.2. SHAKE256StreamRange przyjmuje tę samą trójkę FirstLength, SecondStart, SecondLength co SHA256StreamRange i SHA3_256StreamRange, więc kod walidacyjny przechodzi po /ByteRange podpisu tak samo dla każdego algorytmu i nigdy nie robi wyjątku dla XOF

Co się psuje w pad10*1, gdy pozycja ląduje na rate-1?

Nic, pod warunkiem że XOR-ujesz oba bajty paddingu do stanu zamiast je przypisywać. FIPS 202 §B.2 stawia sufiks domeny na aktualnej pozycji absorb i $80 na ostatnim bajcie bloku. Gdy wiadomość akurat zostawia pozycję na Rate- 1, te dwa zapisy celują w ten sam bajt i muszą się złożyć w $9F. Zapis zamiast XOR-a sprawia, że jeden z nich wygrywa, padding jest po cichu zły dla dokładnie jednej długości wiadomości na 136, a każdy wektor regresji, który posiadasz, przechodzi, bo żaden tam nie ląduje. PLShakeXOFFinalize w PDFlibPas XOR-uje oba, co sprawia, że przypadek nakładający się wynika z ogólnego bez żadnego rozgałęzienia

Przypadek nakładania się pad10*1 w PDFlibPas: PLShakeXOFFinalize stawia sufiks domeny na aktualnej pozycji absorb i $80 na ostatnim bajcie bloku; gdy pozycja ląduje na Rate minus 1, oba zapisy celują w jeden bajt, XOR składa je w $9F, a zwykły zapis zepsułby jedną długość wiadomości na 136
Zapis zamiast XOR-a pozwala jednemu bajtowi paddingu wygrać, a ponieważ defekt pojawia się dla dokładnie jednej długości wiadomości na 136, każdy twój wektor regresji może przechodzić, podczas gdy padding jest po cichu zły
// Paddinguje ostatni częściowy blok i wchodzi w fazę squeeze
Procedure PLShakeXOFFinalize(Var Ctx: TPLShakeXOF);
Begin
  If Not Ctx.Absorbing Then
    Exit;
  Ctx.Absorbing:= False;
  // sufiks na SqueezePos, $80 na Rate-1; gdy SqueezePos = Rate-1, oba
  // XOR-y lądują na tym samym bajcie i poprawnie składają się w $9F
  Ctx.State[Ctx.SqueezePos div 8]:= Ctx.State[Ctx.SqueezePos div 8]xor
    (UInt64(Ctx.Suffix)shl (8* (Ctx.SqueezePos and 7)));
  Ctx.State[(Ctx.Rate- 1)div 8]:= Ctx.State[(Ctx.Rate- 1)div 8]xor
    (UInt64($80)shl (8* ((Ctx.Rate- 1)and 7)));
  SHA3Permute(Ctx.State);
  Ctx.SqueezePos:= 0;
End;

TPLShakeXOF: absorbowanie wprost do gąbki

PDFlibPas wystawia nieograniczone wyjście przez osobny rekord, TPLShakeXOF, trzymający tylko State jako 25 słów UInt64, Rate, Suffix, flagę Absorbing i SqueezePos. Nie ma bufora wiadomości: PLShakeXOFAbsorb XOR-uje przychodzące bajty bezpośrednio do stanu i permutuje, ilekroć wypełni blok, a SqueezePos służy jako offset w obu fazach. PLShakeXOFInit wybiera wariant jednym booleanem, True dla SHAKE128 z rate 168 i False dla SHAKE256 z rate 136, a PLShakeXOFSqueeze permutuje dalej, dopóki wywołujący nie dostanie liczby bajtów, o którą prosił. Wyjście przekraczające blok było sprawdzane przy 200 bajtach, za pierwszą granicą bloku w obu wariantach

Strumieniowe SHAKE w PDFlibPas przez TPLShakeXOF, rekord ze State, Rate, Suffix, flagą Absorbing i SqueezePos, ale bez bufora wiadomości: PLShakeXOFInit wybiera SHAKE128 z rate 168 albo SHAKE256 z rate 136, PLShakeXOFAbsorb XOR-uje bajty wprost do gąbki, PLShakeXOFFinalize paddingu i permutuje, a PLShakeXOFSqueeze permutuje dalej, dopóki wywołujący nie dostanie każdego bajtu
Absorbowanie w kilku wywołaniach bez wcześniejszej konkatenacji pozwala Ed448 wyciągnąć swoje 114 bajtów, a portowi ML-DSA squeeze w nieskończoność — pokrycie, którego żaden one-shotowy punkt wejścia z sufitem 136 bajtów nie mógłby obsłużyć
Var
  Ctx: TPLShakeXOF;
  Digest: Array [0.. 113] Of Byte;   // Ed448 chce 114 bajtów, RFC 8032 §5.2
Begin
  PLShakeXOFInit(Ctx, False);        // False = SHAKE256, rate 136
  PLShakeXOFAbsorb(Ctx, @Dom4[1], Length(Dom4));
  PLShakeXOFAbsorb(Ctx, @Prefix[1], Length(Prefix));
  PLShakeXOFAbsorb(Ctx, @Msg[1], Length(Msg));
  PLShakeXOFFinalize(Ctx);
  PLShakeXOFSqueeze(Ctx, @Digest[0], SizeOf(Digest));
End;

// Albo, gdy potrzebujesz tylko jednego bufora na wejściu i jednego na wyjściu:
//   PLShakeXOF(False, Input, Output, 114)

Absorbowanie w kilku wywołaniach ma większe znaczenie, niż wygląda. Ed448 poprzedza wiadomość stringiem dom4 i 57-bajtową wartością przed hashowaniem, a rejection sampler chce podać ziarno i bajty indeksu bez wcześniejszego sklejania stringów. To, że rekord strumieniowy wylądował przed oboma konsumentami, było sednem: czysto pascalowa robota z krzywymi Ed448 i Brainpool potrzebuje 114 bajtów w jednym strzale, a port FIPS 204 ML-DSA squeezuje w nieskończoność z obu wariantów. Żadne z nich nie dałoby się napisać pod one-shotowym API

Dlaczego PDFlibPas odmawia wystawienia SHAKE256 jako selektora digestu?

Bo nie ma legalnego sposobu, żeby go wybrać. TPDFlibDigestAlgorithm w PDFlibDigitalSign prowadzi daAuto, daSHA1, daSHA256, daSHA384, daSHA512, daSHA3_256, daSHA3_384, daSHA3_512 i się zatrzymuje. Nie ma daSHAKE256, a to pominięcie to decyzja, nie przeoczenie. RFC 8702 rejestruje SHAKE w CMS, ale wewnątrz podpisu PDF jedynym miejscem, w którym ISO/TS 32002 stawia SHAKE256, jest wewnętrzny digest Ed448, gdzie wybiera go algorytm podpisu, a nie wywołujący. Opublikowanie go jako samodzielnej wartości DigestAlgorithm pozwoliłoby każdemu sparować SHAKE256 z RSA albo ECDSA i wyprodukować słownik podpisu, którego żaden walidator nie zaakceptuje. Możliwość jest, selektora nie ma, a kod po stronie weryfikacji woła SHAKE256StreamRange po układzie /ByteRange bezpośrednio. Jeśli dziś wybierasz digest dla podpisów, które kontrahent musi walidować, daSHA256 do daSHA3_512 w workflow PAdES B-B do B-LTA ze znacznikami czasu i długoterminowymi danymi walidacji pozostaje odpowiedzią; ISO/TS 32001 to to, co rządzi tym, kiedy SHA-3 w ogóle stanie się rutyną w PDF

Testowanie XOF: wektor, który wygląda, jakby przechodził

Porównuj pełną długość wyjścia i używaj więcej niż jednego wektora. Wynik SHAKE256 dla pustego stringa to ten, który wszyscy zapamiętują, i jest słabym testem właśnie dlatego, że jest tak powszechnie cytowany. 32-bajtowe wyjście SHAKE256 dla abc jest gorsze: jego pierwsze 16 bajtów, 483366601360a8771c6863080cc4114d, pokrywa się z kilkoma sąsiednimi wektorami, więc ucięte porównanie raportuje sukces, podczas gdy dyskryminująca połowa, 8db44530f8f1e1ee4f94ea37e78b5739, zostaje niesprawdzona. Regresja PDFlibPas porównuje oba wektory na całej długości. Dorzuć też przypadek przekraczający blok, bo pętla squeeze, która nigdy nie przecina granicy rate, nie została przetestowana wcale

Szersza lekcja z wtopienia SHAKE w istniejącą implementację SHA-3 jest taka, że współdzielony kod nigdy nie był ryzykiem. Keccak-f[1600] to ta sama permutacja dla obu rodzin i albo zgadza się z wektorami, albo nie. Ryzyko żyje w dwóch bajtach i jednym integerze, które się różnią, oraz w suficie, który musisz podać uczciwie, zamiast pozwolić wywołującym odkryć go na własną rękę. Żeby zobaczyć, jak warstwa digestu pasuje obok narzędzi podpisywania, szyfrowania i PDF/A w natywnej bazie kodu Object Pascala, strona produktu biblioteki PDFlibPas Delphi PDF listuje pełny zestaw komponentów i wspieraną macierz kompilatorów