Article technique

Ne jamais remonter les octets de longueur DER (PDFium)

PDFium Component localise le début d'une structure CMS imbriquée à partir de la longueur de son contenu, jamais en remontant à rebours sur les octets de longueur, parce que l'octet juste avant le contenu est le dernier octet de longueur et ne dit rien du nombre de ceux qui le précèdent. CmsHeaderStart dans FPdfCms.pas dérive au contraire la longueur d'en-tête de ContentLen, ce que DER rend exact, et c'est ce qui empêche AddSignatureTimestampToCms de corrompre tout CMS dont l'ensemble de certificats dépasse 127 octets

Le cadre est la mise à niveau PAdES B-T. Un attribut d'horodatage de signature, celui que la clause 5.3 d'ETSI EN 319 122-1 définit sous l'OID 1.2.840.113549.1.9.16.2.14, doit atterrir dans les unsignedAttrs du SignerInfo décrit dans la clause 5.3 de la RFC 5652, et par définition il ne peut être ajouté qu'après l'existence de la valeur de signature, puisque le jeton d'horodatage est calculé sur cette valeur. Donc le CMS est déjà construit et déjà signé quand le jeton arrive. Ajouter un attribut change la longueur du SignerInfo, ce qui change la longueur du SET signerInfos, puis celle de SignedData, puis celle du conteneur EXPLICIT [0], puis celle du ContentInfo extérieur. Chaque en-tête englobant doit être réémis, et tout ce qui n'est pas sur ce chemin doit être transporté octet pour octet. La visite guidée de B-LT et B-LTA traite de ce que le jeton vous apporte ; cet article porte sur les quatre octets devant l'ensemble de certificats que la reconstruction n'arrêtait pas de rater

Pourquoi ajouter un horodatage a-t-il besoin du décalage de balise d'un voisin ?

Parce que la reconstruction réutilise tel quel quatre voisins du SET signerInfos, et que le lecteur indique où se trouve leur contenu, pas où se trouve leur balise. TDerReader.ReadTlv renvoie l'octet de balise, le décalage du contenu, la longueur du contenu et le décalage du TLV suivant. C'est la bonne surface pour descendre dans une structure, mais pour recopier un élément entier il vous faut l'octet où siège sa balise, et le seul élément que détient un appelant est ContentOffs. CmsSliceTlv existe pour combler cet écart : étant donné un décalage et une longueur de contenu, il renvoie la balise, les octets de longueur et le contenu comme un seul tampon, et AddSignatureTimestampToCms l'appelle pour l'OID contentType, l'INTEGER version, le SET digestAlgorithms, la SEQUENCE encapContentInfo et, quand il est présent, l'ensemble certificates [0]

// Dans AddSignatureTimestampToCms : descendre, découper les voisins tels quels
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
  raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// certificats [0] facultatifs
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
  if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
    raise Exception.Create('CMS: certificates [0] malformed');
  SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL);   // balise + octets de longueur + contenu
  R.Position:= CN;
end;

Sur ces cinq tranches, quatre sont minuscules : un OID de onze octets, un INTEGER de trois octets, un ensemble d'algorithmes de hachage de dix-sept octets, un encapContentInfo détaché de treize octets. L'ensemble de certificats est celui qui porte le certificat du signataire et sa chaîne, et un vrai certificat X.509 fait plusieurs centaines d'octets au strict minimum. L'ensemble de certificats est donc la seule tranche dont les octets de longueur sont un jour sous forme longue, et c'est la tranche que l'ancien utilitaire ne savait pas localiser

Ce que l'ajout d'un horodatage PAdES B-T reconstruit dans un CMS : CmsSliceTlv recopie octet pour octet contentType, version, digestAlgorithms, encapContentInfo et l'ensemble de certificats, l'ensemble de certificats est la seule tranche assez longue pour quitter la forme courte de longueur, et chaque en-tête englobant depuis SignerInfo jusqu'à ContentInfo est réémis
La partie signée n'est pas touchée par construction parce que le préfixe de SignerInfo jusqu'à l'OCTET STRING de signature est recopié tel quel, donc un validateur qui recalcule l'empreinte de signedAttrs voit des octets identiques avant et après l'arrivée de l'horodatage

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

Parce que le nombre d'octets de longueur est stocké dans le premier d'entre eux, et qu'en lisant depuis le contenu vers l'arrière on rencontre le dernier en premier. La clause 8.1.3.4 de 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 : un octet initial avec le bit 8 à un dont les bits 7 à 1 donnent le nombre d'octets suivants, puis ces octets portant la longueur comme entier non signé en gros-boutiste. Rien dans la règle ne marque un octet suivant comme suivant. Son bit 8 est un bit de grandeur comme un autre, donc une remontée à rebours qui teste le bit de poids fort de Buf[ContentOffs- 1] teste un bit de donnée puis lit ses sept bits de poids faible comme un compte

// L'ancien utilitaire, à qui on ne donne que le décalage du contenu
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // tombe sur le DERNIER octet de longueur
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // n'a de sens que pour le PREMIER
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// En-tête d'un ensemble de certificats de 1500 octets :  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 set, $DC and $7F= 92
//   Result= ContentOffs- 94     (la balise est à ContentOffs- 4)

Prenez l'en-tête d'un ensemble de certificats contenant 1500 octets de certificats, A0 82 05 DC. La remontée tombe sur DC, voit un bit de poids fort à un, extrait 92 des sept bits faibles et annonce la balise 94 octets avant le contenu, alors qu'elle est 4 octets avant lui. Dans un SignedData construit par BuildSignedData, le contenu de l'ensemble de certificats ne se trouve qu'à quelques dizaines d'octets du début du CMS, donc le décalage calculé n'était pas simplement trop précoce mais négatif, et l'ancien code protégeait ContentOffs- 1 contre une descente sous zéro, pas son résultat final. CmsSliceTlv prenait alors une tranche plus longue de quatre-vingt-dix et quelques octets que l'élément, commençant avant le tampon, et le SignedData reconstruit portait cette tranche là où aurait dû se trouver son ensemble de certificats. Une longueur de trois octets dont le dernier tombait sous $80, par exemple A0 82 05 10, échouait dans l'autre sens : la remontée la prenait pour un octet de forme courte et commençait la tranche à 05, deux octets trop tard et à l'intérieur des octets de longueur, sans aucune balise. Le résultat était faux dans les deux cas, seule la direction variait

Pourquoi les octets de longueur DER ne peuvent pas être remontés à rebours : lire A0 82 05 DC depuis la fin tombe sur le dernier octet DC, dont le bit de poids fort à un produit un compte bidon de 92 et place la balise 94 octets trop tôt, tandis que A0 82 05 10 échoue dans l'autre sens et commence la tranche deux octets trop tard, à l'intérieur des octets de longueur
L'ancien CmsHeaderStart protégeait la soustraction intermédiaire au lieu de son résultat final, donc une tranche pouvait même commencer avant le tampon, et le SignedData reconstruit portait cette tranche là où appartenait son ensemble de certificats

Que garantit DER qui rend la dérivation vers l'avant exacte ?

DER garantit que l'encodage de la longueur est une pure fonction de la longueur. La clause 10.1 de X.690 restreint DER à la forme définie et exige le nombre minimal d'octets, ce qui supprime les deux libertés que BER autorise : la forme indéfinie, et le remplissage d'une longueur en forme longue par des octets de tête nuls. Sous cette règle, une longueur de contenu inférieure à 128 a exactement un octet de longueur, et toute autre longueur a un octet initial plus exactement autant d'octets suivants que la longueur compte d'octets significatifs. L'appelant de CmsHeaderStart détient déjà ContentLen, parce que ReadTlv vient de le renvoyer, donc la longueur d'en-tête est calculable sans regarder un seul octet du tampon

La dérivation vers l'avant que DER garantit : une longueur de contenu de 127 s'encode A0 7F, 128 s'encode A0 81 80, 255 s'encode A0 81 FF, 256 s'encode A0 82 01 00 et 1500 s'encode A0 82 05 DC, donc la longueur d'en-tête découle de ContentLen seul et TryReadTlvAt a déjà rejeté toute forme BER non minimale
Les fixtures construites à partir de certificats de 32 et 64 octets restaient dans la forme courte, où la remontée à rebours répond juste pour la mauvaise raison, et c'est pourquoi la suite de tests de frontière franchit désormais 127, 128, 255 et 256 octets
// L'utilitaire livré : dériver l'en-tête de la longueur du contenu.
// Au titre de X.690 10.1, les octets de longueur sont fonction de ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // forme courte, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // l'octet initial, X.690 8.1.3.5
    Remaining:= ContentLen;
    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:= ContentOffs;
end;

Deux détails rendent cela sûr et pas simplement plausible. D'abord, l'hypothèse que l'entrée est du DER est imposée en amont : TDerReader.TryReadTlvAt, sur lequel ReadTlv est bâti, rejette la forme indéfinie, rejette une longueur en forme longue dont le premier octet suivant est nul, et rejette un unique octet suivant inférieur à $80. Un TLV qui atteint CmsSliceTlv a déjà passé ces vérifications, donc une longueur non minimale de style BER ne peut pas atteindre la dérivation et la faire mentir. Ensuite, le repli en cas de résultat négatif protège désormais la vraie réponse, pas un intermédiaire. Il vaut la peine de dire que le lecteur connaissait le décalage de balise depuis le début : TDerTlv porte à la fois Offset et HeaderLength, et seule la surface ReadTlv à quatre paramètres de sortie les abandonne. Les renvoyer serait l'interface propre à long terme ; le correctif livré garde cette surface intacte et rend l'utilitaire correct en lui-même

Pourquoi les tests d'horodatage passaient-ils avec le bug en place ?

Parce que chaque certificat de fixture était assez court pour utiliser la forme courte, et que la remontée à rebours est correcte exactement dans ce cas. Tests.PadesTimestamp.pas construit son certificat de signataire avec SetLength(SignerCertDer, 32) dans un test et 64 dans un autre, rempli par une rampe d'octets. Un ensemble de certificats de 32 octets s'encode A0 20 et un de 64 octets A0 40, un seul octet de longueur chacun. Remonter depuis le contenu tombe sur cet unique octet, son bit de poids fort est à zéro parce que c'est le premier et seul octet de longueur, et l'utilitaire répond juste pour la mauvaise raison. La suite de 1414 cas était verte, le CMS horodaté s'analysait, le validateur d'étape 1 signalait B-T, et chacune de ces vérifications tournait sur un ensemble de certificats qu'aucun vrai document n'a jamais contenu

La règle générale est la partie utile. Dès qu'un chemin de code dépend de la façon dont une longueur est encodée, la fixture doit franchir la frontière d'encodage, et pour DER cela veut dire un contenu de plus de 127 octets, ce qui force la forme longue, et idéalement de plus de 255 octets aussi, ce qui force un second octet suivant. La même discipline s'applique à l'autre cas de cette revue où l'auto-vérification ne pouvait pas voir une déviation DER : le SET OF non trié dans signedAttrs était invisible pour un aller-retour de même origine pour une raison structurellement identique, le test n'exerçait que des entrées sur lesquelles le mauvais code et le bon code sont d'accord. L'esquisse ci-dessous appelle directement l'utilitaire de découpage, ce qui suppose de l'exporter depuis FPdfCms.pas pour la compilation de test ; la même frontière est atteignable par la surface publique en donnant à BuildSignedData un certificat de chaîne de chaque taille et en réanalysant le résultat horodaté

// Verrouiller la frontière : une tranche via un en-tête en forme longue doit partir de la balise
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);

procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
  W: TDerWriter;
  Content, Tlv: TBytes;
  I, Len: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      Len:= Lens[I];
      SetLength(Content, Len);
      Tlv:= W.Wrap($A0, Content);            // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      // le contenu commence juste après l'en-tête ; la tranche doit être tout le TLV
      Assert.AreEqual(Length(Tlv),
        Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
        'slice through header of a '+ IntToStr(Len)+ '-byte content');
    end;
  finally
    W.Free;
  end;
end;

Où la reconstruction trace encore ses limites

AddSignatureTimestampToCms est écrit pour le CMS que BuildSignedData émet, et ses limites en découlent. Le parcours attend un seul SignerInfo et ne réémet que celui-là, donc un CMS étranger à plusieurs signataires reviendrait avec un seul signataire ; il reconnaît un ensemble certificates [0] facultatif mais pas un ensemble crls [1], et un CMS qui en porte un échoue bruyamment avec l'exception signerInfos SET expected plutôt que de découper silencieusement de travers. Le nouvel unsignedAttrs contient un seul attribut, donc la règle d'ordre SET OF de la clause 11.6 de X.690 est trivialement satisfaite et n'a besoin d'aucun tri. Et la partie signée n'est pas touchée par construction : le préfixe de SignerInfo jusqu'à l'OCTET STRING de signature est recopié tel quel, et c'est pourquoi un validateur qui recalcule l'empreinte de signedAttrs voit les mêmes octets avant et après l'ajout de l'horodatage. Quand un validateur refuse quand même le document, les causes sont généralement ailleurs et méritent leur propre liste de contrôle

Le lecteur DER, l'écrivain, le constructeur CMS et cette injection d'horodatage sont tous livrés en source Pascal avec le composant PDFium pour Delphi, et un bug de cette forme est l'argument en faveur de cela : quand un SignedData reconstruit ressort quatre-vingt-dix octets trop long, vous voulez lire l'utilitaire qui a découpé la tranche et la clause de X.690 qu'il a mal lue, pas une pile d'appels venue d'une boîte noire