O PDFlibPas implementa o ML-DSA, o Module-Lattice-Based Digital Signature Algorithm normalizado na FIPS 204, inteiramente em Object Pascal. Os três conjuntos de parâmetros chegam como funções comuns: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, mais as entradas KeyGen e Verify correspondentes. Sem OpenSSL, sem DLL de plataforma, sem cola em C. A única unidade PDFlibMLDSA não depende de nada além da esponja SHAKE da biblioteca, e a sua saída coincide byte a byte com os vetores de teste known-answer oficiais da FIPS 204
Essa última frase é a única parte que exigiu trabalho a sério. Escrever aritmética de reticulados em Pascal é mecânico; fazê-la concordar com o NIST não é. O que se segue é o relato de engenharia da portabilidade: como os três conjuntos de parâmetros acabaram a partilhar um único motor, e os defeitos concretos que separavam compila e corre de coincide com o KAT. Se está a avaliar opções pós-quânticas para um pipeline de documentos em Delphi ou C++Builder, os defeitos são a parte útil, porque cada um deles produz saída com aparência plausível que falha silenciosamente a interoperabilidade
Porque escrever um assinador pós-quântico em Object Pascal puro?
Porque a alternativa é uma dependência nativa por cada alvo, e uma biblioteca PDF em Delphi já tem muitas delas. O PDFlibPas compila em Delphi, C++Builder e FPC/Lazarus nos alvos Win32, Win64 e Unix; ligar uma biblioteca pós-quântica em C significaria acompanhar uma compilação dela para cada uma dessas combinações, mais a superfície de convenções de chamada e de propriedade de memória entre ambos. Uma unidade Pascal pura compila onde quer que o resto da biblioteca compile, e esse é todo o argumento
O ML-DSA torna isto invulgarmente barato, porque a sua única dependência primitiva é o SHAKE. Não há camada de inteiros grandes, nem curva elíptica, nem suíte de hash separada. O PDFlibPas ganhou um XOF de streaming na versão imediatamente anterior à portabilidade: TPLShakeXOF em PDFlibDigest, onde PLShakeXOFInit seleciona SHAKE128 (rate 168) ou SHAKE256 (rate 136), seguido de PLShakeXOFAbsorb, PLShakeXOFFinalize e um ciclo PLShakeXOFSqueeze que continua a permutar para um comprimento de saída arbitrário. Todas as rotinas de rejection-sampling na unidade ML-DSA estão escritas diretamente contra essa API de quatro chamadas
Um motor, três conjuntos de parâmetros: TMLDSAParams
O PDFlibPas descreve um conjunto completo de parâmetros ML-DSA com um único registo e seleciona-o pelo número do conjunto, pelo que o ML-DSA-44, 65 e 87 correm pelos mesmos caminhos de código. A primeira implementação funcional era uma compilação 4x4 fixa, ligada por fios ao ML-DSA-44; generalizá-la significou elevar k e l, eta, tau, beta, gamma1 e gamma2, omega e o comprimento do desafio para TMLDSAParams, e depois derivar todo o resto. Os pontos de entrada públicos tornaram-se wrappers de três linhas
Type
TMLDSAParams= Record
K, L, D, Eta, Tau, Beta, Gamma1, Gamma2, Omega: Integer;
Alpha, MW1: Cardinal;
W1BW, EtaBW, Gamma1BW, T1BW: Integer;
T0Rng: Cardinal;
CTildaBytes: Integer;
PublicKeyBytes, SecretKeyBytes, SignatureBytes: Integer;
End;
// Os campos derivados são calculados, nunca transcritos de uma tabela
Params.Alpha:= 2* Cardinal(Params.Gamma2);
Params.MW1:= (Q- 1)div Params.Alpha;
Params.W1BW:= BitWidth(Params.MW1- 1);
Params.EtaBW:= BitWidth(2* Cardinal(Params.Eta));
Params.Gamma1BW:= BitWidth(Cardinal(Params.Gamma1));
Function MLDSA65Sign(Const SecretKey, Message, Context, Rnd: AnsiString;
Out Signature: AnsiString): Boolean;
Var
Params: TMLDSAParams;
Begin
BuildMLDSAParams(65, Params);
Result:= MLDSASignInternal(Params, SecretKey, Message, Context, Rnd,
Signature);
End;
Os cinco campos derivados são calculados, e não copiados das tabelas da FIPS 204, de propósito. Larguras de bits transcritas à mão são exatamente a classe de constante que parece certa na revisão e está desviada por um na produção, e dois dos defeitos reais desta portabilidade tinham essa forma. Os tamanhos declarados ficam como constantes nomeadas para validação: 1312 / 2560 / 2420 bytes de chave pública, chave secreta e assinatura para ML-DSA-44, 1952 / 4032 / 3309 para ML-DSA-65, 2592 / 4896 / 4627 para ML-DSA-87
Onde é que uma portabilidade ML-DSA escrita de raiz falha primeiro?
Em expand_a, Algoritmo 32 da FIPS 204, e o modo de falha é enganadoramente belo. A matriz A é amostrada semeando o SHAKE128 com rho seguido de dois bytes de índice, pelo que o buffer de semente tem 34 bytes: rho(32), depois j, depois i. Escrito em Pascal com indexação AnsiString de base 1, esses dois bytes são Msg[33] e Msg[34]. O primeiro rascunho desta portabilidade escreveu-os em Msg[34] e Msg[35], desviados exatamente um byte, e o resultado foi um par de chaves cujo rho coincidia perfeitamente com o vetor de teste enquanto todos os coeficientes de t estavam errados. Apenas a matriz ficou poluída, e a matriz é precisamente aquilo que a chave pública não transporta verbatim
Mais dois defeitos viviam na mesma rotina. O comprimento de absorção tem de ser 34, não 35; um byte extra de lixo altera todo o stream comprimido por squeeze. E o ciclo interno de rejeição tem de consumir todos os grupos de três bytes que o bloco puder fornecer, incluindo o que começa no offset 165 de um bloco SHAKE128 de 168 bytes, o que dá 56 grupos por bloco. Um script de verificação cruzada que parava no offset 162 perdia a cauda de cada bloco e deslocava o prefixo t1 amostrado a partir de aproximadamente o décimo terceiro byte
SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J); // primeiro o índice da coluna
Msg[34]:= AnsiChar(I); // depois o índice da linha
PLShakeXOFInit(Ctx, True); // SHAKE128, taxa 168
PLShakeXOFAbsorb(Ctx, @Msg[1], 34);
PLShakeXOFFinalize(Ctx);
Cnt:= 0;
While Cnt< N Do
Begin
PLShakeXOFSqueeze(Ctx, @Buf[0], 168);
BOff:= 0;
// BOff+2 <= 167 mantém o grupo no offset 165: 56 trios por bloco
While (BOff+ 2<= High(Buf))And (Cnt< N) Do
Begin
T3:= ((Buf[BOff+ 2]and $7F)shl 16)xor (Buf[BOff+ 1]shl 8)xor Buf[BOff];
If T3< Q Then
Begin
Poly^[Cnt]:= T3;
Inc(Cnt);
End;
Inc(BOff, 3);
End;
End;
Com esses três corrigidos, os resumos SHA-256 das chaves pública e secreta ML-DSA-44 completas coincidiram com os vetores known-answer da FIPS 204. Uma lição de depuração também vale a pena nomear, porque custou uma sessão: quando constrói uma verificação cruzada em Python para um ciclo de rejeição movido por XOF, hashlib.shake_128().digest(n) devolve o mesmo prefixo em cada chamada em vez de continuar o stream. Obtenha o comprimento total uma vez, e depois corte-o em blocos do tamanho do rate, ou a sua referência reciclará com gosto os próprios valores que o seu Pascal corretamente rejeitou
Amostrar eta: porque o ML-DSA-65 precisa do seu próprio ramo
O PDFlibPas mantém dois caminhos separados em expand_s porque o Algoritmo 33 da FIPS 204 define genuinamente dois. Para eta = 2, cada nibble é rejeitado quando atinge 15 e, caso contrário, reduzido mod 5. Para eta = 4, o nibble é rejeitado a 9 ou acima e depois usado diretamente, sem redução modular alguma. O ML-DSA-65 é o único conjunto enviado com eta = 4, e reutilizar para ele o caminho mod 5 desalinha s1 e s2 desde o primeiro coeficiente, produzindo um par de chaves internamente consistente, que verifica contra si próprio, e que não coincide com nada do que outros produzem
Procedure StoreNibble(Nibble: Byte);
Var
M: Integer;
Centered: Cardinal;
Begin
If Cnt>= N Then
Exit;
If Eta= 4 Then
Begin
If Nibble>= 9 Then // rejeitar, depois tomar o nibble tal como está
Exit;
M:= Nibble;
End
Else
Begin
If Nibble>= 15 Then // eta = 2: rejeitar 15, depois reduzir mod 5
Exit;
M:= Nibble mod 5;
End;
If Eta>= M Then
Centered:= Eta- M
Else
Centered:= Q- (M- Eta);
Vec[I][Cnt]:= Centered;
Inc(Cnt);
End;
Os tamanhos são o teste: comprimento de c-tilde e largura de bits de gamma1
Dois parâmetros de codificação variam com o nível de segurança de formas fáceis de perder quando uma compilação ML-DSA-44 funcional está ali à mão. O hash de desafio c-tilde tem 2 x lambda / 8 bytes, ou seja, 32 para ML-DSA-44, 48 para ML-DSA-65 e 64 para ML-DSA-87. Deixá-lo fixo em 32 produz uma assinatura ML-DSA-65 de 3293 bytes em vez dos 3309 padrão, e o prefixo do KAT diverge imediatamente. O campo do registo CTildaBytes existe precisamente para que esse número não possa ser esquecido
O segundo é a largura de empacotamento do polinómio de máscara z. O PDFlibPas calcula-a como BitWidth(Gamma1), e não como o expoente: gamma1 = 2^19 para ML-DSA-65 e 87 precisa de 20 bits por coeficiente, e não 19, e esse único bit decide se cada polinómio z ocupa 640 bytes ou algo que nenhum verificador analise. O verificador carregava um defeito correspondente durante a portabilidade, em que o buffer de desserialização de z tinha o tamanho de 192 bytes em vez de 576. O comprimento da assinatura é o teste de regressão mais barato que alguma vez escreverá: verifique 2420, 3309 e 4627 contra Length(Signature) e a maioria dos erros de parametrização anuncia-se antes de chegar a uma única afirmação criptográfica
Assinar sem um ciclo sem limites
A assinatura ML-DSA é baseada em rejeição, por isso repete com um kappa incrementado até uma assinatura candidata passar as verificações de norma e de hint. O PDFlibPas limita isso com um orçamento externo explícito de 65535 tentativas; ao esgotar, MLDSASignInternal devolve False e deixa a assinatura vazia em vez de girar dentro de uma thread de produção de documentos. Na prática, o vetor oficial ML-DSA-44 tem sucesso em kappa = 4 com 55 hints contra o teto omega de 80, pelo que o orçamento é um trilho de segurança e não um limite de funcionamento
O erro que fez com que esse trilho parecesse necessário não era numérico de todo. A assinatura parecia pendurar-se, a suspeita caiu sobre decompose e make_hint (Algoritmos 36 e 39 da FIPS 204), e a causa real era um alvo de acumulação invertido: o vetor que alimenta o cálculo de hints deve acumular c*t0, enquanto o c*t0 original tem de sobreviver intocado para a verificação da norma. Aponte ambos para o mesmo buffer e o ciclo rejeita para sempre com aritmética perfeitamente correta. Tanto no caminho de sucesso como no de orçamento esgotado, a unidade zera sementes derivadas, polinómios secretos, máscaras, o desafio e os buffers de codificação; a semente, a chave secreta e o rnd fornecidos pelo chamador continuam a ser responsabilidade do chamador, que é a divisão certa para uma biblioteca que não pode saber de onde vieram essas strings
Onde é que o ML-DSA encontra hoje a pilha de assinaturas PDF?
Seja preciso quanto ao que existe. O PDFlibPas envia o ML-DSA como primitivas de assinatura verificadas mais uma ligação a mecanismo PKCS #11, não como substituto direto do seu resultado PAdES atual. O caminho do token é TPDFlibPKCS11Client.SignMLDSA, e é deliberadamente um ponto de entrada separado porque CKM_ML_DSA consome a mensagem em bruto em vez de um resumo pré-calculado, pelo que as callbacks existentes de SignHash e de resumo externo não podem ser reutilizadas. A descoberta sem certificado exige que CertificateOptional seja ativado explicitamente junto com uma etiqueta ou ID de chave privada, e o cliente valida CKA_PARAMETER_SET contra a lista branca CKP_ML_DSA_44 / 65 / 87 no momento da ligação, pelo que o emparelhamento padrão de certificados RSA e ECDSA nunca é afrouxado por acidente
A integração ao nível do documento é a parte ainda regida por trabalho de normalização e não por código da biblioteca. A ISO 32000-2 §12.8 define o dicionário de assinatura e o seu payload CMS, e a ISO/TS 32002 é o veículo para estender esse suporte a algoritmos de hash e de assinatura mais recentes; até que os seus validadores e contrapartes acompanhem, a assinatura clássica continua a ser o caminho de produção. A postura prática é de vias paralelas: continue a enviar assinaturas PAdES B-B a B-LTA com carimbo de tempo e dados de validação de longo prazo para tudo o que uma terceira parte tenha de validar hoje, enquanto prova o tratamento de chaves ML-DSA e a integração de tokens em paralelo. Para experiências locais, o mesmo fluxo de certificado autoassinado construído sobre a CryptoAPI dá-lhe uma identidade de assinatura sem envolver uma CA pública
Teste uma alteração de conjunto de parâmetros da mesma forma que testaria qualquer outra alteração de assinatura. Primeiro os tamanhos, depois os vetores oficiais, depois os casos negativos: um byte de assinatura adulterado, uma string de contexto trocada, uma chave truncada. O PDFlibPas cobre tudo isso na sua suíte DUnitX, e a mesma disciplina pertence ao seu próprio pipeline, idealmente ao lado da bancada de conformidade e assinatura que valida em lote um corpus de documentos para que uma regressão nunca chegue a um cliente sem ser notada
A prontidão pós-quântica para software de documentos não chegará como um único interruptor. Chega como primitivas que pode testar, um caminho de token que pode ligar, e um trilho de normalização que segue sem apostar a versão atual nele. Para ver como a unidade ML-DSA se situa ao lado do resto das ferramentas de assinatura, encriptação e PDF/A numa base de código Object Pascal nativa, a página do produto PDFlibPas Delphi PDF library lista o conjunto completo de componentes e a matriz de compiladores suportados