Artigo Técnico

SHAKE256 XOF em Delphi: separação de domínio Keccak

A PDFlibPas implementa SHA-3 e SHAKE256 numa única permutação Keccak-f[1600] em Object Pascal. As duas famílias diferem em exatamente dois sítios: o byte de domínio pad10*1, $06 para SHA-3 e $1F para SHAKE segundo a FIPS 202 §6.2 e §6.3, e a regra do rate. O SHAKE256 fixa o seu rate em 136 bytes independentemente do comprimento da saída

Essa segunda diferença é a que as pessoas erram, porque não parece diferença nenhuma. O SHA3-256 também corre a rate 136, pelo que um SHAKE256 construído copiando o caminho de código do SHA-3 passa em todos os testes de 32 bytes que lhe atirar e depois produz lixo na primeira vez que alguém pede 64. O que se segue é como as duas famílias acabaram por partilhar um record de contexto em PDFlibDigest, onde o ponto de entrada one-shot para, e porque é que o XOF em streaming teve de existir antes de o Ed448 ou o ML-DSA poderem

Porque é que SHA-3 e SHAKE256 partilham um contexto Keccak?

Porque o único estado por família é um único byte, e a PDFlibPas guarda-o em TSHA3Context como Suffix. Uma vez que o separador de domínio vive no contexto em vez de estar codificado à força na rotina de finalização, SHA3AbsorbBlock, SHA3Update e SHA3Final servem as duas famílias sem alterações, e há exatamente uma cópia da permutação de 24 rondas na biblioteca. Os inicializadores são onde os dois caminhos divergem, e divergem por completo

Um contexto Keccak a servir duas famílias na PDFlibPas: o TSHA3Context guarda o separador de domínio num único byte Suffix, o SHA3Init define o suffix como $06 com o Rate derivado como 200 menos duas vezes o comprimento da saída, e o SHAKE256Init define $1F com o rate fixo em 136 bytes, pelo que SHA3AbsorbBlock, SHA3Update e SHA3Final servem ambos sem alterações
Porque o único estado por família é um byte, as duas famílias de hash partilham uma única cópia da permutação de 24 rondas, e os dois inicializadores são o único sítio onde os caminhos divergem
Type
  TSHA3Context= Record
    State: Array [0.. 24] Of UInt64;
    Buffer: Array [0.. 135] Of Byte;
    BufferLength: Integer;
    Rate: Integer;
    OutputLength: Integer;
    // Separador de domínio: $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;   // o rate segue o tamanho do digest
  Context.Suffix:= $06;
End;

// SHAKE256: o rate está fixo em 136 bytes seja o que o chamador pedir,
// porque o SHAKE é uma função de saída extensível
Procedure SHAKE256Init(Var Context: TSHA3Context; OutputBytes: Integer);
Begin
  FillChar(Context, SizeOf(Context), 0);
  Context.OutputLength:= OutputBytes;
  Context.Rate:= 136;
  Context.Suffix:= $1F;
End;

A fórmula em SHA3Init dá 136, 104 e 72 bytes de rate para SHA3-256, SHA3-384 e SHA3-512, pelo que rate e nível de segurança estão soldados na família de comprimento fixo. SHAKE256Init quebra deliberadamente essa ligação: OutputLength passa a ser um pedido de bytes, não uma declaração de força de segurança, e a capacidade da esponja fica em 512 bits independentemente de quanto saída se tirar. Esse desacoplamento é a definição de uma função de saída extensível, e escrevê-lo como dois inicializadores sobre um contexto torna a distinção visível no código em vez de enterrada num comentário

O teto de 136 bytes no ponto de entrada one-shot

As funções de conveniência na PDFlibPas recusam qualquer coisa fora de 1..136 bytes, e esse limite é honesto em vez de arbitrário. SHAKE256StreamRange e SHAKE256ByteArray passam ambas por SHA3Final, que faz o padding do último bloco, permuta uma vez e lê a saída diretamente do estado. Apenas os primeiros Rate bytes desse estado são saída legal, pelo que um squeeze produz no máximo 136 bytes; para lá disso precisa de uma permutação nova por bloco. Os dois consumidores reais cabem na mesma na janela: o perfil de assinatura PDF digere 32 bytes, e o Ed448 precisa de 114 segundo a RFC 8032 §5.2. SHAKE256StreamRange recebe o mesmo trio FirstLength, SecondStart, SecondLength que SHA256StreamRange e SHA3_256StreamRange, pelo que o código de validação percorre um /ByteRange de assinatura da mesma forma para todos os algoritmos e nunca faz caso especial do XOF

O que corre mal no pad10*1 quando a posição aterra em rate-1?

Nada, desde que faça XOR de ambos os bytes de pad no estado em vez de os atribuir. A FIPS 202 §B.2 coloca o suffix de domínio na posição atual de absorb e $80 no último byte do bloco. Quando a mensagem deixa por acaso a posição em Rate- 1, essas duas escritas apontam para o mesmo byte e têm de combinar em $9F. Armazenar em vez de fazer XOR e um deles ganha, o padding fica silenciosamente errado para exatamente um comprimento de mensagem em 136, e todos os vetores de regressão que tem passam porque nenhum aterra lá. O PLShakeXOFFinalize na PDFlibPas faz XOR de ambos, o que faz o caso de sobreposição sair do caso geral sem nenhum branch

O caso de sobreposição do pad10*1 na PDFlibPas: o PLShakeXOFFinalize coloca o suffix de domínio na posição atual de absorb e $80 no último byte do bloco; quando a posição aterra em Rate menos 1 ambas as escritas apontam para um byte, o XOR combina-os em $9F e um store simples corromperia um comprimento de mensagem em 136
Armazenar em vez de fazer XOR deixa um byte de pad ganhar, e como o defeito aparece para exatamente um comprimento de mensagem em 136, todos os vetores de regressão que tem podem passar enquanto o padding está silenciosamente errado
// Faz o padding do último bloco parcial e entra na fase de squeeze
Procedure PLShakeXOFFinalize(Var Ctx: TPLShakeXOF);
Begin
  If Not Ctx.Absorbing Then
    Exit;
  Ctx.Absorbing:= False;
  // suffix em SqueezePos, $80 em Rate-1; quando SqueezePos = Rate-1 os dois
  // XORs caem no mesmo byte e combinam corretamente em $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: absorver diretamente na esponja

A PDFlibPas expõe saída sem limite através de um record separado, TPLShakeXOF, que guarda apenas State como 25 palavras UInt64, Rate, Suffix, um flag Absorbing e SqueezePos. Não há buffer de mensagem: PLShakeXOFAbsorb faz XOR dos bytes de entrada diretamente no estado e permuta sempre que enche um bloco, com SqueezePos a servir de deslocamento nas duas fases. PLShakeXOFInit escolhe a variante com um boolean, True para SHAKE128 a rate 168 e False para SHAKE256 a rate 136, e PLShakeXOFSqueeze continua a permutar até o chamador ter a contagem de bytes que pediu. A saída entre blocos foi verificada em 200 bytes, para lá da primeira fronteira de bloco nas duas variantes

SHAKE em streaming na PDFlibPas através do TPLShakeXOF, um record com State, Rate, Suffix, um flag Absorbing e SqueezePos mas sem buffer de mensagem: o PLShakeXOFInit escolhe SHAKE128 a rate 168 ou SHAKE256 a rate 136, o PLShakeXOFAbsorb faz XOR dos bytes diretamente na esponja, o PLShakeXOFFinalize faz o padding e permuta, e o PLShakeXOFSqueeze continua a permutar até o chamador ter todos os bytes
Absorver em várias chamadas sem concatenar primeiro é o que deixa o Ed448 tirar os seus 114 bytes e o port ML-DSA fazer squeeze indefinidamente, uma cobertura que nenhum ponto de entrada one-shot com o seu teto de 136 bytes podia suportar
Var
  Ctx: TPLShakeXOF;
  Digest: Array [0.. 113] Of Byte;   // O Ed448 quer 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;

// Ou, quando um buffer de entrada e um de saída é tudo o que precisa:
//   PLShakeXOF(False, Input, Output, 114)

Absorver em várias chamadas importa mais do que parece. O Ed448 prefixa a mensagem com uma string dom4 e um valor de 57 bytes antes de fazer hash, e um rejection sampler quer alimentar uma seed e bytes de índice sem concatenar strings primeiro. O record de streaming chegar antes de qualquer consumidor era o ponto: o trabalho de curvas Ed448 e Brainpool em Pascal puro precisa de 114 bytes de uma vez, e o port ML-DSA FIPS 204 faz squeeze indefinidamente das duas variantes. Nenhum podia ter sido escrito contra a API one-shot

Porque é que a PDFlibPas se recusa a expor SHAKE256 como seletor de digest?

Porque não há forma legal de o escolher. TPDFlibDigestAlgorithm em PDFlibDigitalSign corre daAuto, daSHA1, daSHA256, daSHA384, daSHA512, daSHA3_256, daSHA3_384, daSHA3_512, e para. Não há daSHAKE256, e a omissão é uma decisão, não um descuido. A RFC 8702 regista o SHAKE em CMS, mas dentro de uma assinatura PDF o único sítio onde a ISO/TS 32002 coloca SHAKE256 é como digest interno do Ed448, onde o algoritmo de assinatura o escolhe e o chamador não. Publicá-lo como um valor DigestAlgorithm autónomo deixaria qualquer um emparelhar SHAKE256 com RSA ou ECDSA e produzir um dicionário de assinatura que nenhum validador aceita. A capacidade vem, o seletor não, e o código do lado da verificação chama SHAKE256StreamRange sobre o layout /ByteRange diretamente. Se está a escolher um digest para assinaturas que uma contraparte tem de validar hoje, daSHA256 até daSHA3_512 dentro de um workflow PAdES B-B a B-LTA com timestamping e dados de validação de longo prazo continua a ser a resposta; a ISO/TS 32001 é o que governa quando o SHA-3 se torna rotina no PDF de todo

Testar um XOF: o vetor que parece que passa

Compare o comprimento completo da saída e use mais do que um vetor. O resultado SHAKE256 da string vazia é o que toda a gente memoriza, e é um teste fraco precisamente por ser tão citado. A saída SHAKE256 de 32 bytes para abc é pior: os seus primeiros 16 bytes, 483366601360a8771c6863080cc4114d, coincidem com vários vetores vizinhos, pelo que uma comparação truncada reporta sucesso enquanto a metade discriminante, 8db44530f8f1e1ee4f94ea37e78b5739, fica por verificar. A regressão da PDFlibPas compara os dois vetores em todo o seu comprimento. Acrescente também um caso entre blocos, porque um loop de squeeze que nunca cruza uma fronteira de rate não foi testado de todo

A lição mais ampla de dobrar o SHAKE numa implementação SHA-3 existente é que o código partilhado nunca foi o risco. O Keccak-f[1600] é a mesma permutação para as duas famílias, e ou corresponde aos vetores ou não. O risco vive nos dois bytes e num inteiro que diferem, e no teto que tem de afirmar honestamente em vez de deixar os chamadores descobrirem. Para ver como a camada de digest se encaixa ao lado das ferramentas de assinatura, encriptação e PDF/A num codebase Object Pascal nativo, a página do produto PDF Library para Delphi lista o conjunto completo de componentes e a matriz de compiladores suportados