Articolo tecnico

Firma e cifratura PDF con SM2, SM3 e SM4 in Delphi

PDFlibPas, la libreria PDF per Delphi e C++Builder, implementa la suite di crittografia commerciale cinese in Object Pascal puro: hashing SM3 (GB/T 32905), cifratura a blocchi SM4 (GB/T 32907) e firme su curva ellittica SM2 (GB/T 32918). Con esse firma i PDF con firme CMS SM2-con-SM3 attraverso SetSignProcessSM2Identity e cifra i documenti con un crypt filter SM4 attraverso Encrypt con strength 6, senza provider di sistema o DLL esterne di mezzo

Il motivo usuale per volerlo è un requisito di gara, non la curiosità. Dai sistemi venduti a governo, finanza e sanità cinesi ci si aspetta sempre più spesso l'uso degli algoritmi nazionali, e Windows CryptoAPI e CNG non firmano con SM2 né fanno hashing con SM3. Se il percorso di firma passa per la piattaforma, la risposta è semplicemente no. Un'implementazione Pascal pura dà la stessa risposta su Win32, Win64 e sui target FPC, e la dà senza una dipendenza nativa mobile sotto la pipeline dei documenti

Perché costruire la suite GB/T in Object Pascal puro?

PDFlibPas costruisce SM2, SM3 e SM4 in Pascal perché nessun provider di piattaforma che già supporta sa fare il lavoro. CryptoAPI e CNG non hanno supporto alle firme SM3, quindi prima che esistesse l'identità SM2 la libreria si rifiutava deliberatamente di firmare quando il digest era impostato a SM3: l'alternativa era un SignerInfo che dichiara un OID di digest e porta una firma su tutt'altra cosa, cioè una firma sbagliata silenziosa invece di un errore visibile

I tre algoritmi hanno anche un ordine di costruzione naturale, e la libreria lo ha seguito. SM3 è arrivato primo, perché SM2 lo richiede per l'hash di identità ZA e per il message digest. SM4 è arrivato secondo come unità indipendente. SM2 è arrivato per ultimo, prima come primitive grezze verificate contro la curva di esempio pubblicata nell'appendice della specifica, poi come identità di firma collegata allo stesso builder CMS che gestisce già Ed25519, Ed448 e ML-DSA. Lo stesso approccio è descritto per le curve ECDSA Ed448 e Brainpool in Pascal puro e per il firmatario post-quantum ML-DSA di FIPS 204, e SM2 si innesta in quel framework come un tipo di chiave in più

Le primitive sono funzioni pubbliche, quindi potete usarle fuori dalla firma PDF. SM3String e SM3ByteArray in PDFlibDigest restituiscono il digest grezzo di 32 byte (un byte per carattere, non esadecimale), e PDFlibSM4 esporta helper ECB e CBC in due varianti: versioni allineate al blocco che restituiscono una stringa vuota su una lunghezza sbagliata, e versioni PKCS#7 come SM4EncryptCBCPKCS7 per dati di lunghezza arbitraria

uses
  SysUtils, PDFlibDigest, PDFlibSM4;

function RawToHex(const S: AnsiString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + IntToHex(Byte(S[I]), 2);
end;

var
  Digest, Key, IV, Cipher, Plain: AnsiString;
begin
  // esempio GB/T 32905: SM3("abc")
  Digest := SM3String('abc');
  Assert(LowerCase(RawToHex(Digest)) =
    '66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0');

  // SM4 richiede una chiave di 16 byte; CBC richiede un IV di 16 byte
  Key := 'SixteenByteKey!!';
  IV  := 'InitVector16byte';
  Cipher := SM4EncryptCBCPKCS7(Key, IV, 'Invoice 2026-0042 approved');
  Plain  := SM4DecryptCBCPKCS7(Key, IV, Cipher);
  Assert(Plain = 'Invoice 2026-0042 approved');
end;

Contate a mano quelle stringhe da 16 byte a vostro rischio. Due stringhe di test nella suite di test SM4 che dovevano essere 16 byte si sono rivelate 17, e poiché gli helper allineati al blocco restituiscono per progetto una stringa vuota su una lunghezza sbagliata, il fallimento è emerso come un assert sconcertante invece che come un'eccezione

Firmare un PDF con un'identità SM2

Un processo di firma PDFlibPas usa una chiave SM2 non appena SetSignProcessSM2Identity o SetSignProcessSM2IdentityFromFile gli aggancia un certificato e una chiave privata. Il certificato è DER, e la chiave privata può essere uno scalare grezzo di 32 byte, un contenitore PKCS#8 non cifrato o un ECPrivateKey SEC 1. Agganciare un'identità SM2 cancella qualsiasi identità Ed25519, Ed448, ML-DSA o Brainpool sullo stesso processo e spegne la firma da certificato store e CNG, così un processo di firma non porta mai due chiavi in competizione

var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('contract.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open source PDF');
    Pdf.SetSignProcessField(SignId, 'Signature1');
    if Pdf.SetSignProcessSM2IdentityFromFile(SignId,
         'signer-sm2.cer', 'signer-sm2.key') <> 1 then
      raise Exception.Create('SM2 certificate or key file missing');
    Pdf.SetSignProcessInfo(SignId, 'Approved', 'Shanghai', '[email protected]');
    Pdf.SetSignProcessCustomSubFilter(SignId, 'ETSI.CAdES.detached');
    Pdf.SetSignProcessDigestAlgorithm(SignId, 8);   // 8 = SM3
    Pdf.EndSignProcessToFile(SignId, 'contract-signed.pdf');
    if Pdf.GetSignProcessResult(SignId) <> 1 then
      raise Exception.CreateFmt('SM2 signing failed, code %d',
        [Pdf.GetSignProcessResult(SignId)]);
    Pdf.ReleaseSignProcess(SignId);
  finally
    Pdf.Free;
  end;
end;

Notate cosa vi dice e cosa non vi dice il valore di ritorno di SetSignProcessSM2IdentityFromFile. Un ritorno 1 significa che entrambi i file sono stati letti e memorizzati sul processo di firma. Il controllo vero, ricavare la chiave pubblica dallo scalare privato e confrontarla con il certificato, gira quando la firma viene applicata, quindi una coppia di chiavi disallineata si manifesta come un GetSignProcessResult diverso da 1, non alla chiamata di identità

Il codice 8 in SetSignProcessDigestAlgorithm seleziona SM3, e con un'identità SM2 è di fatto obbligatorio: il builder CMS impone SM3 per una chiave SM2 indipendentemente da ciò che il processo aveva chiesto, allo stesso modo in cui una chiave Ed25519 viene forzata su SHA-512. Anche la combinazione inversa è bloccata. Con adbe.pkcs7.detached o ETSI.CAdES.detached, un processo di firma impostato su SM3 che non porta un'identità SM2 esce senza firmare, perché nessun altro firmatario della libreria può produrre una firma basata su SM3 che combaci con l'OID dichiarato

Cosa entra in un SignerInfo SM2-con-SM3?

Una firma SM2 in PDFlibPas si calcola sugli attributi firmati codificati in DER, la costruzione CMS standard, ma SM2 aggiunge un passaggio che ECDSA non ha. GB/T 32918.2 fa l'hash del messaggio insieme a ZA, un digest di identità definito come ZA = SM3(ENTL || ID || a || b || Gx || Gy || xA || yA), dove ogni parte della curva è un valore big-endian di 32 byte e ID è l'identificatore distintivo del firmatario. La firma è poi r = (e + x1) mod n e s = ((1 + d)-1(k - r·d)) mod n, con un nonce k fresco dal CSPRNG della libreria e un retry sui round degeneri

PDFlibPas usa l'identificatore distintivo predefinito della specifica, la stringa di 16 caratteri 1234567812345678, sia sul lato firma sia su quello di verifica. Non esiste un'API per cambiarlo. Combacia con la stragrande maggioranza dei certificati SM2 in servizio, ma se una controparte firma con un ID personalizzato i valori ZA differiscono e la firma non verifierà, anche se ogni altro byte è corretto

La coppia (r, s) entra nel CMS come una SEQUENCE DER di due valori INTEGER, il layout che ogni SignerInfo della famiglia DSA usa. PDFlibPas scrive gli identificatori di algoritmo registrati in GM/T 0006: 1.2.156.10197.1.301 per la chiave SM2, 1.2.156.10197.1.401 per SM3 e 1.2.156.10197.1.501 per SM2-con-SM3. Le richieste di timestamp RFC 3161 create con SM3 (digest 8 di SetTimeStampOptions) portano lo stesso OID SM3. Le release prima della 3.589.1 scrivevano un arco non registrato 1.2.156.101.1; la verifica lo accetta ancora, quindi i documenti firmati con quelle build continuano a validare in PDFlibPas, ma i validatori di terze parti non li riconosceranno, motivo più che valido per rifirmare tutto ciò che esce dalla vostra pipeline

Costruzione del SignerInfo SM2-con-SM3 in PDFlibPas per Delphi: il digest copre ZA = SM3 di ENTL, ID e i parametri della curva con ID predefinito 1234567812345678, r e s seguono GB/T 32918.2 con un nonce CSPRNG fresco, e il CMS porta gli OID GM/T 0006 1.2.156.10197.1.301, 1.2.156.10197.1.401 e 1.2.156.10197.1.501
Il valore della firma è una SEQUENCE DER di due INTEGER sugli attributi firmati; le build prima della 3.589.1 scrivevano un arco OID non registrato che i validatori di terze parti rifiutano, quindi rifirmate tutto ciò che esce dalla vostra pipeline

Dove la verifica SM2 si è rotta: byte di segno DER e un riporto perso

La maggior parte dello sforzo di ingegneria nel codice SM2 è andata in due difetti che producevano output plausibile. Sono entrambi tipici della crittografia big integer scritta a mano, e sono entrambi economici da evitare una volta che li conoscete

Il primo era un byte di segno DER. I valori INTEGER ASN.1 sono con segno, quindi l'encoder anteponde correttamente 0x00 a ogni r o s con il bit più alto a 1, producendo un valore di 33 byte. Il verifier convertiva ogni intero a una larghezza fissa di 32 byte senza prima togliere quel byte. Circa la metà di tutti i valori r e s ha il bit più alto a 1, così una larga frazione dei documenti appena firmati falliva la verifica. La prima esecuzione di prova ha per caso pescato valori corti ed è passata; la suite Delphi Win32, firmando con nonce casuali, ha esposto il problema all'istante. Il verifier ora elimina uno zero iniziale da ogni intero di 33 byte prima di fare il padding a 32 byte, e rifiuta tutto ciò che è più lungo

Difetto di verifica delle firme SM2 in PDFlibPas per Delphi e la sua correzione: la codifica INTEGER di ASN.1 anteponde 0x00 quando il bit più alto di r o s è a 1, il vecchio verifier faceva il padding del valore di 33 byte a 32 byte fissi senza togliere il byte di segno, e il verifier corretto elimina lo zero iniziale e rifiuta tutto ciò che è più lungo
Circa la metà di tutti i valori r e s porta il prefisso 0x00, quindi output dall'aspetto plausibile falliva la verifica in blocco; i valori di prova corti passarono la prima esecuzione finché la suite Delphi Win32 non firmò con nonce casuali

Il secondo era un riporto perso nell'addizione a 256 bit. Gli elementi di campo sono otto limb UInt32 little-endian, e l'addizione modulare era scritta come "somma, poi sottrai p se il risultato non è sotto p". Per input ridotti la somma sta sotto 2p ma può comunque superare 2256, e quando ci riusciva, il riporto scartato ripiegava il valore nei bassi 256 bit, dove il confronto con p diceva che tutto andava bene. Raddoppiare un punto con una coordinata grande andava storto; i multipli piccoli no. Il sintomo visibile era che da 1G a 8G combaciavano con il riferimento mentre 9G, 10G e 11G no, e la correzione fu trovata confrontando 1280 input e output di addizione con valori calcolati in modo indipendente e scavando nella prima coppia divergente

L'addizione corretta in PDFlibSM2 conserva il riporto restituito dalla somma grezza dei limb e sottrae p una volta quando il riporto è a 1 o il risultato non è sotto p; una sola sottrazione basta perché p supera 2255. Lo schema vale per qualunque campo primo appena sopra una potenza di due. Se il vostro helper di addizione scarta il riporto finale, testate multipli ben oltre il punto in cui il raddoppio attraversa per la prima volta il confine di parola, non solo 1G, 2G e 3G

Perché SM3 era sbagliato due volte prima di essere giusto

La prima implementazione SM3 falliva ogni test vector standard, e così faceva un'implementazione di riferimento indipendente scritta al fianco. Due implementazioni che concordano tra loro e discordano dallo standard indicano un malinteso condiviso della specifica, non un errore di trascrizione. Confrontare le formule riga per riga con il testo SM3 pubblicato ha trovato tre punti in cui il codice era stato scritto a memoria

  • L'espansione del messaggio applica la rotazione a 7 bit a W[j-13]; W[j-6] è un termine XOR semplice
  • SS1 combina rotl(A, 12), E e la costante di round ruotata con addizione modulo 232, non XOR
  • Per i round da 16 a 63, FF è la funzione maggioranza (X AND Y) OR (X AND Z) OR (Y AND Z); la funzione di scelta in stile SHA appartiene a GG

Dopo quelle tre correzioni la stringa vuota, abc e il vector a due blocchi di 64 byte combaciavano tutti. SM4 poi è entrato al primo colpo, perché la S-box e i test vector erano stati presi dal testo della specifica prima di scrivere qualunque codice: round key rk0 = F12186F9, il vector a blocco singolo 681EDF34D206965E86B3E94F536E4246, e il vector da un milione di iterazioni 595298C7C6FD271F0402F804C33D3F66. Il lavoro su SM2 ha aggiunto una regola imparentata: le costanti esadecimali lunghe si generano per raggruppamento e si controllano in lunghezza, mai a mano, dopo che una coordinata del punto base copiata a mano a cui mancava una cifra ha mandato una sessione di debug dietro a bug di implementazione che non esistevano

Cifrare un PDF con il crypt filter SM4

TPDFlib.Encrypt con strength 6 cifra il documento selezionato con SM4-128 attraverso il framework dei crypt filter di ISO 32000-1 §7.6.5. Il dizionario di cifratura usa il security handler standard con /V 4, /R 4 e /Length 128, e il crypt filter /StdCF porta /CFM /SM4. Salvare con strength 6 alza il file ad almeno PDF 1.6, GetEncryptionLevel riporta 7 per un tale file, e la cifratura a chiave pubblica ha la stessa opzione attraverso EncryptPublicKey con strength 4

var
  Pdf: TPDFlib;
  Perm: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('report.pdf', '') <> 1 then
      raise Exception.Create('cannot load PDF');
    // stampa, copia e compilazione moduli consentite; niente modifiche o assemblaggio
    Perm := Pdf.EncodePermissions(1, 1, 0, 0, 1, 1, 0, 1);
    if Pdf.Encrypt('owner-secret', 'reader-pass', 6, Perm) <> 1 then
      raise Exception.Create('SM4 encryption failed');
    Pdf.SaveToFile('report-sm4.pdf');
  finally
    Pdf.Free;
  end;
end;

Ogni stringa e stream viene cifrata come IV casuale di 16 byte seguito dal ciphertext SM4-CBC con padding PKCS#7, lo stesso layout che usa AESV2. La chiave per oggetto segue l'Algoritmo 1 di ISO 32000-1 §7.6.2: la file key, i tre byte bassi del numero di oggetto e due byte del numero di generazione, più il suffisso sAlT, sottoposti a hash con MD5 e troncati a 16 byte. La derivazione della chiave dalla password è quella della revisione 4, quindi lo strength 6 ne eredita i limiti: le password non vengono processate con SASLprep (solo gli strength AES-256 3, 4 e 5 lo fanno), e la derivazione della chiave non è più robusta di quella di AESV2

Struttura del crypt filter SM4 scritto da PDFlibPas Encrypt con strength 6 in Delphi: /V 4, /R 4 e /Length 128 con il filtro /StdCF impostato a /CFM /SM4, una chiave MD5 per oggetto su file key, numeri di oggetto e generazione più sAlT troncati a 16 byte, e ogni stream memorizzato come IV di 16 byte seguito da ciphertext SM4-CBC con padding PKCS#7
Stringhe e stream usano il layout di byte di AESV2 con un nucleo SM4, ma /SM4 è fuori dai valori /CFM che ISO 32000 definisce, quindi i reader mainstream non possono decifrare e la distribuzione generale dovrebbe restare su AES-256

Limiti di interoperabilità da conoscere prima di rilasciare

Il sunto onesto è che la firma SM2 interopera entro i confini degli OID e dell'ID predefinito descritti sopra, mentre la cifratura SM4 interopera solo con il software che conosce la stessa convenzione. /SM4 non è uno dei valori /CFM definiti in ISO 32000-1 Tabella 25 o in ISO 32000-2, che definiscono None, V2, AESV2 e AESV3. Un reader PDF mainstream che incrocia /CFM /SM4 non decifrerà il file. PDFlibPas stesso legge /SM4 e la variante /SM4V1 attraverso il proprio security handler, quindi una pipeline chiusa in cui PDFlibPas scrive e legge funziona; un file per il grande pubblico dovrebbe usare invece AES-256

Per le firme, il confine è il layout del certificato e il validatore dall'altra parte. PLValidateSM2Identity, il controllo dietro il processo di firma, accetta il layout GM/T 0015, in cui il SubjectPublicKeyInfo porta id-ecPublicKey con la curva SM2 1.2.156.10197.1.301 come parametro, così come i certificati che mettono l'OID SM2 nel campo algoritmo stesso, e rifiuta tutto ciò di cui il punto pubblico derivato non combacia con lo scalare privato. Prima di affidarvi alle firme SM2 in produzione, firmate un documento con un certificato della CA che userete davvero e verificate con il validatore che gira alla controparte. Potete eseguire il controllo di identità da soli prima di avviare un processo di firma:

uses
  SysUtils, PDFlibModernSign;

var
  CertDER, KeyDER, Scalar: AnsiString;
begin
  // CertDER e KeyDER caricati da disco o da un export HSM
  // Tenete il parametro Out in una sua variabile: passare KeyDER sia come
  // input Const sia come output Out cancellerebbe prima l'input
  if not PLValidateSM2Identity(CertDER, KeyDER, Scalar) then
    raise Exception.Create('certificate and SM2 private key do not match');
  FillChar(Pointer(Scalar)^, Length(Scalar), 0);
end;

Quel commento registra un difetto reale del lavoro di integrazione. Quando la stessa variabile AnsiString veniva passata come input Const e output Out, il compilatore finalizzava la variabile Out all'ingresso, l'input diventava vuoto prima che la routine lo leggesse, e il controllo di identità falliva su una chiave perfettamente buona. Qualsiasi API che prende una chiave come Const e restituisce una chiave derivata come Out richiede variabili separate dal lato chiamante

FAQ

PDFlibPas ha bisogno di Windows CNG o di una DLL esterna per SM2, SM3 e SM4?

No. PDFlibPas implementa tutti e tre gli algoritmi in Object Pascal in PDFlibDigest, PDFlibSM4 e PDFlibSM2, e le firme SM2 sono costruite e verificate dal codice CMS della libreria. Windows CryptoAPI e CNG non c'entrano con la produzione della firma SM2

Si può usare SM2 con SHA-256 al posto di SM3?

No. Quando un processo di firma porta un'identità SM2, il builder CMS di PDFlibPas usa sempre SM3, perché GB/T 32918.2 definisce le firme SM2 su un digest SM3 che include l'hash di identità ZA. Impostare il digest a SHA-256 su un processo di firma SM2 non cambia il risultato

Adobe Acrobat aprirà un PDF cifrato con SM4 da PDFlibPas?

Non aspettatevelo. Il crypt filter /CFM /SM4 è fuori dai valori definiti da ISO 32000, quindi i reader mainstream non lo riconoscono. Usate la cifratura SM4 per sistemi chiusi che leggono i file con PDFlibPas o un'implementazione compatibile, e AES-256 per i documenti destinati alla distribuzione generale

Firma SM2, hashing SM3, cifratura SM4 e l'intero set di API di firma PAdES sono inclusi in PDFlibPas, la Delphi PDF Library, che compila con Delphi, C++Builder e FPC/Lazarus su target Windows e Unix