Article technique

Ne jamais remonter les octets de longueur DER (PDFium)

Les quatre octets A0 82 05 DC ouvrent un élément [0] spécifique au contexte portant 1500 octets de contenu. Lus de gauche à droite ils sont sans ambiguïté : balise A0, un octet de longueur initial 82 annonçant deux de plus, puis 05 DC. Lus de droite à gauche, en partant du premier octet de contenu, le premier octet que vous rencontrez est DC. Son bit haut est réglé et ses sept bits bas disent 92, si bien qu'un utilitaire qui lui fait confiance rapporte la balise 94 octets avant le contenu. La vraie distance est 4

Personne ne remonte à rebours exprès. Ça arrive parce que la plupart des lecteurs DER rendent en retour l'offset de contenu et la longueur de chaque élément, ce qui est exactement ce qu'il faut pour descendre dedans, tandis que copier un élément frère inchangé demande l'offset de sa balise. Soustraire depuis l'offset de contenu ressemble à un travail de deux lignes, et ça reste correct tant que chaque longueur tient dans un seul octet. La longueur d'un vrai ensemble de certificats ne tient jamais dans un

Ce qu'un horodatage de signature fait à un CMS signé

Ajouter un horodatage PAdES B-T force chaque en-tête englobant du CMS à être réencodé pendant que cinq éléments frères doivent passer octet par octet. L'attribut signature-time-stamp que la clause 5.3 de l'ETSI EN 319 122-1 définit sous l'OID 1.2.840.113549.1.9.16.2.14 vit dans les unsignedAttrs du SignerInfo de la clause 5.3 de la RFC 5652, et le jeton RFC 3161 à l'intérieur est calculé sur la valeur de signature. Alors le CMS est déjà construit et déjà signé quand le jeton arrive de la TSA. Un attribut de plus allonge le SignerInfo, ce qui allonge le SET signerInfos, puis SignedData, puis l'enveloppe [0] EXPLICIT, puis le ContentInfo extérieur

Dans PDFium Component cette séquence est trois appels exportés dans FPdfCms.pas et FPdfTsa.pas. SignPadesBytes les exécute pour vous quand TPadesSignOptions.Level vaut plB_T ou plus et que TsaUrl est réglé, et il lève EPadesCrypto quand un niveau B-T est demandé avec un TsaUrl vide au lieu d'écrire une signature B-B simple. Si vous construisez le CMS vous-même avec BuildSignedData, la chaîne ressemble à ceci

// CmsDer porte le ContentInfo renvoyé par BuildSignedData.
// SignPadesBytes exécute les mêmes trois étapes quand Options.Level >= plB_T
var
  SigValue: TBytes;
  Ts: TPadesTimestamp;
begin
  if not ExtractSignatureValue(CmsDer, SigValue) then
    raise Exception.Create('CMS carries no signature value');
  // Le messageImprint de la TSA est calculé sur les octets de la valeur de signature
  Ts:= RequestTimestamp(TsaUrl, SigValue, Nonce);
  // Réencode SignerInfo jusqu'à ContentInfo, copie les frères inchangés
  AddSignatureTimestampToCms(CmsDer, Ts.TokenDer);
end;

AddSignatureTimestampToCms fait passer cinq éléments intacts : l'OID contentType, l'INTEGER de version du SignedData, le SET digestAlgorithms, la SEQUENCE encapContentInfo et, quand présent, l'ensemble certificates [0]. Dans le CMS que BuildSignedData émet avec SHA-256, quatre de ceux-ci sont minuscules : 11 octets pour l'OID, 3 pour la version, 17 pour l'ensemble d'algorithme de condensé, 13 pour le encapContentInfo détaché. L'ensemble de certificats tient le certificat du signataire suivi de chaque certificat de chaîne que vous avez passé. Un vrai certificat X.509 court sur plusieurs centaines d'octets. C'est la seule tranche dont les octets de longueur utilisent toujours la forme longue

Ce qu'ajouter un horodatage PAdES B-T reconstruit dans un CMS : AddSignatureTimestampToCms copie contentType, version, digestAlgorithms, encapContentInfo et l'ensemble de certificats octet par octet, l'ensemble de certificats est la seule tranche assez longue pour quitter la forme courte de longueur, et chaque en-tête englobant de SignerInfo à ContentInfo est réémis
La portion signée est intacte par construction parce que le préfixe SignerInfo jusqu'à l'OCTET STRING de signature est copié verbatim, si bien qu'un validateur qui re-condense signedAttrs voit des octets identiques avant et après l'atterrissage de l'horodatage

Pourquoi les octets de longueur DER ne peuvent-ils pas être parcourus à rebours ?

Le compte d'octets de longueur est stocké dans le premier d'entre eux, et un parcours à rebours rencontre le dernier d'abord. La clause 8.1.3.4 du X.690 définit la forme courte : un octet, bit 8 à zéro, bits 7 à 1 portant une longueur de 0 à 127. La clause 8.1.3.5 définit la forme longue. Son octet initial a le bit 8 réglé, et les bits 7 à 1 comptent les octets subséquents qui portent la longueur comme entier non signé grand-boutiste. Rien ne marque un octet subséquent comme subséquent. Son bit 8 est un bit de magnitude comme les sept autres. Alors un utilitaire qui teste le bit haut de Buf[ContentOffs- 1] teste des données puis lit des données comme un compte

// L'utilitaire tentant, ne recevant que l'offset de contenu. Ne l'expédiez pas
function NaiveHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte: Integer;
begin
  P:= ContentOffs- 1;            // atterrit sur le DERNIER octet de longueur
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // significatif seulement pour le PREMIER
    Result:= P- 1
  else
    Result:= P- (LenByte and $7F)- 1;
end;

// A0 82 05 DC (1500 bytes): Buf[ContentOffs- 1]= $DC, $DC and $7F= 92
//   Result= ContentOffs- 94, la balise est à ContentOffs- 4
// A0 82 05 10 (1296 octets) : $10 a le bit 8 à zéro, lu comme forme courte
//   Result= ContentOffs- 2, ce qui pointe 05 à l'intérieur des octets de longueur

Les deux directions de panne apparaissent avec des tailles ordinaires. Un ensemble de certificats de 1500 octets finit son en-tête en DC, et l'utilitaire démarre la tranche 90 octets trop tôt. Un ensemble de 1296 octets finit en 10, l'utilitaire le prend pour une longueur de forme courte, et la tranche démarre deux octets trop tard sans aucune balise. Qu'une chaîne donnée atterrisse sur le premier ou le second cas ne dépend de rien de plus que l'octet bas de sa longueur totale

Le cas précoce peut aussi sortir du devant du tampon. Pour un CMS disposé comme BuildSignedData le dispose, chaque longueur englobante prenant deux octets, le contenu de l'ensemble de certificats commence à 60 octets : 4 pour l'en-tête ContentInfo, 11 pour l'OID, 4 chacun pour les en-têtes [0] et SignedData, puis 3, 17 et 13 pour les petits frères et 4 pour l'en-tête de l'ensemble de certificats. Soustrayez 94 de 60 et l'utilitaire renvoie -34. Son seul garde se trouve sur ContentOffs- 1, si bien que le résultat négatif part droit dans la copie

Pourquoi les octets de longueur DER ne peuvent pas être parcourus à rebours : lire A0 82 05 DC depuis la fin atterrit sur le dernier octet DC, dont le bit haut réglé donne un compte bidon de 92 et place la balise 94 octets trop tôt, pendant que A0 82 05 10 rate de l'autre façon et démarre la tranche deux octets trop tard à l'intérieur des octets de longueur
Un parcours à rebours qui garde la soustraction intermédiaire au lieu de son résultat final peut même démarrer une tranche avant le tampon, laissant le SignedData reconstruit avec cette tranche là où son ensemble de certificats devrait être

Ce que la clause 10.1 du X.690 épingle

Le DER fait de l'encodage de longueur une fonction pure de la longueur. La clause 10.1 du X.690 restreint le DER à la forme définie et exige le nombre minimal d'octets, ce qui retire les deux libertés que le BER permet : la forme indéfinie, et une longueur de forme longue bourrée d'octets zéro en tête. Sous cette règle une longueur de contenu sous 128 prend toujours exactement un octet de longueur. Toute autre longueur prend un octet initial plus exactement autant d'octets subséquents que la valeur a d'octets significatifs. Cela donne 81 80 pour 128. À 256 l'en-tête grandit en 82 01 00, et 65536 demande 83 01 00 00. Du code qui tient déjà la longueur de contenu peut calculer la taille d'en-tête sans lire un seul octet du tampon

La dérivation avant que le DER garantit : une longueur de contenu de 127 s'encode en A0 7F, 128 en A0 81 80, 255 en A0 81 FF, 256 en A0 82 01 00 et 1500 en A0 82 05 DC, si bien que la longueur d'en-tête suit de ContentLen seul et TryReadTlvAt a déjà rejeté toute forme BER non minimale
Un contenu de moins de 128 octets reste dans la forme courte, où un parcours à rebours répond correctement pour la mauvaise raison, si bien que les tests de frontière doivent traverser 127, 128, 255 et 256 octets

La dérivation n'est bonne que si l'affirmation que l'entrée est vraiment du DER tient. TDerReader.TryReadTlvAt fait respecter cette affirmation avant que le tranchage ne se produise. Il rejette un octet de longueur initial de $80 (la forme indéfinie), une longueur de forme longue dont le premier octet subséquent est zéro, un unique octet subséquent sous $80 qui aurait dû être une forme courte, et plus de quatre octets subséquents. ReadTlv est construit sur TryReadTlvAt, si bien que chaque élément que AddSignatureTimestampToCms parcourt a déjà passé ces contrôles. Une longueur bourrée à la BER n'atteint jamais l'arithmétique

À l'intérieur de AddSignatureTimestampToCms l'offset de balise de chaque frère est dérivé de cette façon depuis son offset de contenu et sa longueur de contenu. Quand le début dérivé tombe avant le commencement du tampon, signifiant que les offsets ne peuvent pas aller ensemble, la procédure lève une exception avec le message CMS: TLV header precedes the buffer start et ne produit aucun CMS du tout. Une reconstruction malformée qui s'analyse quand même est bien plus dure à retracer qu'une exception au point où les offsets ont cessé d'être d'accord

La dérivation porte une hypothèse de plus : l'identifiant prend un seul octet. Le X.690 permet des identifiants multi-octets pour les numéros de balise 31 et au-delà, et TryReadTlvAt les analyse. Le SignedData du CMS n'en utilise jamais, puisque ses balises sont SEQUENCE, SET, INTEGER et OID universels plus les [0] et [1] spécifiques au contexte, si bien que l'hypothèse tient pour chaque élément que la reconstruction d'horodatage touche

Copier des éléments frères dans votre propre code DER

Si vous écrivez du code contre TDerReader, sautez la dérivation entièrement. PeekTlv et ReadNextTlv remplissent un enregistrement TDerTlv dont l'Offset pointe déjà la balise et dont le HeaderLength est la taille mesurée de balise plus octets de longueur, et EncodedBytes renvoie l'élément de Offset à NextOffset. Ce chemin gère aussi les identifiants multi-octets, qu'aucune arithmétique de longueur ne peut recouvrer. Le ReadTlv à quatre paramètres de sortie est pratique pour descendre. Il ne rapporte que le côté contenu, toutefois, donc prenez-le quand vous avez besoin du contenu et la forme enregistrement quand vous avez besoin de l'élément entier

// Chaque enfant de SignedData comme TLV complète : balise, octets de longueur, contenu.
// TDerTlv.Offset pointe la balise, si bien qu'aucune arithmétique d'en-tête n'est impliquée
function SignedDataChildren(const CmsDer: TBytes): TDerByteArrays;
var
  Root, Ci, Explicit0, Sd: TDerReader;
  Tlv: TDerTlv;
begin
  Result:= nil;
  Root.Initialize(CmsDer);
  if not Root.ReadNextTlv(Tlv) or not Root.EnterConstructed(Tlv, Ci) then
    raise Exception.Create('ContentInfo SEQUENCE expected');
  if not Ci.SkipTlv then                            // contentType OID
    raise Exception.Create('contentType OID expected');
  if not Ci.ReadNextTlv(Tlv) or not Ci.EnterConstructed(Tlv, Explicit0) then
    raise Exception.Create('[0] EXPLICIT content expected');
  if not Explicit0.ReadNextTlv(Tlv) or not Explicit0.EnterConstructed(Tlv, Sd) then
    raise Exception.Create('SignedData SEQUENCE expected');
  while not Sd.AtEnd do
  begin
    if not Sd.ReadNextTlv(Tlv) then
      raise Exception.Create('SignedData child malformed');
    SetLength(Result, Length(Result)+ 1);
    Result[High(Result)]:= Sd.EncodedBytes(Tlv);   // p.ex. A0 82 05 DC ...
  end;
end;

Pour les cas où une autre API ne vous donne rien de plus qu'un offset de contenu et une longueur, la dérivation avant est courte. DerHeaderStart, montré avec son contrôle de frontière dans la section suivante, est votre propre code ; il reflète la règle de la clause 10.1 et renvoie -1 pour les offsets qui ne peuvent pas être cohérents, si bien que l'appelant a une valeur qu'il peut refuser plutôt qu'un octet de début d'apparence plausible

Pourquoi les certificats de test courts cachent-ils la faute ?

Un petit certificat de remplacement garde l'ensemble de certificats dans la forme courte, et le parcours à rebours est correct pour exactement ce cas. Un ensemble de 32 octets s'encode en A0 20 et un de 64 octets en A0 40. Remonter depuis le contenu atterrit sur cet unique octet de longueur, et son bit haut est à zéro parce qu'il est le premier et le seul. Bonne réponse, mauvaise raison. Un test d'horodatage construit sur des certificats si petits peut réanalyser le résultat, trouver l'attribut, rapporter du B-T, et ne jamais pousser un ensemble de certificats de taille réaliste à travers la tranche

Tout chemin de code qui dépend de comment une longueur est encodée demande une entrée de test qui traverse la frontière d'encodage. Pour le DER cela veut dire du contenu de 128 octets ou plus, ce qui force la forme longue, et de 256 octets ou plus, ce qui force un second octet subséquent. Le contrôle ci-dessous compare DerHeaderStart contre l'en-tête que le lecteur a mesuré en analysant, aux deux frontières et au cas de 1500 octets de l'ouverture. Un SET OF non trié dans signedAttrs se glisse à travers les allers-retours de même origine pour la même raison structurelle : le test ne nourrit que des entrées sur lesquelles le code faux et le code juste sont d'accord

// Votre propre utilitaire pour les API qui ne rapportent qu'offset de contenu et longueur.
// Suppose un identifiant à un octet, vrai pour toute balise du CMS SignedData
function DerHeaderStart(ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  LengthOctets:= 1;                  // forme courte, X.690 8.1.3.4
  if ContentLen>= 128 then
  begin
    Remaining:= ContentLen;          // forme longue, X.690 8.1.3.5
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // un par octet significatif
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= -1;                     // offsets incohérents : laissez l'appelant lever
end;

// Contrôle de frontière, écrit comme votre propre code de test : l'offset de balise dérivé
// doit égaler l'offset que TDerReader a mesuré en analysant l'en-tête
procedure CheckHeaderDerivation;
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
var
  W: TDerWriter;
  R: TDerReader;
  Tlv: TDerTlv;
  Content: TBytes;
  I: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      SetLength(Content, Lens[I]);
      R.Initialize(W.Wrap($A0, Content));   // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      if not R.PeekTlv(Tlv) then
        raise Exception.CreateFmt('unreadable TLV for %d bytes', [Lens[I]]);
      if DerHeaderStart(Tlv.ContentOffset, Tlv.ContentLength)<> Tlv.Offset then
        raise Exception.CreateFmt('header start wrong for %d bytes', [Lens[I]]);
    end;
  finally
    W.Free;
  end;
end;

La même frontière est atteignable de bout en bout à travers les fonctions exportées. Donnez à BuildSignedData des certificats de chaîne dont la taille combinée met l'ensemble de certificats juste sous et juste au-dessus de 128 et 256 octets, lancez AddSignatureTimestampToCms sur chaque résultat, puis parcourez la sortie avec SignedDataChildren et comparez l'élément ensemble de certificats octet par octet avec celui du CMS avant que l'horodatage ne soit ajouté

Ce que AddSignatureTimestampToCms accepte

AddSignatureTimestampToCms est écrit pour le CMS que BuildSignedData émet : un SignerInfo, un ensemble certificates [0] facultatif, pas de crls [1] et pas encore de unsignedAttrs. Nourrissez-le avec un SET signerInfos portant plus d'un SignerInfo et il lève au lieu de garder le premier signataire et d'abandonner les autres. Un CMS qui porte un ensemble crls [1] s'arrête à l'exception CMS: signerInfos SET expected, parce que l'élément après les certificats n'est pas le SET que le parcours cherche. Les deux refus arrivent avant que quoi que ce soit ne soit réécrit dans CmsDer

Le nouvel unsignedAttrs tient exactement un attribut, si bien que la règle d'ordre du SET OF de la clause 11.6 du X.690 est satisfaite sans tri. La partie signée du SignerInfo, de sa version jusqu'à l'OCTET STRING de signature, est copiée comme un seul bloc, ce qui veut dire qu'un validateur re-condensant signedAttrs voit les mêmes octets avant et après l'ajout du jeton. Quand un validateur refuse quand même le document, les causes siègent d'habitude ailleurs et sont couvertes dans cette liste de contrôle des raisons de rejet PAdES. Ce que le jeton vous achète dans le temps, et comment B-LT et B-LTA s'appuient dessus, est dans la démonstration des horodatages RFC 3161 et du DSS

Le lecteur et le rédacteur DER dans FPdfAsn1.pas, le constructeur CMS et l'étape d'horodatage dans FPdfCms.pas sont tous du source Pascal qui vient avec le PDFium Component for Delphi. Quand un SignedData reconstruit refuse de s'analyser dans un validateur, vous pouvez marcher pas à pas dans le code qui a coupé chaque élément et le vérifier contre la clause X.690 qu'il cite