O PDFlibPas implementa ML-DSA, o Module-Lattice-Based Digital Signature Algorithm padronizado no FIPS 204, inteiramente em Object Pascal. Os três conjuntos de parâmetros chegam como funções ordinárias: 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 sponge SHAKE da biblioteca, e sua saída coincide byte a byte com os vetores de teste known-answer oficiais do FIPS 204
Essa última frase é a única parte que exigiu trabalho de verdade. Escrever aritmética de reticulados em Pascal é mecânico; fazer isso concordar com o NIST não é. O que se segue é o relato de engenharia do port: como os três conjuntos de parâmetros acabaram compartilhando um motor, e os defeitos específicos que separaram compila e roda de coincide com o KAT. Se você está avaliando 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 de aparência plausível que falha silenciosamente na interoperabilidade
Por que escrever um assinador pós-quântico em Object Pascal puro?
Porque a alternativa é uma dependência nativa por alvo, e uma biblioteca PDF em Delphi já tem bastante disso. O PDFlibPas compila em Delphi, C++Builder e FPC/Lazarus nos alvos Win32, Win64 e Unix; vincular uma biblioteca pós-quântica em C significaria acompanhar uma build dela para cada um desses slots, mais a superfície de convenção de chamada e posse de memória entre eles. Uma unidade Pascal pura compila onde quer que o resto da biblioteca compile, e esse é o argumento inteiro
O ML-DSA torna isso incomumente barato, porque sua única dependência primitiva é a 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 ao port: TPLShakeXOF em PDFlibDigest, em que PLShakeXOFInit seleciona SHAKE128 (rate 168) ou SHAKE256 (rate 136), seguido de PLShakeXOFAbsorb, PLShakeXOFFinalize e um loop PLShakeXOFSqueeze que continua permutando por um comprimento de saída arbitrário. Toda rotina de rejection-sampling na unidade ML-DSA é escrita diretamente contra essa API de quatro chamadas
Um motor, três conjuntos de parâmetros: TMLDSAParams
O PDFlibPas descreve um conjunto inteiro de parâmetros ML-DSA com um único record e o seleciona pelo número do conjunto, de modo que ML-DSA-44, 65 e 87 passam pelos mesmos caminhos de código. A primeira implementação funcional era uma build fixa 4x4 hard-wired para o 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 viraram 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;
// 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 do 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á errada em uma unidade na produção, e dois dos defeitos reais deste port tinham essa forma. Os tamanhos declarados permanecem como constantes nomeadas para validação: 1312 / 2560 / 2420 bytes de chave pública, chave secreta e assinatura para o ML-DSA-44, 1952 / 4032 / 3309 para o ML-DSA-65, 2592 / 4896 / 4627 para o ML-DSA-87
Onde um port de ML-DSA do zero erra primeiro?
Em expand_a, Algoritmo 32 do FIPS 204, e o modo de falha é enganosamente bonito. A matriz A é amostrada semeando a SHAKE128 com rho seguido de dois bytes de índice, de modo 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 deste port escreveu-os em Msg[34] e Msg[35], deslocado em exatamente um byte, e o resultado foi um par de chaves cujo rho coincidia perfeitamente com o vetor de teste enquanto cada coeficiente de t estava errado. Só a matriz foi poluída, e a matriz é a única coisa que a chave pública não carrega verbatim
Mais dois defeitos viviam na mesma rotina. O comprimento do absorb tem de ser 34, não 35; um byte extra de lixo muda todo o stream squeezed. E o loop interno de rejeição precisa consumir cada grupo 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 cross-check que parava no offset 162 descartava 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); // índice de coluna primeiro
Msg[34]:= AnsiChar(I); // depois o índice de linha
PLShakeXOFInit(Ctx, True); // SHAKE128, rate 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 trincas 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 digests SHA-256 das chaves públicas e secretas ML-DSA-44 completas coincidiram com os vetores known-answer do FIPS 204. Uma lição de depuração também vale ser nomeada, porque custou uma sessão: quando você constrói um cross-check em Python para um loop de rejeição dirigido por XOF, hashlib.shake_128().digest(n) devolve o mesmo prefixo em cada chamada em vez de continuar o stream. Pegue o comprimento inteiro de uma vez, depois fatie-o em blocos do tamanho do rate, ou sua referência irá reconsumir com prazer os mesmos valores que seu Pascal rejeitou corretamente
Amostragem de eta: por que o ML-DSA-65 precisa de seu próprio ramo
O PDFlibPas mantém dois caminhos separados em expand_s porque o Algoritmo 33 do FIPS 204 de fato define dois. Para eta = 2, cada nibble é rejeitado quando chega a 15 e, caso contrário, reduzido mod 5. Para eta = 4, o nibble é rejeitado em 9 ou acima e então usado diretamente, sem nenhuma redução modular. O ML-DSA-65 é o único conjunto distribuído com eta = 4, e reutilizar o caminho mod 5 para ele desalinha s1 e s2 desde o primeiro coeficiente, produzindo um par de chaves que é internamente consistente, verifica contra si mesmo e não coincide com nada que qualquer outra pessoa produza
Procedure StoreNibble(Nibble: Byte);
Var
M: Integer;
Centered: Cardinal;
Begin
If Cnt>= N Then
Exit;
If Eta= 4 Then
Begin
If Nibble>= 9 Then // rejeite, depois pegue o nibble como está
Exit;
M:= Nibble;
End
Else
Begin
If Nibble>= 15 Then // eta = 2: rejeite 15, depois reduza 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 do c-tilde e largura de bits do gamma1
Dois parâmetros de codificação variam com o nível de segurança de formas fáceis de perder de vista quando uma build ML-DSA-44 funcional está bem ali. 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 record 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 a calcula como BitWidth(Gamma1), não como o expoente: gamma1 = 2^19 para ML-DSA-65 e 87 precisa de 20 bits por coeficiente, não 19, e esse bit único decide se cada polinômio z ocupa 640 bytes ou algo que nenhum verifier parseará. O verifier carregava um defeito correspondente durante o port, em que o buffer de desserialização de z tinha 192 bytes em vez de 576. O comprimento da assinatura é o teste de regressão mais barato que você escreverá: verifique 2420, 3309 e 4627 contra Length(Signature) e a maioria dos erros de parametrização se anuncia antes de você chegar a uma única asserção criptográfica
Assinar sem um loop sem limite
A assinatura ML-DSA é baseada em rejeição, então ela tenta de novo com um kappa incrementado até uma assinatura candidata passar nas verificações de norma e hint. O PDFlibPas limita isso com um orçamento externo explícito de 65535 tentativas; ao esgotá-lo, 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 de omega de 80, de modo que o orçamento é um trilho de segurança, não um limite de operação
O bug que fez esse trilho parecer necessário não era numérico de forma alguma. A assinatura parecia travar, a suspeita recaiu sobre decompose e make_hint (Algoritmos 36 e 39 do FIPS 204), e a causa real era um alvo de acumulação invertido: o vetor que alimenta o cálculo do hint deve acumular c*t0, enquanto o c*t0 original precisa sobreviver intacto para a verificação de norma. Aponte ambos para o mesmo buffer e o loop rejeita para sempre com aritmética perfeitamente correta. Tanto no caminho de sucesso quanto 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 por quem chama permanecem sob responsabilidade de quem chama, que é a divisão certa para uma biblioteca que não pode saber de onde vieram essas strings
Onde o ML-DSA encontra hoje a pilha de assinaturas PDF?
Seja preciso quanto ao que existe. O PDFlibPas distribui ML-DSA como primitivas de assinatura verificadas mais um vínculo de mecanismo PKCS #11, não como um substituto direto do seu output PAdES atual. O caminho de token é TPDFlibPKCS11Client.SignMLDSA, e ele é deliberadamente um ponto de entrada separado porque CKM_ML_DSA consome a mensagem bruta em vez de um digest pré-calculado, de modo que os callbacks existentes SignHash e de digest externo não podem ser reutilizados. A descoberta sem certificado exige que CertificateOptional seja ativado explicitamente junto com um label ou ID de chave privada, e o cliente valida CKA_PARAMETER_SET contra a whitelist CKP_ML_DSA_44 / 65 / 87 no momento da conexão, de modo que o pareamento padrão de certificados RSA e ECDSA nunca seja afrouxado por acidente
A integração no nível de documento é a parte ainda governada por trabalho de padronização, não por código de biblioteca. O ISO 32000-2 §12.8 define o dicionário de assinatura e seu payload CMS, e o ISO/TS 32002 é o veículo para estender esse suporte a algoritmos de hash e assinatura mais novos; até que seus validators e contrapartes acompanhem, a assinatura clássica permanece o caminho de produção. A postura prática é de trilhos paralelos: continue distribuindo assinaturas PAdES de B-B a B-LTA com timestamping e dados de validação de longo prazo para tudo que uma terceira parte precisa validar hoje, enquanto prova o manuseio de chaves ML-DSA e a integração de tokens ao lado disso. Para experimentos locais, o mesmo fluxo de certificado autoassinado construído sobre CryptoAPI dá a você uma identidade de assinatura sem envolver uma CA pública
Teste uma mudança de conjunto de parâmetros como você testaria qualquer outra mudança de assinatura. Primeiro os tamanhos, depois os vetores oficiais, depois os casos negativos: um byte de assinatura adulterado, uma string de contexto destoante, uma chave truncada. O PDFlibPas cobre tudo isso em sua suíte DUnitX, e a mesma disciplina pertence ao seu próprio pipeline, idealmente junto com o workbench de conformidade e assinatura que loteia validação por 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. Ela chega como primitivas que você pode testar, um caminho de token que você pode ligar e uma trilha de padronização que você segue sem apostar a versão atual nisso. Para ver como a unidade ML-DSA se situa ao lado do resto das ferramentas de assinatura, criptografia e PDF/A em uma base de código Object Pascal nativa, a página de produto da PDFlibPas Delphi PDF library lista o conjunto completo de componentes e a matriz de compiladores suportados