Article technique

Mots de Passe SASLprep AES-256 en Delphi avec PDFlibPas

Un PDF chiffré en AES-256 avec un mot de passe non ASCII s ouvre dans le programme qui l a écrit et nulle part ailleurs. La cause est presque toujours une étape de préparation manquante : la norme ISO 32000-2 §7.6.4.3.3 exige que le mot de passe soit traité avec le profil SASLprep de stringprep avant d être encodé en UTF-8 et haché. PDFlibPas, la bibliothèque PDF pour Delphi et C++Builder, effectue cette préparation à l intérieur de Encrypt, EncryptFile et DecryptFile

Ce n est pas l histoire du mauvais mot de passe ni celle des bits de permission. Si vos utilisateurs tapent un mot de passe que vous n avez jamais délivré, la mécanique de nouvelle tentative dans l article sur la nouvelle tentative des mots de passe PDF chiffrés est ce qu il vous faut, et si vous essayez de déterminer ce qu un fichier existant applique réellement, l audit de chiffrement et de permissions couvre ce terrain. Celui-ci est plus étroit et plus étrange : le mot de passe est correct, l utilisateur l a tapé correctement, et le fichier refuse toujours de s ouvrir ailleurs

Pourquoi un mot de passe non ASCII s ouvre-t-il dans un lecteur mais pas dans un autre ?

Parce que les deux programmes hachent des séquences d octets différentes à partir des mêmes frappes. La dérivation de clé de révision 6 dans la norme ISO 32000-2 §7.6.4.3.3 prend le mot de passe en octets UTF-8, tronque à 127 octets, ajoute un sel, et exécute le hachage renforcé ; le résultat est vérifié contre les entrées /U et /O dans le dictionnaire de chiffrement. Rien dans cette chaîne n est flou. Un octet différent n importe où dans l entrée produit un condensé complètement différent, la validation échoue, et le lecteur n a qu une seule chose à dire : mauvais mot de passe

Les octets divergent parce qu Unicode offre plusieurs façons de saisir ce qui ressemble au même mot de passe. Un mot de passe chinois peut arriver sous forme de caractères précomposés depuis une méthode de saisie et sous forme de formes de compatibilité depuis une autre. Un mot de passe allemand ou français copié depuis un traitement de texte peut porter une ESPACE INSÉCABLE (U+00A0) là où l utilisateur croit qu il y a une espace ordinaire, ou un TRAIT D UNION CONDITIONNEL (U+00AD) qui s affiche comme rien du tout. SASLprep existe pour réduire tout cela à une forme canonique unique avant que quiconque ne hache quoi que ce soit, de sorte que chaque implémentation conforme dérive la même clé de la même intention

Que change réellement SASLprep dans un mot de passe ?

La RFC 4013 définit SASLprep comme un profil du cadre stringprep de la RFC 3454, et ce sont quatre étapes ordonnées plutôt qu une seule transformation. Le mappage vient en premier : la table C.1.2 de la RFC 3454 (espaces non ASCII) est mappée vers U+0020, et la table B.1 (caractères couramment mappés vers rien) est supprimée purement et simplement. La normalisation vers Unicode NFKC suit, c est l étape qui replie les caractères de compatibilité et les séquences combinantes. Puis la vérification de sortie interdite rejette tout ce qui figure dans les tables C.2.1 à C.9. Enfin, la règle bidirectionnelle de la section 6 de la RFC 3454 est appliquée à la chaîne normalisée

PDFlibPas implémente le profil entier dans l unité PDFlibSASLprep, qui expose un seul point d entrée. PLSASLprepPassword prend le mot de passe brut, écrit la forme préparée dans un paramètre var, et renvoie False quand le mot de passe doit être refusé. La fonction est délibérément totale sur le chemin heureux : un mot de passe uniquement ASCII revient identique octet pour octet, donc rien ne change pour les déploiements existants

uses
  PDFlibSASLprep;

var
  Prepared: WideString;
begin
  // RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 deletes SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 maps NBSP to U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC folds ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC folds ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII is never touched
end;

L ambiguïté U+200B que les tables ne résolvent pas

Un point de code atterrit dans deux tables de la RFC 3454 à la fois, et les deux tables sont en désaccord. L ESPACE DE CHASSE NULLE (U+200B) tombe dans la plage C.1.2 U+2000 à U+200B, où la règle dit de la mapper vers U+0020, et elle tombe aussi dans la plage B.1 U+200B à U+200D, où la règle dit de la supprimer. Lisez l étape de mappage dans un ordre ou dans l autre et vous obtenez des octets différents à partir du même mot de passe : a+U+200B+b se prépare en a b sous C.1.2 et en ab sous B.1. La RFC 4013 nomme les deux tables et ne dit pas laquelle l emporte, donc c est une véritable ambiguïté dans la spécification plutôt qu une erreur de lecture. PDFlibPas teste l appartenance à C.1.2 en premier et mappe donc U+200B vers une espace, ce qui est le comportement sur lequel se sont fixées d autres implémentations stringprep largement déployées ; les faire correspondre est la seule chose qui compte ici, car l objectif est l accord d octets avec quel que soit le lecteur qu utilise le client

Lire les anciens fichiers : préparé d abord, brut ensuite

La correction crée son propre problème de compatibilité. Chaque fichier AES-256 écrit avant le changement hachait le mot de passe UTF-8 brut, donc rendre le lecteur strictement conforme verrouillerait les clients hors de leurs propres archives. PDFlibPas résout cela côté lecture en essayant deux candidats dans l ordre. TPDFDocument.SetPassword construit une liste de candidats qui commence par la forme préparée et retombe sur la forme brute, et elle n ajoute l entrée préparée que lorsque le document est réellement AES-256 et que les deux formes diffèrent. Pour un mot de passe ASCII, les formes sont identiques, la liste contient une seule entrée, et le coût de tout le mécanisme est une simple comparaison de chaînes. DecryptFile fait la même chose le long de son chemin de réécriture directe AES-256, en appelant PLDirectDecryptFileAES256 avec le mot de passe préparé en premier

var
  Lib: TPDFlib;
  Bytes: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.DrawText(100, 100, 'saslprep roundtrip');
    // Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
    Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
      Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
    Bytes := Lib.SaveToString;
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    // 'pass' is what SASLprep produced and what any conforming reader computes,
    // so the plain ASCII form opens a file created with the soft-hyphen form
    if Lib.LoadFromString(Bytes, 'pass') = 1 then
      Caption := IntToStr(Lib.PageCount);
  finally
    Lib.Free;
  end;
end;

Le repli emporte une garde qui mérite d être reprise. La seconde tentative dans DecryptFile ne s exécute que lorsque les formes préparée et brute diffèrent et que la première tentative n a signalé aucun code d erreur dur. Un échec structurel signifie que l entrée est endommagée ou n est pas la révision de chiffrement supposée, et retenter un fichier corrompu avec un autre mot de passe ne fait que brûler une seconde analyse complète sur une entrée hostile ; le raisonnement derrière ce réflexe est exposé dans la note sur l analyse sûre des PDF non fiables. Notez aussi qu il n y a aucun repli côté écriture, et cette asymétrie est intentionnelle. La lecture tolère l histoire, l écriture non : chaque nouveau fichier AES-256 obtient les octets conformes

Quels mots de passe sont rejetés purement et simplement, et qu est-ce que l erreur 604 ?

SASLprep peut refuser un mot de passe entièrement, et quand cela arrive, le chiffrement doit échouer bruyamment plutôt que de substituer silencieusement quelque chose. Encrypt et EncryptFile préparent à la fois les mots de passe propriétaire et utilisateur chaque fois que Strength vaut 3 ou 4, renvoient 0 en cas de refus, et définissent LastErrorCode à PDFLIB_ERROR_PASSWORD_SASLPREP, qui vaut 604. Deux familles d entrées le déclenchent. Les tables de sortie interdite rejettent les caractères de contrôle (C.2.1 et C.2.2), les points de code à usage privé (C.3), les non-caractères (C.4), les substituts isolés (C.5), U+FFFD (C.6), les caractères de description idéographique (C.7), et les plages de contrôle d affichage et de balisage (C.8 et C.9). Séparément, la règle bidi de la section 6 de la RFC 3454 rejette toute chaîne contenant un caractère RandALCat de la table D.1 à moins que la chaîne ne commence et ne se termine toutes deux par un tel caractère et ne contienne aucune lettre de gauche à droite

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // U+0007 is a C.2.1 control character, so preparation refuses the password
    if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
         Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
    begin
      if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then  // 604
        ShowMessage('The password contains characters that PDF encryption does not permit.');
    end;
  finally
    Lib.Free;
  end;
end;

Cette règle bidi est celle qui va surprendre votre support. Un mot de passe arabe ou hébreu se terminant par un chiffre occidental, ou avec une lettre latine égarée au milieu, est refusé par la spécification même s il paraît parfaitement raisonnable dans le champ de saisie. Affichez le 604 comme un message concernant les caractères du mot de passe, pas comme un échec de chiffrement générique, ou quelqu un passera un après-midi à chercher un bogue dans votre dérivation de clé

Limites honnêtes : NFKC, un LCat approximatif, et un piège Delphi

Deux parties de l implémentation sont des approximations, et toutes deux méritent d être énoncées clairement plutôt qu enterrées. La normalisation NFKC est effectuée par l API Windows NormalizeString, chargée dynamiquement depuis Normaliz.dll. Quand cette bibliothèque n est pas disponible, la chaîne mappée est utilisée non normalisée, ce qui signifie que les étapes de mappage et d interdiction s exécutent quand même mais que le repliement de compatibilité non. En pratique, la DLL a été livrée avec chaque version de Windows depuis Vista, donc le chemin dégradé est une préoccupation antérieure à Vista et non Windows plutôt qu une préoccupation vivante, mais un mot de passe qui dépend du repliement NFKC produirait des octets différents là-bas et c est une divergence réelle, bien que rare. La vérification bidi est la seconde approximation : détecter les caractères LCat utilise les plages de lettres courantes au lieu de la table D.2 complète de la RFC 3454, et le sens de cette erreur est ce qui la rend acceptable. Un caractère LCat manqué ne peut que faire passer la règle bidi là où la spécification aurait rejeté, jamais l inverse, et cela ne touche jamais les étapes de mappage ou de normalisation, donc la séquence d octets préparée d un mot de passe accepté reste inchangée. Le risque résiduel est donc une divergence de politique plutôt qu une divergence d octets : un mot de passe dans une écriture exotique qu une implémentation plus stricte refuserait d accepter du tout. Chaque mot de passe accepté par les deux côtés se hache de façon identique, ce qui est la propriété dont dépend réellement l interopérabilité

Enfin, un piège de syntaxe Delphi qui coûte une heure si vous ne l avez jamais rencontré. Quand une fonction renvoie un type procédural, l assigner sans parenthèses ne l appelle pas. Le compilateur lit Proc := GetNormalizeProc; comme prenant l adresse de GetNormalizeProc elle-même, puis signale E2009 avec la plainte peu utile que les conventions d appel diffèrent, car l accesseur utilise la convention par défaut tandis que le type d API importé est stdcall. Les parenthèses vides sont obligatoires

type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // loads Normaliz.dll on first use
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: reads as @GetNormalizeProc, conventions differ
  Proc := GetNormalizeProc();    // correct: calls the accessor and assigns its result
  if not Assigned(Proc) then
    Exit;                        // no NFKC available, mapped string is used as-is
end;

La préparation des mots de passe est un de ces détails qui n apparaît jamais dans une liste de fonctionnalités et qui décide si un document chiffré survit au contact d un client dans une autre locale. Les points d entrée Encrypt, EncryptFile, DecryptFile et SetPassword décrits ici font partie de losLab PDF Developer Library Pascal Edition pour Delphi et C++Builder, dont la page produit porte la référence complète de chiffrement et la table complète des codes d erreur