Artículo técnico

SHAKE256 XOF en Delphi: separación de dominios Keccak

PDFlibPas implementa SHA-3 y SHAKE256 sobre una única permutación Keccak-f[1600] en Object Pascal. Las dos familias difieren exactamente en dos sitios: 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 hace 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 luego produce basura la primera vez que alguien pide 64. Lo que sigue es cómo las dos familias acabaron compartiendo un único registro de contexto en PDFlibDigest, dónde se detiene el punto de entrada one-shot, y por qué el XOF de streaming tenía que existir antes de que Ed448 o ML-DSA pudieran

¿Por qué SHA-3 y SHAKE256 comparten un 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 ambas familias sin cambios, y hay exactamente una copia de la permutación de 24 rondas en la librería. Los inicializadores son donde divergen los dos caminos, y divergen por completo

Un contexto Keccak sirviendo a dos familias en PDFlibPas: TSHA3Context guarda el separador de dominio en un único byte Suffix, SHA3Init fija el sufijo a $06 con Rate derivado como 200 menos dos veces la longitud de salida, y SHAKE256Init fija $1F con el rate clavado en 136 bytes, así 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 inicializadores son el único sitio donde divergen los caminos
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 está fijo en 136 bytes sea lo que sea 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 de SHA3Init da 136, 104 y 72 bytes de rate para SHA3-256, SHA3-384 y SHA3-512, así que para la familia de longitud fija el rate y el nivel de seguridad van soldados. SHAKE256Init rompe ese vínculo a propósito: OutputLength se convierte en una petición de bytes, no en una declaración de fortaleza de seguridad, y la capacidad del sponge se queda en 512 bits por mucho output que extraigas. Ese desacoplamiento es la definición de una función de salida extensible, y escribirlo como dos inicializadores sobre un contexto hace visible la distinción en el código fuente en lugar de enterrarla en un comentario

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

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

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

Nada, siempre que hagas XOR de los dos bytes de padding en el estado en lugar de asignarlos. FIPS 202 §B.2 pone el sufijo de dominio en la posición de absorb actual y $80 en el último byte del bloque. Cuando el mensaje deja por casualidad la posición en Rate- 1, esas dos escrituras apuntan al mismo byte y tienen que combinarse en $9F. Si haces store en lugar de XOR gana uno de los dos, el padding queda silenciosamente mal para exactamente una longitud de mensaje de cada 136, y todos los vectores de regresión que tienes pasan porque ninguno cae ahí. PLShakeXOFFinalize en PDFlibPas hace XOR de ambos, lo que hace que el caso solapado salga del caso general sin ninguna rama

El caso de solapamiento de pad10*1 en PDFlibPas: PLShakeXOFFinalize coloca el sufijo 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 ambas escrituras apuntan a un byte, el XOR las combina en $9F y un store plano corrompería una longitud de mensaje de cada 136
Hacer store en lugar de XOR deja que un byte de padding gane, y como el defecto aparece para exactamente una longitud de mensaje de cada 136, todos los vectores de regresión que tienes pueden pasar mientras el padding está silenciosamente mal
// Rellena el último bloque parcial y entra en la fase de squeeze
Procedure PLShakeXOFFinalize(Var Ctx: TPLShakeXOF);
Begin
  If Not Ctx.Absorbing Then
    Exit;
  Ctx.Absorbing:= False;
  // sufijo en SqueezePos, $80 en Rate-1; cuando SqueezePos = Rate-1 los dos
  // XOR caen en el mismo byte y se combinan correctamente 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 output sin límite a través de un registro separado, TPLShakeXOF, que guarda solo State como 25 palabras UInt64, Rate, Suffix, un flag Absorbing y SqueezePos. No hay buffer de mensaje: PLShakeXOFAbsorb hace XOR de los bytes entrantes directamente en el estado y permuta cada vez que llena un bloque, con SqueezePos sirviendo como 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 tiene el número de bytes que pidió. El output entre bloques se comprobó a 200 bytes, pasada la primera frontera de bloque en ambas variantes

SHAKE de streaming en PDFlibPas mediante TPLShakeXOF, un registro 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 hace XOR de bytes directo en el sponge, PLShakeXOFFinalize rellena y permuta, y PLShakeXOFSqueeze sigue permutando hasta que el caller tiene todos los bytes
Absorber en varias llamadas sin concatenar primero es lo que permite a Ed448 extraer sus 114 bytes y al port de ML-DSA hacer squeeze indefinidamente, una cobertura que ningún punto de entrada one-shot con su techo de 136 bytes podría dar
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 cadena dom4 y un valor de 57 bytes antes de hashear, y un rejection sampler quiere alimentar una semilla y bytes de índice sin concatenar cadenas primero. Que el registro de streaming llegara antes que cualquiera de los dos consumidores era el punto: el trabajo con curvas Ed448 y Brainpool en Pascal puro necesita 114 bytes de una sola vez, y el port de ML-DSA de FIPS 204 hace squeeze indefinidamente desde ambas variantes. Ninguno de los dos se habría podido escribir 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 recorre 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 sitio 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 valor independiente de DigestAlgorithm dejaría que cualquiera empareje SHAKE256 con RSA o ECDSA y produzca un diccionario de firma que ningún validador 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 hasta daSHA3_512 dentro de un flujo de trabajo PAdES de 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 gobierna cuándo SHA-3 se vuelve 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 cadena vacía es el que todo el mundo memoriza, y es un test débil precisamente porque se cita tanto. El output 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 comprobar. La regresión de PDFlibPas compara ambos vectores en toda su longitud. Añade además un caso entre bloques, porque un bucle de squeeze que nunca cruza una frontera 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 las dos familias y o bien 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 enunciar con honestidad en lugar de dejar que los callers lo descubran. Para ver cómo encaja la capa de digest junto al tooling de firma, cifrado y PDF/A en una base de código Object Pascal nativa, la página de producto de la librería de PDF en Delphi PDFlibPas lista el conjunto completo de componentes y la matriz de compiladores soportados