Article technique

Signature PDF depuis le magasin de certificats HotPDF : ordre des octets CNG contre CAPI

HotPDF signe un PDF avec un certificat déjà présent dans le magasin de certificats Windows en transmettant l'empreinte à Windows lui-même, et Windows traite cette demande via l'un des deux backends de clé privée : CNG, qui renvoie la signature RSA en gros-boutiste (big-endian), ou l'ancien CSP CryptoAPI, qui la renvoie en petit-boutiste (little-endian). Confondre les deux et la signature CMS que HotPDF intègre se retrouve à l'envers, octet par octet, pour le backend qui a réellement répondu, de sorte qu'un validateur conforme signale la signature comme invalide alors même que les octets du document n'ont jamais été touchés

Deux problèmes sans rapport entre eux se cachent derrière cette seule phrase, et le signataire par certificat système de HotPDF doit résoudre les deux avant de signer quoi que ce soit. Le décalage d'ordre des octets est silencieux : l'appel de signature renvoie toujours True, le PDF s'ouvre toujours, et l'échec n'apparaît que lorsqu'une visionneuse parcourt la structure CMS et la rejette. Le second problème est bruyant et propre à C++Builder : une demi-douzaine de fonctions crypt32 refusent de se lier, car la bibliothèque d'import fournie par RAD Studio ne les exporte pas. Aucun de ces deux problèmes n'existe si l'on ne signe qu'avec un fichier PFX, ce qui explique pourquoi ils piègent souvent les développeurs passant de la signature en un appel basée sur PFX à un certificat déjà installé par le service informatique dans le profil de l'utilisateur

Sélectionner un certificat dans le magasin

HotPDF expose ce chemin via HPDFSignPDFStreamWithSystemCertificate et HPDFSignPDFFileWithSystemCertificate, tous deux pilotés par un enregistrement THPDFCertificateStoreSelector : Location (cslCurrentUser ou cslLocalMachine), StoreName ('MY', le magasin personnel, par défaut), une Thumbprint SHA-1, et un indicateur AllowUI. L'empreinte est normalisée en interne, si bien que les tirets ou espaces copiés directement depuis l'interface du gestionnaire de certificats sont retirés avant la comparaison

var
  Selector: THPDFCertificateStoreSelector;
  Options: THPDFCMSSignOptions;
begin
  Selector := THPDFCertificateStoreSelector.Default;  // cslCurrentUser, store 'MY'
  Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
  Selector.AllowUI := False;

  Options := HPDFCMSDefaultOptions(palBaseline_B_B);
  if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
    'invoice-signed.pdf', Selector, Options) then
    raise Exception.Create('Certificate-store signing failed');
end;

AllowUI = False importe plus qu'il n'y paraît, car cela correspond directement à CRYPT_ACQUIRE_SILENT_FLAG, et Windows le respecte à la lettre : si la clé privée du certificat correspondant réside sur une carte à puce ou un jeton nécessitant une saisie de code PIN que Windows n'a pas déjà mise en cache, CryptAcquireCertificatePrivateKey échoue plutôt que d'afficher une boîte de dialogue depuis ce qui pourrait être un processus de service. Cet échec est bruyant, une EHPDFCMSError visible immédiatement, mais il est facile de l'interpréter à tort comme « certificat introuvable » alors que la cause réelle est un jeton qui attend un code PIN que personne ne va saisir

Pourquoi CNG et CAPI ne s'accordent-ils pas sur l'ordre des octets ?

Quel backend répond n'est pas une supposition : CryptAcquireCertificatePrivateKey le signale directement via un paramètre de sortie KeySpec, et c'est cette seule valeur qui détermine la branche choisie par le signataire de HotPDF. Une clé issue d'un fournisseur de stockage de clés CNG revient avec KeySpec réglé sur la sentinelle CERT_NCRYPT_KEY_SPEC ($FFFFFFFF) ; toute autre valeur désigne une clé CSP CryptoAPI traditionnelle. La plupart des certificats personnels émis ou importés sur une installation Windows actuelle se résolvent en CNG, même si un shim CSP hérité subsiste pour la compatibilité, ce qui explique pourquoi HotPDF demande CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG conjointement avec CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG avant même de regarder la valeur renvoyée

Les deux backends n'appellent pas seulement des fonctions différentes, NCryptSignHash pour une clé CNG, CryptSignHashA pour une clé CSP ; ils renvoient la signature RSA brute dans un ordre d'octets opposé. La sortie de CNG correspond déjà à ce qu'attend PKCS#1 : une chaîne d'octets gros-boutiste, l'octet le plus significatif en premier, exactement ce que produit la conversion I2OSP de la RFC 8017 et ce dont a besoin un SignerInfo CMS (RFC 5652) dans son champ de signature selon ISO 32000-1 §12.8.3. CryptSignHash de CryptoAPI, à l'inverse, renvoie la signature en petit-boutiste, une bizarrerie documentée qui remonte à la façon dont les anciens CSP représentaient en interne les grands nombres. Omettre l'inversion sur le chemin CAPI place chaque octet de la signature au mauvais endroit ; le calcul RSA reste correct, mais la chaîne d'octets lue par un vérificateur n'est pas celle définie par PKCS#1

// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
  Temp := Signature[I];
  Signature[I] := Signature[High(Signature) - I];
  Signature[High(Signature) - I] := Temp;
end;

Et pour un callback de signature personnalisé ?

Quiconque contourne le signataire intégré de HotPDF pour le magasin de certificats hérite de la même règle d'ordre des octets. HPDFCMSSignPDFStreamWithExternalSigner prend un THPDFCMSSignDigestCallback, une closure de type reference to function(const SignedAttributesSHA256: TBytes): TBytes, pour signer via un HSM, une pile logicielle de carte à puce, ou tout autre dispositif qui n'est pas un certificat pour lequel le magasin Windows peut vous fournir un handle de clé. Quel que soit le backend derrière ce callback, les octets qu'il renvoie doivent être en ordre gros-boutiste avant que HotPDF ne les intègre dans la structure CMS

Signer :=
  function(const SignedAttributesSHA256: TBytes): TBytes
  begin
    if UsesCngKeyStorageProvider then
      Result := SignWithMyCngKey(SignedAttributesSHA256)       // already big-endian
    else
      Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
  end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
  CertificateDER, Signer, Options);

Il convient d'être explicite sur une limite ici : les deux chemins de signature intégrés à HotPDF, CNG via NCryptSignHash avec un remplissage PKCS#1, et CAPI via CryptSignHashA, ciblent tous deux des clés RSA signant une empreinte SHA-256 de 32 octets. Aucun des deux ne négocie un format de signature ECDSA. Un certificat dont la clé privée est basée sur une courbe elliptique nécessite un signataire que vous écrivez vous-même via HPDFCMSSignPDFStreamWithExternalSigner, en encodant la signature ECDSA de la façon attendue par CMS plutôt qu'en supposant une chaîne d'octets RSA de longueur fixe ; ne vous attendez donc pas à ce que le signataire intégré du magasin de certificats fasse ce qu'il faut pour un jeton provisionné avec un certificat EC

Pourquoi C++Builder échoue-t-il à lier CertOpenStore ?

Parce que la bibliothèque d'import C++Builder par défaut de RAD Studio, import32.lib, n'exporte pas CertOpenStore, ni cinq de ses voisines : CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, et CryptAcquireCertificatePrivateKey. Les builds Delphi ne voient jamais ce problème, car dcc32/dcc64 résolvent un import statique external 'crypt32.dll' directement dans la table d'import du PE. C++Builder fonctionne différemment : le compilateur Delphi émet un .obj au format OMF pour le build du package, ilink32 le lie, et à ce stade la même déclaration external n'est plus qu'un symbole non résolu en attente d'une bibliothèque d'import sur la ligne de commande. Pointer le linker vers le répertoire psdk du Windows SDK, où le crypt32.lib complet exporte bien les six symboles, ne résout pas non plus le problème : ilink32 ne lie que les bibliothèques d'import effectivement nommées sur sa ligne de commande, import32.lib cp32mt.lib par défaut, et ajouter un chemin de recherche ne l'amène pas à en tirer quoi que ce soit de plus depuis ce chemin. Lancer tdump sur import32.lib confirme directement l'absence : zéro résultat pour CertOpenStore, contre six résultats propres dans le crypt32.lib du SDK

HotPDF résout ce problème de la même manière qu'il gère déjà l'énumération des certificats ailleurs dans la bibliothèque : au lieu de demander ces symboles au linker, il les charge à l'exécution. Un enregistrement interne THPDFCryptoProcs porte un handle vers crypt32.dll, un handle vers advapi32.dll, et onze champs de pointeurs de fonction ; LoadCryptoProcs charge les deux DLL et résout chaque point d'entrée avec GetProcAddress une seule fois, au début de HPDFSignPDFStreamWithSystemCertificate, en levant immédiatement EHPDFCMSError si quoi que ce soit manque plutôt que d'échouer plus tard avec une violation d'accès au cœur du flux de signature

type
  TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
    hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
  Crypt32Handle: HMODULE;
  CertOpenStore: TCertOpenStoreFn;
begin
  Crypt32Handle := LoadLibrary('crypt32.dll');
  if Crypt32Handle = 0 then
    raise Exception.Create('crypt32.dll could not be loaded');
  @CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
  // ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;

Le chargement se fait une seule fois par appel plutôt que paresseusement à l'intérieur de chaque assistant, car la closure qui choisit entre CNG et CAPI capture la table de fonctions chargée par valeur et doit rester vivante pendant tout le flux de signature, y compris lors du rappel dans HPDFCMSSignPDFStreamWithExternalSigner ; les deux handles de DLL sont libérés dans le bloc finally le plus externe une fois la signature terminée ou une exception levée. Rien de tout cela ne touche la surface publique : HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate, et THPDFCertificateStoreSelector conservent exactement les signatures qu'ils avaient auparavant, si bien qu'adopter le correctif ne demande qu'une recompilation pour le code appelant existant, pas une modification de code

Ce que cet article ne couvre pas

Corriger l'ordre des octets et la liaison C++Builder produit un SignerInfo CMS qu'un validateur peut analyser et une signature qu'il peut vérifier arithmétiquement ; cela ne dit rien de si ce validateur doit faire confiance au certificat qui se trouve derrière, car la construction de la chaîne de certification, la vérification de révocation et la politique d'horodatage sont des préoccupations distinctes ajoutées via les options CMS, et non quelque chose que la correction de l'ordre des octets offre gratuitement. Deux détails d'entretien comptent tout autant que la cryptographie : le PCCERT_CONTEXT renvoyé par la recherche de certificat doit être libéré avec CertFreeCertificateContext avant la fermeture du magasin, et un handle de clé CNG ou CSP acquis, lorsque l'API indique que l'appelant en est propriétaire, doit être libéré via l'appel propre au backend correspondant, jamais celui de l'autre. Si le résultat svValid que vous obtenez au final s'avère plus restreint que prévu, l'article sur la vérification des signatures numériques PDF expose précisément ce que cet indicateur garantit et ne garantit pas. Comme le certificat reste tout du long sous la garde de Windows, la signature depuis le magasin évite une surface d'attaque entière : il n'y a aucun fichier PKCS#12 à analyser ni aucun ASN.1 à parcourir soi-même, ce qui est justement le problème que le durcissement PKCS#12 et ASN.1 de HotPDF traite pour le chemin de signature par fichier PFX

La signature depuis le magasin de certificats, la signature PFX et les callbacks de signataire externe sont trois portes vers le même pipeline CMS/PKCS#7 au sein du composant PDF HotPDF pour Delphi et C++Builder, et choisir la bonne revient surtout à déterminer qui est autorisé à détenir la clé privée : votre processus, un fichier PFX, ou Windows lui-même