PDF Library for Delphi (PDFlibPas) extrait les certificats d'une signature PDF par une pure promenade DER dans le CMS SignedData stocké dans /Contents, sans faire appel à CryptoAPI. Depuis la v3.539.10, chaque lecture imbriquée est bornée par son élément parent, le bourrage de zéros après le CMS est coupé à la longueur que le CMS déclare, et les object identifiers encodent leur premier sous-identifiant combiné en base-128. La règle des bornes et la correction OID ont toutes deux remplacé du code qui produisait de mauvaises réponses sans lever d'erreur, et la règle du bourrage évite que le lecteur plus strict rejette des signatures bien réelles
Le côté lecture compte plus qu'il n'y paraît. Un outil de validation à long terme doit sortir le certificat du signataire et ses émetteurs d'une signature existante avant d'aller chercher les données de révocation, un rapport d'audit doit dire qui a signé, et un build Lazarus sous Linux n'a aucune fonction de message Windows sur laquelle s'appuyer. Un parseur dans cette position plante rarement sur une entrée mal formée. Le mode de défaillance qui fait mal, c'est un compte de certificats qui avale des octets appartenant au voisin, un appariement du signataire fait sur le mauvais champ, ou un OID qui se transforme tranquillement en un autre OID. Un pipeline de signatures bâti là-dessus rapporte des absurdités avec aplomb
Lire les certificats du signataire dans un PDF signé
Cinq méthodes de TPDFlib couvrent le côté lecture, et toutes prennent InputFile, Password, FieldName : chaque appel ouvre le fichier en lecture seule, répond, et le referme. GetSignatureEmbeddedCertificateCount et GetSignatureEmbeddedCertificateDER énumèrent le jeu de certificats dans l'ordre d'encodage, GetSignatureSignerCertificateDER renvoie le certificat qui a produit un SignerInfo donné, et GetSignatureCertificateChainLength / GetSignatureCertificateChainDER remontent du signataire vers l'émetteur le plus lointain que la signature porte elle-même. Les index partent de zéro. Gardez les résultats dans un AnsiString, c'est d'ailleurs pour ça que la bibliothèque les renvoie ainsi : un blob DER passé par string ou un TStrings traverse une conversion de jeu de caractères et revient corrompu
uses
SysUtils, Classes, PDFlibrary;
procedure SaveDer(const FileName: string; const Der: AnsiString);
var
Fs: TFileStream;
begin
Fs := TFileStream.Create(FileName, fmCreate);
try
if Der <> '' then
Fs.WriteBuffer(Der[1], Length(Der));
finally
Fs.Free;
end;
end;
const
Src = 'contract-signed.pdf';
Field = 'Signature1';
var
Pdf: TPDFlib;
ChainLen, I: Integer;
SignerDer, LastDer: AnsiString;
begin
Pdf := TPDFlib.Create;
try
WriteLn('Certificates in the CMS: ',
Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
if SignerDer = '' then
raise Exception.Create('signer certificate missing or not matched');
SaveDer('signer.cer', SignerDer);
ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
for I := 0 to ChainLen - 1 do
SaveDer(Format('chain-%d.cer', [I]),
Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
if ChainLen > 0 then
begin
LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
end;
finally
Pdf.Free;
end;
end;
Deux choses dans cette sortie demandent de l'attention. Un compte à 0 n'est pas un diagnostic : un champ absent, un mot de passe erroné, un blob qui n'est pas du DER et un SignedData qui omet simplement le jeu de certificats optionnel reviennent tous comme 0 ou une chaîne vide, donc journalisez le nom du champ à côté du nombre. Et une chaîne qui s'arrête avant un certificat auto-émis n'est pas une erreur non plus. Le constructeur de chaîne n'utilise que les certificats embarqués dans la signature, donc les émetteurs restants doivent être récupérés via les adresses que GetCertificateIssuerURLs rapporte
Quelle partie de /Contents est réellement le CMS ?
Seul le préfixe que le SEQUENCE externe déclare appartient au CMS, et PLTrimCMSPadding coupe tout ce qui suit. Un signataire réserve la chaîne hexadécimale /Contents avant même que le CMS existe, parce que le /ByteRange décrit dans ISO 32000-1 §12.8.1 doit être figé en premier, donc l'emplacement est dimensionné large et la queue inutilisée est à zéros. PLTrimCMSPadding lit le premier TLV, exige le tag $30, et renvoie les octets jusqu'à la fin de cet élément ; tout ce qui ne commence pas par un SEQUENCE bien formé revient vide. Ce niveau supérieur est le seul endroit où des octets traînants sont légaux, et la distinction compte pour la section suivante : une règle stricte du genre « l'élément doit consommer tout le tampon » rejetterait chaque signature du monde réel, tandis qu'une règle laxiste appliquée à chaque profondeur laisse des champs imbriqués lire des octets qui ne leur appartiennent pas
Pourquoi un lecteur DER a-t-il besoin du décalage de fin du parent ?
Un élément imbriqué n'est valide que s'il se termine à l'intérieur de son parent, et une vérification contre la fin du tampon ne le prouve pas. Le DERReadTLV de bas niveau dans PDFlibASN1 borne chaque élément par la chaîne entière, ce qui est le bon contrôle pour l'objet le plus externe et le mauvais pour tout ce qui est en dessous. Imaginez un SignerInfo dont issuerAndSerialNumber déclare 40 octets alors que le Name de l'émetteur qu'il contient en réclame 60. Chaque octet est toujours dans le tampon, donc un lecteur borné par le tampon accepte le Name, lit le numéro de série dans l'algorithme de condensat qui suit, puis compare cette paire aux certificats embarqués. Avant la v3.539.10, le parcours du CMS lisait exactement comme ça. La correction est un petit wrapper qui transporte la position de fin du parent dans chaque lecture
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
Result := False;
// plus rien à lire dans le parent : on refuse de démarrer
if (Offset < 1) or (Offset >= ParentEnd) then
Exit;
if not DERReadTLV(Data, Offset, Tag, Start, Len) then
Exit;
// Offset est à présent un cran après l'élément ; interdit de dépasser le parent
Result := Offset <= ParentEnd;
end;
// chaque niveau note sa propre fin et la transmet vers le bas :
// OuterEnd := fin de ContentInfo (RFC 5652 section 3)
// ExplicitEnd := fin du contenu [0] EXPLICIT
// ContentEnd := fin de SignedData (RFC 5652 section 5.1)
// SignerEnd / InnerEnd pour SignerInfo et issuerAndSerialNumber
L'unité PDFlibCMSRead fait maintenant passer ces fins à travers ContentInfo, le wrapper [0] EXPLICIT, les champs de SignedData jusqu'à signerInfos, le SignerIdentifier sous ses deux formes issuerAndSerialNumber et [0] subjectKeyIdentifier (RFC 5652 §5.3), et les champs tbsCertificate lus dans chaque certificat embarqué lors de l'appariement du signataire. Dans le jeu de certificats et le jeu signerInfos, un élément qui dépasse la fin du jeu arrête la boucle : PLExtractCMSCertificates renvoie les certificats déjà acceptés et ne colle jamais les octets crls ou signerInfos suivants au dernier. L'appariement émetteur-plus-numéro-de-série exige aussi les deux moitiés, car un numéro de série n'est unique qu'au sein d'un seul émetteur
Pourquoi 2.999.3 ressortait-il en 1.15.3 ?
Les deux premiers arcs d'un OID se combinent en un seul sous-identifiant, pas en un octet, et ce sous-identifiant est encodé en base-128 comme tous les autres arcs. X.690 §8.19.4 le définit comme 40 * arc1 + arc2 ; l'ancien DER_OID écrivait cette valeur avec un Byte(...), ce qui n'est correct que jusqu'à 127, la valeur de 2.47. Pour 2.999 la somme fait 1079, le cast en octet garde 55, et 55 se décode en 1.15, si bien que l'identifiant nomme tranquillement une autre branche de l'arbre. Les valeurs de 128 à 255 échouent autrement, en émettant un octet avec le bit de continuation posé qui avale l'arc suivant. La plupart des identifiants PKI (1.2.840..., 2.5.29..., 0.4.0...) n'atteignent jamais la frontière, d'où sa survie ; les arcs joint-iso-itu-t à partir de 2.48, si. DER_OID sert à la fois à l'encodeur des attributs signés, au matcher dans DERFindExtensionByOID et au contrôle du content-type SignedData, donc un encodage faux cassait l'écriture et la recherche ensemble
uses
SysUtils, PDFlibASN1;
function Hex(const S: AnsiString): string;
var
I: Integer;
begin
Result := '';
for I := 1 to Length(S) do
Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
Result := Trim(Result);
end;
begin
WriteLn(Hex(DER_OID('2.999.3'))); // 06 03 88 37 03
WriteLn(Hex(DER_OID('2.47.1'))); // 06 02 7F 01
WriteLn(Hex(DER_OID('2.48.1'))); // 06 03 81 00 01
WriteLn(Hex(DER_OID('2.5.29.14'))); // 06 03 55 1D 0E
WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2'))); // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
La valeur combinée est portée dans un UInt64 à dessein. DER_OID parse les arcs en Int64, donc un second arc légal peut monter jusqu'à Int64.MaxValue, et ajouter 80 pour arc1 = 2 fait déborder un entier signé 64 bits. UInt64 porte Int64.MaxValue + 80 sans boucler, et le tampon de travail de dix octets contient les dix groupes de 7 bits dont une valeur 64 bits a besoin. Les vecteurs de test qui valent la peine d'être gardés sont ceux de part et d'autre de la frontière : 2.47 doit rester sur un octet, et 2.48 doit en devenir deux
Que garantit le parcours CMS côté lecture ?
PDFlibCMSRead garantit la structure et rien d'autre : il renvoie les octets qui se trouvent là où RFC 5652 dit qu'ils doivent être et ne vérifie ni signature, ni condensat, ni période de validité. Le parcours n'accepte que du DER, donc DERReadTLV rejette les longueurs indéfinies et les numéros de tag multi-octets, et un CMS encodé en BER venant d'un signataire non conforme rapporte zéro certificat plutôt qu'une estimation partielle. Les certificats d'attributs et les autres alternatives de CertificateChoices sont sautés parce que rien en aval ne peut les utiliser. La vérification cryptographique reste entre les mains du code qui en est propriétaire, ce qui commence par les contrôles de couverture d'octets décrits dans signature PAdES et validation du ByteRange en Delphi et se poursuit avec la classification de ce qui a changé après la signature d'un PDF
La leçon plus générale vaut pour tout format binaire : « dans le tampon » est une propriété de sûreté mémoire, « dans le parent » est une propriété de justesse, et un parseur a besoin des deux. La même réflexion sur les longueurs hostiles traverse le durcissement d'un parseur PDF Pascal contre les fichiers malveillants. L'extraction de certificats, la construction de chaîne et les API de validation à long terme présentées ici sont livrées avec losLab PDF Library for Delphi, pour Delphi, C++Builder et Lazarus