Artículo técnico

SHAKE256 XOF en Delphi: separación de dominio Keccak

PDFlibPas implementa SHA-3 y SHAKE256 sobre una sola permutación Keccak-f[1600] en Object Pascal. Las dos familias difieren en exactamente dos lugares: el byte de dominio pad10*1, $06 para SHA-3 y $1F para SHAKE según FIPS 202 §6.2 y §6.3, y la regla de rate. SHAKE256 fija su rate en 136 bytes sin importar la longitud de salida

Esa segunda diferencia es la que la gente entiende mal, porque no parece una diferencia en absoluto. SHA3-256 también corre a rate 136, así que un SHAKE256 construido copiando el camino de código de SHA-3 pasa cada test de 32 bytes que le lances y después produce basura la primera vez que alguien pida 64. Lo que sigue es cómo las dos familias terminaron compartiendo un record de contexto en PDFlibDigest, dónde se detiene el punto de entrada one-shot, y por qué el XOF streaming tuvo que existir antes de que Ed448 o ML-DSA pudieran

¿Por qué SHA-3 y SHAKE256 comparten un solo contexto Keccak?

Porque el único estado por familia es un solo byte, y PDFlibPas lo guarda en TSHA3Context como Suffix. Una vez que el separador de dominio vive en el contexto en lugar de estar hardcodeado en la rutina de finalización, SHA3AbsorbBlock, SHA3Update y SHA3Final sirven a las dos familias sin cambios, y hay exactamente una copia de la permutación de 24 rondas en la librería. Los initializers son donde los dos caminos divergen, y divergen por completo

Un contexto Keccak sirviendo a dos familias en PDFlibPas: TSHA3Context guarda el separador de dominio en un solo byte Suffix, SHA3Init pone el suffix en $06 con Rate derivado como 200 menos el doble de la longitud de salida, y SHAKE256Init pone $1F con el rate fijo en 136 bytes, así que SHA3AbsorbBlock, SHA3Update y SHA3Final sirven a ambas sin cambios
Como el único estado por familia es un byte, ambas familias de hash comparten una sola copia de la permutación de 24 rondas, y los dos initializers son el único lugar donde los caminos divergen
Type
  TSHA3Context= Record
    State: Array [0.. 24] Of UInt64;
    Buffer: Array [0.. 135] Of Byte;
    BufferLength: Integer;
    Rate: Integer;
    OutputLength: Integer;
    // Separador de dominio: $06 para SHA3, $1F para 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;   // el rate sigue al tamaño del digest
  Context.Suffix:= $06;
End;

// SHAKE256: el rate es fijo en 136 bytes pida lo que pida el caller,
// porque SHAKE es una función de salida extensible
Procedure SHAKE256Init(Var Context: TSHA3Context; OutputBytes: Integer);
Begin
  FillChar(Context, SizeOf(Context), 0);
  Context.OutputLength:= OutputBytes;
  Context.Rate:= 136;
  Context.Suffix:= $1F;
End;

La fórmula en SHA3Init da 136, 104 y 72 bytes de rate para SHA3-256, SHA3-384 y SHA3-512, así que rate y nivel de seguridad quedan soldados para la familia de longitud fija. SHAKE256Init rompe ese vínculo a propósito: OutputLength se vuelve una petición de bytes, no una declaración de fortaleza de seguridad, y la capacidad del sponge se queda en 512 bits sin importar cuánta salida extraigas. Ese desacoplamiento es la definición de una función de salida extensible, y escribirlo como dos initializers sobre un contexto hace la distinción visible en el código fuente en lugar de enterrada en un comentario

El techo de 136 bytes del punto de entrada one-shot

Las funciones de conveniencia en PDFlibPas rechazan cualquier cosa fuera de 1..136 bytes, y ese límite es honesto más que arbitrario. SHAKE256StreamRange y SHAKE256ByteArray pasan ambas por SHA3Final, que rellena el último bloque, permuta una vez y lee la salida directo del estado. Solo los primeros Rate bytes de ese estado son salida legal, así que un squeeze produce como máximo 136 bytes; más allá necesitas una permutación fresca por bloque. Los dos consumidores reales caben de todos modos en la ventana: el perfil de firma PDF digiere 32 bytes, y Ed448 necesita 114 según RFC 8032 §5.2. SHAKE256StreamRange toma el mismo triple FirstLength, SecondStart, SecondLength que SHA256StreamRange y SHA3_256StreamRange, así que el código de validación recorre el /ByteRange de una firma de la misma manera para cada algoritmo y nunca hace un caso especial para el XOF

¿Qué sale mal en pad10*1 cuando la posición cae en rate-1?

Nada, siempre que XORes ambos bytes de padding al estado en lugar de asignarlos. FIPS 202 §B.2 pone el suffix de dominio en la posición de absorb actual y $80 en el último byte del bloque. Cuando el mensaje deja la posición justo en Rate- 1, esas dos escrituras apuntan al mismo byte y tienen que combinarse en $9F. Guardar en lugar de XOR y uno de los dos gana, el padding queda silenciosamente mal para exactamente una longitud de mensaje de cada 136, y cada vector de regresión que tienes pasa porque ninguno cae ahí. PLShakeXOFFinalize en PDFlibPas XORea ambos, lo que hace que el caso de solapamiento salga del caso general sin ningún branch

El caso de solapamiento de pad10*1 en PDFlibPas: PLShakeXOFFinalize pone el suffix de dominio en la posición de absorb actual y $80 en el último byte del bloque; cuando la posición cae en Rate menos 1 las dos escrituras apuntan a un byte, el XOR las combina en $9F y un store simple corrompería una longitud de mensaje de cada 136
Guardar en lugar de XORear deja que un byte de padding gane, y como el defecto aparece para exactamente una longitud de mensaje de cada 136, todos tus vectores de regresión pueden pasar mientras el padding está silenciosamente mal
// Rellena el último bloque parcial y entra a la fase de squeeze
Procedure PLShakeXOFFinalize(Var Ctx: TPLShakeXOF);
Begin
  If Not Ctx.Absorbing Then
    Exit;
  Ctx.Absorbing:= False;
  // suffix en SqueezePos, $80 en Rate-1; cuando SqueezePos = Rate-1 los dos
  // XORs caen en el mismo byte y se combinan bien en $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: absorbiendo directo en el sponge

PDFlibPas expone la salida ilimitada con un record aparte, TPLShakeXOF, que solo guarda State como 25 palabras UInt64, Rate, Suffix, un flag Absorbing y SqueezePos. No hay buffer de mensaje: PLShakeXOFAbsorb XORea los bytes entrantes directo en el estado y permuta cada vez que llena un bloque, con SqueezePos sirviendo de offset en ambas fases. PLShakeXOFInit elige la variante con un booleano, True para SHAKE128 a rate 168 y False para SHAKE256 a rate 136, y PLShakeXOFSqueeze sigue permutando hasta que el caller tenga la cantidad de bytes que pidió. La salida entre bloques se chequeó a 200 bytes, pasado el primer límite de bloque en ambas variantes

SHAKE streaming en PDFlibPas a través de TPLShakeXOF, un record con State, Rate, Suffix, un flag Absorbing y SqueezePos pero sin buffer de mensaje: PLShakeXOFInit elige SHAKE128 a rate 168 o SHAKE256 a rate 136, PLShakeXOFAbsorb XORea bytes directo en el sponge, PLShakeXOFFinalize rellena y permuta, y PLShakeXOFSqueeze sigue permutando hasta que el caller tenga todos los bytes
Absorber en varias llamadas sin concatenar primero es lo que le permite a Ed448 tomar sus 114 bytes y al port de ML-DSA hacer squeeze indefinidamente, cobertura que ningún punto de entrada one-shot con su techo de 136 bytes podría soportar
Var
  Ctx: TPLShakeXOF;
  Digest: Array [0.. 113] Of Byte;   // Ed448 quiere 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;

// O, cuando un buffer de entrada y uno de salida es todo lo que necesitas:
//   PLShakeXOF(False, Input, Output, 114)

Absorber en varias llamadas importa más de lo que parece. Ed448 prefija el mensaje con una string dom4 y un valor de 57 bytes antes de hashear, y un rejection sampler quiere alimentar una semilla y bytes de índice sin concatenar strings primero. Que el record streaming aterrizara antes que cualquiera de los dos consumidores era el punto: el trabajo de Ed448 y curvas Brainpool en Pascal puro necesita 114 bytes de una vez, y el port de ML-DSA de FIPS 204 post-quántico en Pascal puro hace squeeze indefinidamente desde ambas variantes. Ninguno de los dos se podría haber escrito contra la API one-shot

¿Por qué PDFlibPas se niega a exponer SHAKE256 como selector de digest?

Porque no hay forma legal de elegirlo. TPDFlibDigestAlgorithm en PDFlibDigitalSign corre daAuto, daSHA1, daSHA256, daSHA384, daSHA512, daSHA3_256, daSHA3_384, daSHA3_512, y se detiene. No hay daSHAKE256, y la omisión es una decisión, no un descuido. RFC 8702 registra SHAKE en CMS, pero dentro de una firma PDF el único lugar donde ISO/TS 32002 pone SHAKE256 es como digest interno de Ed448, donde el algoritmo de firma lo elige y el caller no. Publicarlo como un valor DigestAlgorithm independiente dejaría que cualquiera combine SHAKE256 con RSA o ECDSA y produzca un diccionario de firma que ningún validador va a aceptar. La capacidad viaja, el selector no, y el código del lado de verificación llama a SHAKE256StreamRange sobre el layout /ByteRange directamente. Si estás eligiendo un digest para firmas que una contraparte tiene que validar hoy, daSHA256 a daSHA3_512 dentro de un flujo PAdES B-B a B-LTA con timestamping y datos de validación a largo plazo sigue siendo la respuesta; ISO/TS 32001 es lo que manda cuando SHA-3 se vuelva rutina en PDF

Probar un XOF: el vector que parece que pasa

Compara la longitud de salida completa y usa más de un vector. El resultado SHAKE256 de string vacía es el que todo el mundo memoriza, y es un test débil precisamente porque se cita tanto. La salida SHAKE256 de 32 bytes para abc es peor: sus primeros 16 bytes, 483366601360a8771c6863080cc4114d, coinciden con varios vectores vecinos, así que una comparación truncada reporta éxito mientras la mitad discriminante, 8db44530f8f1e1ee4f94ea37e78b5739, queda sin chequear. La regresión de PDFlibPas compara ambos vectores en toda su longitud. Agrega además un caso entre bloques, porque un loop de squeeze que nunca cruza un límite de rate no se ha probado en absoluto

La lección más amplia de plegar SHAKE en una implementación SHA-3 existente es que el código compartido nunca fue el riesgo. Keccak-f[1600] es la misma permutación para ambas familias y o coincide con los vectores o no. El riesgo vive en los dos bytes y el entero que difieren, y en el techo que tienes que declarar con honestidad en lugar de dejar que los callers lo descubran. Para ver cómo la capa de digest encaja junto con el firmado, el cifrado y las herramientas PDF/A en una codebase nativa de Object Pascal, la página del producto PDFlibPas, librería de PDF para Delphi lista el conjunto completo de componentes y la matriz de compiladores soportados