Technisch artikel

SHAKE256 XOF in Delphi: Keccak-domeinscheiding

PDFlibPas implementeert SHA-3 en SHAKE256 op een enkele Keccak-f[1600]-permutatie in Object Pascal. De twee families verschillen op exact twee plekken: de pad10*1-domeinbyte, $06 voor SHA-3 en $1F voor SHAKE volgens FIPS 202 §6.2 en §6.3, en de rateregel. SHAKE256 pind zijn rate op 136 bytes ongeacht de outputlengte

Dat tweede verschil is waar mensen de fout ingaan, want het ziet er helemaal niet uit als een verschil. SHA3-256 draait ook op rate 136, dus een SHAKE256 dat door de SHA-3-codepad te kopiëren is gebouwd slaagt elke 32-byte test die je ertegenaan gooit en produceert daarna garbage zodra iemand om 64 vraagt. Wat volgt is hoe de twee families in PDFlibDigest één contextrecord gingen delen, waar het one-shot-ingangspunt stopt, en waarom de streaming XOF moest bestaan voordat Ed448 of ML-DSA kon

Waarom delen SHA-3 en SHAKE256 één Keccak-context?

Omdat de enige per-family state één enkele byte is, en PDFlibPas die in TSHA3Context als Suffix bewaart. Zodra de domeinscheider in de context leeft in plaats van in de finalisatieroutine te zijn gehardcodeerd, bedienen SHA3AbsorbBlock, SHA3Update en SHA3Final beide families ongewijzigd, en er is exact één kopie van de 24-ronden-permutatie in de library. De initializers zijn waar de twee paden uiteenlopen, en ze lopen volledig uiteen

Eén Keccak-context die twee families bedient in PDFlibPas: TSHA3Context bewaart de domeinscheider in een enkele Suffix-byte, SHA3Init zet de suffix op $06 met Rate afgeleid als 200 min tweemaal de outputlengte, en SHAKE256Init zet $1F met de rate gepind op 136 bytes, zodat SHA3AbsorbBlock, SHA3Update en SHA3Final beide ongewijzigd bedienen
Omdat de enige per-family state één byte is, delen beide hash-families één kopie van de 24-ronden-permutatie, en de twee initializers zijn de enige plek waar de paden uiteenlopen
Type
  TSHA3Context= Record
    State: Array [0.. 24] Of UInt64;
    Buffer: Array [0.. 135] Of Byte;
    BufferLength: Integer;
    Rate: Integer;
    OutputLength: Integer;
    // Domeinscheider: $06 voor SHA3, $1F voor 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 volgt de digestgrootte
  Context.Suffix:= $06;
End;

// SHAKE256: rate is vast op 136 bytes wat de caller ook vraagt,
// want SHAKE is een extendable-output functie
Procedure SHAKE256Init(Var Context: TSHA3Context; OutputBytes: Integer);
Begin
  FillChar(Context, SizeOf(Context), 0);
  Context.OutputLength:= OutputBytes;
  Context.Rate:= 136;
  Context.Suffix:= $1F;
End;

De formule in SHA3Init geeft 136, 104 en 72 bytes rate voor SHA3-256, SHA3-384 en SHA3-512, dus rate en veiligheidsniveau zijn voor de fixed-length-familie aan elkaar gelast. SHAKE256Init verbreekt die link bewust: OutputLength wordt een verzoek om bytes, geen verklaring van security-sterkte, en de sponge-capaciteit blijft op 512 bits staan hoe veel output je ook trekt. Die ontkoppeling is de definitie van een extendable-output-functie, en door hem als twee initializers over één context te schrijven wordt het onderscheid in de bron zichtbaar in plaats van in een comment begraven

Het 136-byte-plafond op het one-shot-ingangspunt

De gemaksfuncties in PDFlibPas weigeren alles buiten 1..136 bytes, en die limiet is eerlijk in plaats van willekeurig. SHAKE256StreamRange en SHAKE256ByteArray lopen allebei via SHA3Final, dat het laatste blok pad, één keer permuteert en output rechtstreeks uit de state leest. Alleen de eerste Rate-bytes van die state zijn legale output, dus één squeeze levert hooguit 136 bytes op; daarboven heb je per blok een verse permutatie nodig. Beide echte consumers passen toch binnen het venster: het PDF-signatuurprofiel digest 32 bytes, en Ed448 heeft er 114 nodig volgens RFC 8032 §5.2. SHAKE256StreamRange neemt dezelfde FirstLength, SecondStart, SecondLength-triple als SHA256StreamRange en SHA3_256StreamRange, dus validatiecode loopt voor elke algoritme op dezelfde manier door een signatuur-/ByteRange en special-caset de XOF nooit

Wat gaat er mis in pad10*1 wanneer de positie op rate-1 landt?

Niets, mits je beide pad-bytes in de state XOR-t in plaats van ze toe te kennen. FIPS 202 §B.2 plaatst de domeinsuffix op de huidige absorb-positie en $80 op de laatste byte van het blok. Laat de boodschap de positie toevallig op Rate- 1 achter, dan mikken die twee writes op dezelfde byte en moeten ze tot $9F combineren. Sla je op in plaats van te XOR-en, dan wint er één, is de padding stilletjes fout voor exact één berichtlengte in de 136, en slaagt elk regressievector dat je bezit omdat geen ervan daar landt. PLShakeXOFFinalize in PDFlibPas XOR-t beide, waardoor het overlappende geval zonder branch uit het algemene geval valt

Het pad10*1-overlapgeval in PDFlibPas: PLShakeXOFFinalize plaatst de domeinsuffix op de huidige absorb-positie en $80 op de laatste byte van het blok; landt de positie op Rate min 1, dan mikken beide writes op één byte, combineert XOR ze tot $9F en zou een gewone store één berichtlengte in de 136 corrumperen
Opslaan in plaats van XOR-en laat één pad-byte winnen, en omdat het defect voor exact één berichtlengte in de 136 verschijnt kan elk regressievector dat je bezit slagen terwijl de padding stilletjes fout is
// Pad het laatste deeltje blok en ga de squeeze-fase in
Procedure PLShakeXOFFinalize(Var Ctx: TPLShakeXOF);
Begin
  If Not Ctx.Absorbing Then
    Exit;
  Ctx.Absorbing:= False;
  // suffix op SqueezePos, $80 op Rate-1; wanneer SqueezePos = Rate-1 mikken
  // de twee XORs op dezelfde byte en combineren ze correct tot $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: rechtstreeks in de sponge absorberen

PDFlibPas stelt onbegrensde output beschikbaar via een apart record, TPLShakeXOF, dat alleen State als 25 UInt64-woorden, Rate, Suffix, een Absorbing-flag en SqueezePos bevat. Er is geen berichtenbuffer: PLShakeXOFAbsorb XOR-t inkomende bytes rechtstreeks in de state en permuteert telkens wanneer hij een blok vult, met SqueezePos als offset in beide fasen. PLShakeXOFInit kiest de variant met één boolean, True voor SHAKE128 op rate 168 en False voor SHAKE256 op rate 136, en PLShakeXOFSqueeze blijft permuteren tot de caller het aantal bytes heeft dat hij vroeg. Cross-block output werd gecheckt op 200 bytes, voorbij de eerste blokgrens in beide varianten

Streaming SHAKE in PDFlibPas via TPLShakeXOF, een record met State, Rate, Suffix, een Absorbing-flag en SqueezePos maar zonder berichtenbuffer: PLShakeXOFInit kiest SHAKE128 op rate 168 of SHAKE256 op rate 136, PLShakeXOFAbsorb XOR-t bytes rechtstreeks de sponge in, PLShakeXOFFinalize pad en permuteert, en PLShakeXOFSqueeze blijft permuteren tot de caller elke byte heeft
In meerdere aanroepen absorberen zonder eerst te concatenaten is wat Ed448 zijn 114 bytes laat trekken en de ML-DSA-port onbeperkt laat squeezen, dekking die geen enkel one-shot-ingangspunt met zijn 136-byte-plafond zou kunnen dragen
Var
  Ctx: TPLShakeXOF;
  Digest: Array [0.. 113] Of Byte;   // Ed448 wil 114 bytes, 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;

// Of, wanneer één buffer in en één buffer uit alles is wat je nodig hebt:
//   PLShakeXOF(False, Input, Output, 114)

In meerdere aanroepen absorberen doet er meer toe dan het lijkt. Ed448 prefix de boodschap met een dom4-string en een 57-byte-waarde vóór het hashen, en een rejection-sampler wil een seed en indexbytes voeren zonder eerst strings te concatenaten. Dat het streaming record vóór beide consumers landde was het punt: het pure-Pascal Ed448- en Brainpool-curvewerk heeft 114 bytes in één keer nodig, en de FIPS 204 ML-DSA-port squeezet onbeperkt uit beide varianten. Geen van beide had tegen de one-shot-API geschreven kunnen worden

Waarom weigert PDFlibPas SHAKE256 als digest-selector bloot te stellen?

Omdat er geen legale manier is om hem te kiezen. TPDFlibDigestAlgorithm in PDFlibDigitalSign draait daAuto, daSHA1, daSHA256, daSHA384, daSHA512, daSHA3_256, daSHA3_384, daSHA3_512 en stopt. Er is geen daSHAKE256, en de weglating is een besluit, geen over het hoofd zien. RFC 8702 registreert SHAKE in CMS, maar binnen een PDF-signatuur is de enige plek waar ISO/TS 32002 SHAKE256 plaatst als interne digest van Ed448, waar het signatuuralgoritme hem kiest en de caller niet. Hem als vrijstaande DigestAlgorithm-waarde publiceren zou iedereen toestaan SHAKE256 met RSA of ECDSA te paren en een signatuurdictionary te produceren die geen enkele validator accepteert. De capability wordt geleverd, de selector niet, en verificatiecode roept SHAKE256StreamRange rechtstreeks over de /ByteRange-layout aan. Kies je een digest voor signatures die een tegenpartij vandaag moet valideren, dan blijven daSHA256 tot en met daSHA3_512 binnen een PAdES B-B- tot B-LTA-workflow met timestamping en validatiedata voor de lange termijn het antwoord; ISO/TS 32001 is wat bepaalt wanneer SHA-3 überhaupt routine wordt in PDF

Een XOF testen: het vector dat lijkt te slagen

Vergelijk de volledige outputlengte, en gebruik meer dan één vector. Het lege-string SHAKE256-resultaat is het vector dat iedereen uit zijn hoofd kent, en het is een zwakke test juist omdat het zo vaak wordt geciteerd. De 32-byte SHAKE256-output voor abc is erger: de eerste 16 bytes, 483366601360a8771c6863080cc4114d, vallen samen met verschillende naburige vectoren, dus een afgekapte vergelijking meldt succes terwijl de onderscheidende helft, 8db44530f8f1e1ee4f94ea37e78b5739, ongecheckt blijft. De PDFlibPas-regressie vergelijkt beide vectoren over hun hele lengte. Voeg ook een cross-block-geval toe, want een squeeze-lus die nooit een rate-grens kruist is helemaal niet getest

De bredere les van SHAKE in een bestaande SHA-3-implementatie vouwen is dat de gedeelde code nooit het risico was. Keccak-f[1600] is voor beide families dezelfde permutatie en hij matcht de vectoren of hij matcht ze niet. Het risico woont in de twee bytes en één integer die verschillen, en in het plafond dat je eerlijk moet uitspreken in plaats van het door callers te laten ontdekken. Wil je zien hoe de digest-laag naast de signing-, encryptie- en PDF/A-tooling in een native Object Pascal-codebase past, dan somt de PDFlibPas Delphi PDF-library productpagina de volledige componentenset en de ondersteunde compilermatrix op