PDFlibPas firma e verifica con Ed448 e con le tre curve ECDSA Brainpool in Object Pascal puro. Nessuna libreria crittografica esterna, nessun provider di piattaforma, nessuna DLL: PDFlibEd448 implementa PureEdDSA RFC 8032 su edwards448, mentre PDFlibBrainpool implementa brainpoolP256r1, brainpoolP384r1 e brainpoolP512r1 RFC 5639. Entrambe le unità sono state realizzate allo stesso modo, contro vettori known-answer generati in modo indipendente prima di scrivere una sola riga di Pascal, e meritano di essere raccontate soprattutto per i bug
L'aritmetica dei campi è un codice insolitamente onesto. O corrisponde ai vettori pubblicati byte per byte oppure no, quindi non c'è spazio per un "funziona quasi". Ciò che rende le cose difficili è che un'implementazione sbagliata produce comunque firme, verifica comunque le proprie firme e appare comunque del tutto plausibile
Perché queste curve e perché in Pascal
Le curve Brainpool compaiono nei profili europei di firma qualificata, quindi una libreria che firma documenti per quel mercato non può trattarle come una curiosità esotica. Ed448 fa parte dell'insieme di algoritmi che ISO/TS 32002 porta nel PDF, dove il relativo digest interno è SHAKE256 anziché SHA-2. Nessuna delle due famiglie è disponibile nelle librerie crittografiche Pascal di uso comune, quindi una libreria PDF che le voglia deve implementarsele da sé
L'argomento legato al deployment è lo stesso valido per tutta la crittografia di questa libreria: un'applicazione che distribuisce un unico binario senza dipendenze crittografiche non ha provider da rilevare, versioni da far corrispondere né comportamenti che cambiano quando l'host riceve patch. La firma è proprio l'ambito in cui una dipendenza mobile è l'ultima cosa desiderabile
Le costanti provengono dal testo della specifica, mai dalla memoria
Il primo tentativo per il punto base di edwards448 fu scritto a memoria ed era sbagliato. Non è un errore straordinario, ma è costosissimo, perché un punto base errato produce un sistema autoconsistente: generazione delle chiavi, firma e verifica concordano tra loro e discordano dal resto del mondo
La procedura che funziona consiste nel prendere ogni parametro di dominio dal testo della specifica e verificarlo poi in modo incrociato. Per edwards448 significa prendere da RFC 8032 il numero primo, la costante della curva, l'ordine del gruppo ed entrambe le coordinate decimali del punto base, convertirli nella rappresentazione interna a limb e confrontarli quindi con i vettori di test pubblicati nello stesso documento. Per le curve Brainpool significa i parametri di RFC 5639, un'implementazione indipendente scritta per generare vettori e una verifica incrociata rispetto a una libreria di sistema in entrambe le direzioni, prima di eseguire qualsiasi codice Pascal
Una scorciatoia di derivazione merita un avvertimento perché sembra universale e non lo è: recuperare il punto base da un valore y fisso funziona per la curva 25519 e non funziona per edwards448, dove quel valore non ha radice quadrata. Uno script l'ha smentita in pochi secondi, il che costa molto meno che scoprirlo con un debugger
Il metodo: un mirror a livello di limb prima di qualsiasi Pascal
La tecnica che ha reso gestibili entrambe le unità è un'implementazione mirror in un linguaggio con interi senza limiti, costruita bottom-up. Prima il solo strato aritmetico: moltiplicazione di campo, sottrazione e propagazione del carry, sottoposti a stress test rispetto ai relativi invarianti algebrici su qualche centinaio di casi casuali. Poi la generazione completa delle chiavi dentro il mirror, che è dove vivono i bug semantici e dove trovarli costa poco. Solo allora la trascrizione in Pascal
Il vantaggio è diagnostico più che di sviluppo. Una volta noto come corretto, il mirror rende ogni disaccordo tra mirror e Pascal una svista di trascrizione, e esplorare lo stesso valore intermedio in entrambe le implementazioni lo localizza immediatamente. Questo trasforma una classe di bug che altrimenti è quasi impossibile da scovare in debug — un singolo limb sbagliato in fondo a una moltiplicazione scalare — in un confronto di cinque minuti
Quattro cause profonde in Ed448
Tutte e quattro sono state trovate esplorando i valori intermedi, e tutte e quattro sono del tipo che produce output dall'aspetto valido
La prima è una trappola di notazione. La maggior parte delle formule pubblicate per l'addizione di Edwards unificata assume una costante di curva pari a meno uno, mentre edwards448 ha più uno. Trascritta senza modifiche, il numeratore della coordinata y viene scritto come somma dove dovrebbe essere una differenza. La correzione non consiste nell'aggiustare il segno, ma nel ridedurre la forma di prodotto senza inversione dalla legge di addizione affine per la curva corretta, il che produce le quattro espressioni delle coordinate e non lascia spazio a un segno ereditato dalla fonte sbagliata
La seconda riguarda la decompressione dei punti. Recuperare la x affine dalle coordinate proiettive richiede una moltiplicazione per l'inverso di Z. Moltiplicando per l'inverso al quadrato si ottiene un valore che resta una rappresentazione proiettiva valida ed è la coordinata affine sbagliata, quindi il sintomo è una y corretta con una x sbagliata. Ogni volta che una coordinata è giusta e l'altra no, il bug sta nella normalizzazione, non nell'aritmetica
La terza è un'abitudine importata dalla curva più corta. Sia lo scalare per firma sia lo scalare di challenge devono essere ridotti dal digest completo, che per Ed448 è di 114 byte, non dai suoi primi 57. Anche la curva a 32 byte usa l'intero digest a 64 byte, quindi la regola è coerente; è solo l'assunzione che "metà del digest sia la larghezza dello scalare" a essere sbagliata
La quarta è l'ordinamento. Il prefisso di separazione del dominio viene per primo, prima del prefisso di contesto e del messaggio, che non è l'ordine suggerito dalla lettura intuitiva di R e A nella specifica. Sbagliare questo produce firme che verificano solo contro la propria implementazione e contro nient'altro, che è il fallimento più ingannevole possibile
// Progettazione del carry di campo: propagazione con pura semantica
// floor, così limb positivi e negativi funzionano entrambi e la
// sottrazione non richiede bias. Il carry superiore ripiega attraverso
// 2^448 = 2^224 + 1 (mod p), che tocca il limb 0 e il limb 8.
// Limitato a quattro passate; due osservate in pratica
procedure FeCarry(var A: TFe448);
var
I, Round: Integer;
Carry: Int64;
begin
for Round := 1 to 4 do
begin
Carry := 0;
for I := 0 to 15 do
begin
A[I] := A[I] + Carry;
Carry := Floor28(A[I]); // floor, non troncamento
A[I] := A[I] - (Carry shl 28);
end;
if Carry = 0 then
Break;
A[0] := A[0] + Carry; // 2^448 == 1
A[8] := A[8] + Carry; // 2^448 == 2^224
end;
end;
Una versione precedente di quella routine applicava un bias prima di propagare, e con input di grandi dimensioni riversava nelle limb basse un carry spurio di entità errata. Gli schemi di carry basati sul bias sono una fonte persistente di questa classe di difetti; la semantica floor con un ciclo repeat limitato è più facile da ragionare e misurabilmente abbastanza veloce
Due cause profonde in Brainpool
La prima non è affatto crittografia. La rappresentazione funzionante è di 33 limb, quindi il prodotto di due valori ne richiede 66, e l'array dei prodotti era dichiarato con 64. Scrivere oltre la fine corrompeva la memoria adiacente, il che si presentava dapprima come risultati sbagliati ed è diventato un crash solo dopo l'aggiunta di una scansione più ampia. La regola che ne è venuta fuori vale per ogni buffer numerico a dimensione fissa: dimensionarlo dalla larghezza del prodotto nel caso peggiore e aggiungere margine, poi non pensarci più. L'array nel codice distribuito è di 68 limb
La seconda è una forma di elevamento a potenza confusa. Esistono due forme corrette di square-and-multiply e consumano l'esponente in direzioni opposte: la forma right-to-left moltiplica e poi eleva al quadro la base e deve leggere i bit dal lato meno significativo, mentre la forma left-to-right eleva al quadro e poi moltiplica e legge dal lato più significativo. Il ciclo di inversione modulare aveva un corpo right-to-left con una scansione dei bit dal più significativo. Entrambe le metà sono da manuale, la combinazione no, e il risultato è un inverso sbagliato che sembra comunque un elemento di campo plausibile
// Raddoppio e addizione jacobiana quando il record di destinazione
// può essere la stessa variabile di una sorgente. Una copia dell'intero
// record all'ingresso è l'unico presidio affidabile: scrivere i limb
// di R inquina le letture successive di P
procedure BPPointDouble(var R: TBPPoint; const P: TBPPoint;
const Curve: TBPCurve);
var
Pin: TBPPoint;
begin
Pin := P; // prima copiare, poi calcolare solo da Pin
// ... M = 3X^2 + A*Z^4, S = 4*X*Y^2, X3 = M^2 - 2S, ...
end;
Due lezioni di processo che costano più dei bug
La correzione incrementale al volo non converge su una unità crittografica. Una bozza fu patchata ripetutamente finché non accumulò 32 routine duplicate e una struttura danneggiata, e fu risolta solo riscrivendola. Lo schema da adottare è scriverla una volta sola partendo da un mirror validato oppure riscriverla; una sequenza di correzioni locali a un'aritmetica che non si comprende ancora si accumula più velocemente di quanto corregga
E controllare il timestamp dell'eseguibile prima di credere a un risultato di test. Una build incrementale che compila ma non ricollega esegue il binario precedente, il che ha generato un intero giro di piste false su probe mancanti e output duplicato. Quando si fa debug di crittografia, un risultato inspiegabile dovrebbe suggerire la domanda "è il binario che ho appena compilato" prima di "l'algoritmo è sbagliato"
Prestazioni, ambito e come richiamarla
La riduzione modulare nell'unità Brainpool è uno shift-subtract bit-serial dal bit più alto impostato del prodotto, quindi una moltiplicazione costa all'incirca quanto la larghezza in bit. Una verifica P-256 si colloca nelle prime centinaia di millisecondi, il che è normale per firmare o verificare documenti e sarebbe inadeguato per un terminatore TLS. La riduzione di Barrett è l'aggiornamento ovvio e richiede un valore di lavoro più ampio di quello trasportato dall'attuale rappresentazione, quindi è una modifica da fare quando un carico di lavoro la richiede, non preventivamente
uses
PDFlibEd448, PDFlibBrainpool;
var
PublicKey, Signature: AnsiString;
Curve: TBPCurve;
R, S, PubX, PubY: TBPValue;
begin
// Ed448: PureEdDSA, SHAKE256 interno, chiavi a 57 byte
if Ed448PublicKeyFromSeed(Seed, PublicKey) and
Ed448Sign(DocumentDigest, Seed, Signature) then
Assert(Ed448Verify(DocumentDigest, PublicKey, Signature));
// Brainpool: il chiamante fornisce il nonce per firma, quindi la
// politica dei nonce resta all'applicazione
Curve := BPLoadCurve(bpP256r1);
if BPKeyGen(PubX, PubY, PrivateD, Curve) and
BPSignFixedK(R, S, Hash, PrivateD, Nonce, Curve) then
Assert(BPVerify(R, S, Hash, PubX, PubY, Curve));
end;
Si noti che il punto di ingresso di firma Brainpool riceve il nonce invece di generarlo. È deliberato: la generazione del nonce è la singola cosa più catastrofica da sbagliare in ECDSA, poiché un valore ripetuto o prevedibile rivela la chiave privata, e la decisione su da dove venga la casualità appartiene all'applicazione e al suo regime di conformità, non a una libreria PDF
Queste curve affiancano il lavoro post-quantum descritto nell'articolo su FIPS 204 ML-DSA, e si innestano nella stessa pipeline di firma e validazione trattata in firma e validazione PAdES. Per i certificati di test su queste curve, la via della generazione locale è descritta in certificati autofirmati con CryptoAPI. L'intera matrice degli algoritmi è elencata nella pagina di prodotto di losLab PDF Developer Library