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
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
// 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
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