Articolo tecnico

ML-DSA in Delphi: FIPS 204 post-quantum in PDFlibPas

PDFlibPas implementa ML-DSA, il Module-Lattice-Based Digital Signature Algorithm standardizzato in FIPS 204, interamente in Object Pascal. Tutti e tre i set di parametri arrivano come normali funzioni: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, più le voci KeyGen e Verify corrispondenti. Niente OpenSSL, niente DLL di piattaforma, niente colla in C. L'unica unità PDFlibMLDSA non dipende da nulla se non dalla spugna SHAKE della libreria, e il suo output coincide con i vettori di test known-answer ufficiali di FIPS 204 byte per byte

Quell'ultima frase è l'unica parte che ha richiesto lavoro vero. Scrivere aritmetica di reticoli in Pascal è meccanico; farla coincidere con NIST non lo è. Quella che segue è la cronaca ingegneristica del port: come i tre set di parametri sono finiti a condividere un unico motore, e i difetti specifici che separavano compila e gira da coincide con il KAT. Se state valutando opzioni post-quantum per una pipeline documentale Delphi o C++Builder, i difetti sono la parte utile, perché ognuno di essi produce un output dall'aspetto plausibile che fallisce silenziosamente l'interoperabilità

Perché scrivere un firmatario post-quantum in Object Pascal puro?

Perché l'alternativa è una dipendenza nativa per ogni target, e una libreria PDF Delphi ne ha già abbastanza. PDFlibPas compila su Delphi, C++Builder e FPC/Lazarus per i target Win32, Win64 e Unix; legare una libreria post-quantum in C significherebbe mantenere una sua build per ciascuno di quei posti, più la superficie di convenzioni di chiamata e di proprietà della memoria tra i due. Un'unità Pascal pura compila ovunque compili il resto della libreria, e questo è tutto l'argomento

ML-DSA rende questa scelta insolitamente economica, perché la sua unica dipendenza primitiva è SHAKE. Niente layer di interi grandi, niente curve ellittiche, niente suite di hash separata. PDFlibPas ha acquisito un XOF in streaming nella release subito precedente al port: TPLShakeXOF in PDFlibDigest, dove PLShakeXOFInit seleziona SHAKE128 (rate 168) o SHAKE256 (rate 136), seguito da PLShakeXOFAbsorb, PLShakeXOFFinalize e un ciclo PLShakeXOFSqueeze che continua a permutare per una lunghezza di output arbitraria. Ogni routine di rejection sampling nell'unità ML-DSA è scritta direttamente contro quella API di quattro chiamate

Un motore, tre set di parametri: TMLDSAParams

PDFlibPas descrive un intero set di parametri ML-DSA con un solo record e lo seleziona per numero di set, così ML-DSA-44, 65 e 87 passano per gli stessi percorsi di codice. La prima implementazione funzionante era una build fissa 4x4 cablata su ML-DSA-44; generalizzarla è stato sollevare k e l, eta, tau, beta, gamma1 e gamma2, omega e la lunghezza del challenge dentro TMLDSAParams, poi derivare tutto il resto. I punti di ingresso pubblici sono diventati wrapper di tre righe

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;

// I campi derivati si calcolano, non si trascrivono da una tabella
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;

I cinque campi derivati vengono calcolati anziché copiati dalle tabelle FIPS 204 di proposito. Le larghezze di bit trascritte a mano sono esattamente la classe di costante che in revisione sembra giusta e in produzione è fuori di uno, e due dei difetti veri di questo port erano di quella forma. Le dimensioni dichiarate restano come costanti nominate per la validazione: 1312 / 2560 / 2420 byte di chiave pubblica, chiave segreta e firma per ML-DSA-44, 1952 / 4032 / 3309 per ML-DSA-65, 2592 / 4896 / 4627 per ML-DSA-87

PDFlibPas instrada MLDSA44Sign, MLDSA65Sign e MLDSA87Sign attraverso BuildMLDSAParams dentro un unico record TMLDSAParams i cui campi derivati si calcolano anziché trascriversi, così un solo motore condiviso MLDSASignInternal serve tutti e tre i set di parametri FIPS 204
Tre set di parametri condividono un motore perché il numero di set seleziona solo un record, e le larghezze di bit derivate si calcolano invece di trascriversi dalle tabelle FIPS 204

Dove sbaglia per primo un port ML-DSA da zero?

In expand_a, l'Algoritmo 32 di FIPS 204, e la modalità di guasto è fuorviante nel migliore dei modi. La matrice A si campiona seminando SHAKE128 con rho seguito da due byte di indice, quindi il buffer del seme è di 34 byte: rho(32), poi j, poi i. Scritti in Pascal con indicizzazione AnsiString a base 1 quei due byte sono Msg[33] e Msg[34]. La prima bozza di questo port li scriveva in Msg[34] e Msg[35], spostati di esattamente un byte, e il risultato era una coppia di chiavi il cui rho coincideva perfettamente col vettore di test mentre ogni coefficiente di t era sbagliato. Soltanto la matrice era inquinata, e la matrice è proprio la cosa che la chiave pubblica non porta alla lettera

Altri due difetti vivevano nella stessa routine. La lunghezza di absorb dev'essere 34, non 35; un byte spazzatura in più cambia l'intero stream estratto. E il ciclo interno di rifiuto deve consumare ogni gruppo di tre byte che il blocco può dare, incluso quello che inizia all'offset 165 di un blocco SHAKE128 da 168 byte, cioè 56 gruppi per blocco. Uno script di controllo incrociato che si fermava all'offset 162 buttava la coda di ogni blocco e spostava il prefisso t1 campionato da circa il tredicesimo byte in poi

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // prima l'indice di colonna
Msg[34]:= AnsiChar(I);          // poi l'indice di riga
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 tiene il gruppo a offset 165: 56 triple per blocco
  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;

Con quei tre corretti, i digest SHA-256 delle chiavi pubblica e segreta ML-DSA-44 complete coincidevano con i vettori known-answer di FIPS 204. Una lezione di debugging merita di essere nominata anch'essa, perché è costata una sessione: quando costruisci un controllo incrociato Python per un ciclo di rifiuto guidato da XOF, hashlib.shake_128().digest(n) restituisce lo stesso prefisso a ogni chiamata invece di proseguire lo stream. Prendi tutta la lunghezza una volta, poi tagliala in blocchi di dimensione rate, o il tuo riferimento consumerà di nuovo con piacere proprio i valori che il tuo Pascal ha correttamente rifiutato

La routine expand_a ML-DSA di PDFlibPas semina SHAKE128 con un buffer da 34 byte che contiene rho, l'indice di colonna e l'indice di riga, accanto a una prima bozza che spostava entrambi i byte di indice e a un controllo incrociato che buttava l'ultimo gruppo di tre byte di ogni blocco
Due difetti off-by-one nella stessa routine: byte di indice scritti una posizione in ritardo, e un ciclo di rifiuto che si ferma prima del gruppo di tre byte a offset 165

Campionamento di eta: perché ML-DSA-65 ha bisogno del suo ramo

PDFlibPas tiene due percorsi separati in expand_s perché l'Algoritmo 33 di FIPS 204 ne definisce davvero due. Per eta = 2 ogni nibble viene rifiutato quando arriva a 15 e altrimenti ridotto mod 5. Per eta = 4 il nibble viene rifiutato a 9 o sopra e poi usato direttamente, senza alcuna riduzione modulare. ML-DSA-65 è l'unico set distribuito con eta = 4, e riutilizzarvi il percorso mod 5 disallinea s1 e s2 dal primissimo coefficiente, producendo una coppia di chiavi internamente coerente, che verifica contro se stessa, e che non coincide con nulla di ciò che producono gli altri

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // rifiuta, poi prendi il nibble com'è
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: rifiuta 15, poi riduci 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;

Le dimensioni sono il test: lunghezza di c-tilde e larghezza di bit di gamma1

Due parametri di codifica variano col livello di sicurezza in modi facili da perdere quando una build ML-DSA-44 funzionante è lì a portata di mano. L'hash di challenge c-tilde è 2 x lambda / 8 byte, cioè 32 per ML-DSA-44, 48 per ML-DSA-65 e 64 per ML-DSA-87. Lasciarlo fisso a 32 produce una firma ML-DSA-65 da 3293 byte invece dei 3309 standard, e il prefisso del KAT diverge subito. Il campo del record CTildaBytes esiste proprio perché quel numero non possa essere dimenticato

Il secondo è la larghezza di packing del polinomio maschera z. PDFlibPas la calcola come BitWidth(Gamma1), non come esponente: gamma1 = 2^19 per ML-DSA-65 e 87 richiede 20 bit per coefficiente, non 19, e quel singolo bit decide se ogni polinomio z occupa 640 byte o qualcosa che nessun verifier parserà. Il verifier portava con sé un difetto gemello durante il port, dove il buffer di deserializzazione di z era dimensionato 192 byte invece di 576. La lunghezza della firma è il test di regressione più economico che scriverai mai: asserisci 2420, 3309 e 4627 contro Length(Signature) e la maggior parte degli errori di parametrizzazione si annuncia prima che tu arrivi a una sola asserzione crittografica

PDFlibPas lega due parametri di codifica ML-DSA al livello di sicurezza: l'hash di challenge c-tilde cresce da 32 a 48 a 64 byte, e il polinomio maschera z viene compresso a BitWidth di gamma1 bit, con la lunghezza della firma come test di regressione
Due parametri variano col livello di sicurezza, e una firma che esce 3293 byte invece di 3309 annuncia l'errore prima che giri qualsiasi asserzione crittografica

Firmare senza un ciclo senza limiti

La firma ML-DSA è basata sul rifiuto, quindi riprova con un kappa incrementato finché una firma candidata passa i controlli di norma e di hint. PDFlibPas lo limita con un budget esterno esplicito di 65535 tentativi; all'esaurimento MLDSASignInternal restituisce False e lascia la firma vuota invece di girare dentro un thread di produzione documenti. In pratica il vettore ufficiale ML-DSA-44 riesce a kappa = 4 con 55 hint contro il tetto omega di 80, quindi il budget è una barriera di sicurezza più che un limite operativo

Il bug che ha reso quella barriera necessaria non era per niente numerico. La firma sembrava bloccarsi, il sospetto cadde su decompose e make_hint (Algoritmi 36 e 39 di FIPS 204), e la causa reale era un target di accumulazione invertito: il vettore che alimenta il calcolo degli hint deve accumulare c*t0, mentre il c*t0 originale deve sopravvivere intatto per il controllo di norma. Puntali entrambi allo stesso buffer e il ciclo rifiuta per sempre con aritmetica perfettamente corretta. Sia sul percorso di successo sia su quello di esaurimento del budget l'unità azzera i semi derivati, i polinomi segreti, le maschere, il challenge e i buffer di codifica; il seme, la chiave segreta e il rnd forniti dal chiamante restano responsabilità del chiamante, che è la ripartizione giusta per una libreria che non può sapere da dove vengano quelle stringhe

Dove incontra oggi ML-DSA lo stack di firme PDF?

Siate precisi su ciò che esiste. PDFlibPas distribuisce ML-DSA come primitive di firma verificate più un binding a meccanismo PKCS #11, non come sostituto drop-in del vostro attuale output PAdES. Il percorso token è TPDFlibPKCS11Client.SignMLDSA, ed è deliberatamente un punto di ingresso separato perché CKM_ML_DSA consuma il messaggio grezzo anziché un digest precalcolato, quindi i callback esistenti SignHash e a digest esterno non possono essere riusati. La discovery senza certificato richiede che CertificateOptional sia abilitato esplicitamente insieme a una label o a un ID di chiave privata, e il client valida CKA_PARAMETER_SET contro la whitelist CKP_ML_DSA_44 / 65 / 87 al momento della connessione, così l'accoppiamento predefinito di certificati RSA ed ECDSA non viene mai allentato per sbaglio

L'integrazione a livello documento è la parte ancora governata dal lavoro di standardizzazione più che dal codice della libreria. ISO 32000-2 §12.8 definisce il dizionario di firma e il suo payload CMS, e ISO/TS 32002 è il veicolo per estendere quel supporto ad algoritmi di hash e firma più recenti; finché i vostri validator e le controparti non seguono, la firma classica resta il percorso di produzione. L'assetto pratico è a binari paralleli: continuare a distribuire firme PAdES da B-B a B-LTA con timestamp e dati di validazione a lungo termine per tutto ciò che una terza parte deve validare oggi, mentre si matura di lato la gestione delle chiavi ML-DSA e l'integrazione dei token. Per esperimenti locali lo stesso flusso di certificati autofirmati costruito su CryptoAPI vi dà un'identità di firma senza coinvolgere una CA pubblica

Testate un cambio di set di parametri come testereste qualsiasi altra modifica alla firma. Prima le dimensioni, poi i vettori ufficiali, poi i casi negativi: un byte di firma manomesso, una stringa di contesto non corrispondente, una chiave troncata. PDFlibPas copre tutto questo nella sua suite DUnitX, e la stessa disciplina appartiene alla vostra pipeline, idealmente accanto al workbench di compliance e firma che raggruppa la validazione su un corpus di documenti così una regressione non arriva mai inosservata a un cliente

La prontezza post-quantum per il software documentale non arriverà come un singolo interruttore. Arriva come primitive che puoi testare, un percorso token che puoi cablare, e una pista di standard che segui senza scommettere su di essa la release corrente. Per vedere come l'unità ML-DSA si colloca accanto al resto degli strumenti di firma, cifratura e PDF/A in una codebase Object Pascal nativa, la pagina prodotto della PDFlibPas Delphi PDF library elenca il set completo di componenti e la matrice dei compilatori supportati