Technischer Artikel

DER-Längenoktets nie rückwärts durchlaufen: PDFium Component

PDFium Component findet den Anfang einer verschachtelten CMS-Struktur über ihre Content-Länge, niemals, indem es über die Längenoktets rückwärts läuft, denn das Byte unmittelbar vor dem Content ist das letzte Längenoktett und verrät nichts darüber, wie viele ihm vorausgehen. CmsHeaderStart in FPdfCms.pas leitet die Header-Länge stattdessen aus ContentLen her, was DER exakt möglich macht, und genau das bewahrt AddSignatureTimestampToCms davor, jedes CMS zu korrumpieren, dessen Zertifikatmenge länger als 127 Byte ist

Das Szenario ist das PAdES-B-T-Upgrade. Ein Signaturzeitstempel-Attribut – jenes, das ETSI EN 319 122-1 in Abschnitt 5.3 unter der OID 1.2.840.113549.1.9.16.2.14 definiert – muss in die unsignedAttrs des SignerInfo, wie RFC 5652 Abschnitt 5.3 ihn beschreibt, und per Definition kann es erst hinzugefügt werden, nachdem der Signaturwert existiert, denn das Timestamp-Token wird über diesen Wert berechnet. Das CMS ist also bereits gebaut und bereits signiert, wenn das Token ankommt. Ein einziges Attribut hinzuzufügen ändert die Länge des SignerInfo, was die Länge des signerInfos-SET ändert, dann die von SignedData, dann die des [0]-EXPLICIT-Wrappers, dann die des äußeren ContentInfo. Jeder umschließende Header muss neu ausgegeben werden, und alles, was nicht auf diesem Pfad liegt, muss Byte für Byte hinübergetragen werden. Der B-LT-und-B-LTA-Durchgang behandelt, was das Token einbringt; dieser Artikel handelt von den vier Bytes vor der Zertifikatmenge, die der Neuaufbau immer wieder falsch getroffen hat

Warum braucht das Hinzufügen eines Timestamps das Tag-Offset eines Geschwisters?

Weil der Neuaufbau vier Geschwister des signerInfos-SET wortgetreu wiederverwendet und der Reader meldet, wo deren Content liegt, nicht, wo ihr Tag sitzt. TDerReader.ReadTlv liefert das Tag-Byte, das Content-Offset, die Content-Länge und das Offset des nächsten TLV zurück. Das ist die richtige Oberfläche, um in eine Struktur hinabzusteigen, aber um ein ganzes Element zu kopieren, braucht man das Oktett, an dem sein Tag sitzt, und das Einzige, was ein Aufrufer hält, ist ContentOffs. CmsSliceTlv existiert, um diese Lücke zu schließen: Bei gegebenem Content-Offset und -Länge liefert es Tag, Längenoktets und Content als einen Puffer, und AddSignatureTimestampToCms ruft es für die contentType-OID, den version-INTEGER, das digestAlgorithms-SET, die encapContentInfo-SEQUENCE und, falls vorhanden, das certificates [0]-Set auf

// Innerhalb von AddSignatureTimestampToCms: hinabsteigen, Geschwister wortgetreu schneiden
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;
// optionales certificates [0]
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);   // Tag + Längenoktets + Content
  R.Position:= CN;
end;

Von diesen fünf Slices sind vier winzig: eine elf Byte lange OID, ein drei Byte langer INTEGER, ein siebzehn Byte langes Digest-Algorithmus-Set, ein dreizehn Byte langer detached encapContentInfo. Das Zertifikatset ist dasjenige, das das Signaturzertifikat und seine Kette trägt, und ein echtes X.509-Zertifikat umfasst mindestens einige hundert Byte. Das Zertifikatset ist also der einzige Slice, dessen Längenoktets überhaupt je in der Langform sind, und es ist der Slice, den der alte Helper nicht lokalisieren konnte

Was das Hinzufügen eines PAdES-B-T-Timestamps in einem CMS neu aufbaut: CmsSliceTlv kopiert contentType, version, digestAlgorithms, encapContentInfo und das Zertifikatset Byte für Byte, das Zertifikatset ist der einzige Slice, der lang genug ist, um die kurze Längenform zu verlassen, und jeder umschließende Header von SignerInfo bis ContentInfo wird neu ausgegeben
Der signierte Teil bleibt konstruktionsbedingt unangetastet, weil das SignerInfo-Präfix bis zum Signatur-OCTET STRING wortgetreu kopiert wird, ein Validator, der signedAttrs neu digested, sieht also vor und nach dem Landen des Timestamps identische Bytes

Warum kann man DER-Längenoktets nicht rückwärts durchlaufen?

Weil die Anzahl der Längenoktets im ersten von ihnen steht und man beim Lesen vom Content rückwärts dem letzten zuerst begegnet. X.690 Abschnitt 8.1.3.4 definiert die Kurzform: ein Oktett, Bit 8 gelöscht, Bits 7 bis 1 halten eine Länge von 0 bis 127. Abschnitt 8.1.3.5 definiert die Langform: ein Anfangsoktett mit gesetztem Bit 8, dessen Bits 7 bis 1 die Anzahl der Folgoktets angeben, gefolgt von ebendiesen Oktets, die die Länge als vorzeichenlose Big-Endian-Ganzzahl tragen. Nichts in der Regel markiert ein Folgoktett als Folgoktett. Sein Bit 8 ist ein Stellenwert-Bit wie jedes andere, also prüft ein rückwärts laufender Gang, der das oberste Bit von Buf[ContentOffs- 1] testet, ein Datenbit und liest dann dessen untere sieben Bits als Anzahl

// Der alte Helper, mit nur dem Content-Offset
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // landet auf dem LETZTEN Längenoktett
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // nur für das ERSTE aussagekräftig
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Header eines 1500-Byte-Zertifikatsets:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> Bit 8 gesetzt, $DC and $7F= 92
//   Result= ContentOffs- 94     (das Tag liegt bei ContentOffs- 4)

Nehmen wir den Header eines Zertifikatsets mit 1500 Byte an Zertifikaten, A0 82 05 DC. Der Gang landet auf DC, sieht ein gesetztes oberstes Bit, extrahiert aus den unteren sieben Bits die 92 und meldet das Tag 94 Bytes vor dem Content, obwohl es 4 Bytes davor liegt. In einem von BuildSignedData gebauten SignedData sitzt der Content des Zertifikatsets nur ein paar Dutzend Bytes im CMS, also war das berechnete Offset nicht bloß zu früh, sondern negativ, und der alte Code bewachte ContentOffs- 1 gegen den Fall unter null, nicht sein Endergebnis. CmsSliceTlv schnitt dann einen rund neunzig Bytes längeren Slice als das Element, beginnend vor dem Puffer, und das neu gebaute SignedData trug diesen Slice, wo sein Zertifikatset hätte stehen sollen. Eine Länge, die über drei Oktette kodiert ist und deren letztes Oktett zufällig unter $80 fällt, etwa A0 82 05 10, scheiterte in die andere Richtung: Der Gang hielt sie für ein Kurzform-Oktett und begann den Slice bei 05, zwei Bytes zu spät und mitten in den Längenoktets, ganz ohne Tag. Das Ergebnis war in beiden Fällen falsch, nur die Richtung variierte

Warum DER-Längenoktets nicht rückwärts durchlaufen werden können: Liest man A0 82 05 DC vom Ende, landet man auf dem letzten Oktett DC, dessen gesetztes oberstes Bit eine falsche Anzahl von 92 liefert und das Tag 94 Oktette zu früh platziert, während A0 82 05 10 in die andere Richtung scheitert und den Slice zwei Oktette zu spät innerhalb der Längenoktets beginnt
Das alte CmsHeaderStart bewachte die Zwischensubtraktion statt ihres Endergebnisses, ein Slice konnte also sogar vor dem Puffer beginnen, und das neu gebaute SignedData trug diesen Slice, wo sein Zertifikatset hingehörte

Was garantiert DER, das die Vorwärtsableitung exakt macht?

DER garantiert, dass die Längenkodierung eine reine Funktion der Länge ist. X.690 Abschnitt 10.1 beschränkt DER auf die bestimmte Form und verlangt die minimale Anzahl an Oktets, womit die zwei Freiheiten wegfallen, die BER erlaubt: die unbestimmte Form und das Auffüllen einer Langform-Länge mit führenden Null-Oktets. Unter dieser Regel hat eine Content-Länge unter 128 genau ein Längenoktett, und jede andere Länge hat ein Anfangsoktett plus genau so viele Folgoktets, wie die Länge signifikante Bytes braucht. Der Aufrufer von CmsHeaderStart hält ContentLen ohnehin schon, weil ReadTlv es gerade zurückgeliefert hat, also lässt sich die Header-Länge berechnen, ohne ein einziges Byte des Puffers anzusehen

Die Vorwärtsableitung, die DER garantiert: Eine Content-Länge von 127 kodiert als A0 7F, 128 als A0 81 80, 255 als A0 81 FF, 256 als A0 82 01 00 und 1500 als A0 82 05 DC, die Header-Länge folgt also allein aus ContentLen, und TryReadTlvAt hat jede nicht-minimale BER-Form bereits verworfen
Aus 32-Byte- und 64-Byte-Zertifikaten gebaute Fixtures blieben in der Kurzform, in der der Rückwärtsgang aus dem falschen Grund richtig antwortet, deshalb kreuzt die Boundary-Suite jetzt 127, 128, 255 und 256 Byte
// Der ausgelieferte Helper: Header aus der Content-Länge ableiten.
// Unter X.690 10.1 sind die Längenoktets eine Funktion von ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // Kurzform, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // das Anfangsoktett, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // eines pro signifikantem Byte
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Zwei Details machen das sicher statt bloß plausibel. Erstens wird die Annahme, der Input sei DER, flussaufwärts erzwungen: TDerReader.TryReadTlvAt, auf dem ReadTlv aufbaut, verwirft die unbestimmte Form, verwirft eine Langform-Länge, deren erstes Folgoktett null ist, und verwirft ein einzelnes Folgoktett unter $80. Ein TLV, der CmsSliceTlv erreicht, hat diese Prüfungen bereits passiert, also kann eine BER-artige nicht-minimale Länge die Ableitung nicht erreichen und sie zum Lügen bringen. Zweitens bewacht der Fallback für ein negatives Ergebnis jetzt die echte Antwort, kein Zwischenergebnis. Erwähnenswert ist, dass der Reader das Tag-Offset die ganze Zeit kannte: TDerTlv trägt sowohl Offset als auch HeaderLength, und nur die ReadTlv-Oberfläche mit vier Out-Parametern verwirft sie. Sie zurückzugeben wäre die sauberere langfristige Schnittstelle; der ausgelieferte Fix hält diese Oberfläche intakt und macht den Helper auf eigenen Füßen korrekt

Warum liefen die Timestamp-Tests mit dem Bug auf Grün?

Weil jedes Fixture-Zertifikat kurz genug für die Kurzform war, und der Rückwärtsgang ist ausgerechnet für diesen Fall korrekt. Tests.PadesTimestamp.pas baut sein Signaturzertifikat in einem Test mit SetLength(SignerCertDer, 32) und in einem anderen mit 64, gefüllt mit einem Byte-Anstieg. Ein 32-Byte-Zertifikatset kodiert als A0 20, ein 64-Byte-Set als A0 40, jeweils ein einzelnes Längenoktett. Der Rückwärtsgang vom Content landet auf ebendiesem einen Oktett, sein oberstes Bit ist gelöscht, weil es das erste und einzige Längenoktett ist, und der Helper antwortet aus dem falschen Grund richtig. Die Suite mit 1414 Fällen war grün, das CMS mit Timestamp ließ sich parsen, der Stage-1-Validator meldete B-T, und jede dieser Prüfungen lief gegen ein Zertifikatset, das kein echtes Dokument je enthalten hat

Die allgemeine Regel ist der nützliche Teil. Wann immer ein Codepfad davon abhängt, wie eine Länge kodiert ist, muss das Fixture die Kodierungsgrenze überschreiten, und für DER heißt das Content länger als 127 Byte, was die Langform erzwingt, und idealerweise auch länger als 255 Byte, was ein zweites Folgoktett erzwingt. Dieselbe Disziplin gilt für den anderen Fall aus jener Review, in dem die Selbstverifizierung eine DER-Abweichung nicht sehen konnte: Das unsortierte SET OF in signedAttrs blieb einem Round Trip aus eigener Herkunft aus strukturell identischem Grund unsichtbar, der Test übte nur Inputs, bei denen falscher und richtiger Code übereinstimmen. Die Skizze unten ruft den Slice-Helper direkt auf, was heißt, ihn aus FPdfCms.pas für den Test-Build zu exportieren; dieselbe Grenze erreicht man über die öffentliche Oberfläche, indem man BuildSignedData je ein Kettenzertifikat jeder Größe gibt und das Ergebnis mit Timestamp neu parst

// Grenze festnageln: Ein Slice durch einen Langform-Header muss am Tag beginnen
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;
      // Content beginnt unmittelbar nach dem Header; der Slice muss das ganze TLV sein
      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;

Wo der Neuaufbau weiterhin seine Grenzen zieht

AddSignatureTimestampToCms ist für das CMS geschrieben, das BuildSignedData ausgibt, und seine Grenzen folgen daraus. Der Durchgang erwartet einen einzigen SignerInfo und gibt auch nur diesen neu aus, ein fremdes Multi-Signer-CMS käme also mit einem Signaturer zurück; er erkennt ein optionales certificates [0]-Set, aber kein crls [1]-Set, und ein CMS mit einem solchen scheitert laut mit der signerInfos SET expected-Exception, statt stillschweigend falsch zu schneiden. Die neuen unsignedAttrs halten ein einziges Attribut, also ist die SET-OF-Ordnungsregel aus X.690 Abschnitt 11.6 trivial erfüllt und braucht kein Sortieren. Und der signierte Teil bleibt konstruktionsbedingt unangetastet: Das SignerInfo-Präfix bis zum Signatur-OCTET STRING wird wortgetreu kopiert, weshalb ein Validator, der signedAttrs neu digested, vor und nach dem Hinzufügen des Timestamps dieselben Bytes sieht. Wenn ein Validator das Dokument trotzdem ablehnt, liegen die Ursachen meist woanders und verdienen eine eigene Checkliste

DER-Reader, Writer, CMS-Builder und diese Timestamp-Injektion werden alle als Pascal-Quelle mit dem PDFium Delphi component ausgeliefert, und ein Bug dieser Form ist das Argument dafür: Wenn ein neu gebautes SignedData rund neunzig Bytes zu lang herauskommt, will man den Helper lesen, der den Slice geschnitten hat, und den Abschnitt von X.690, den er falsch gelesen hat – nicht einen Stacktrace aus einer Black Box