Technischer Artikel

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

Die vier Bytes A0 82 05 DC öffnen ein kontextspezifisches [0]-Element mit 1500 Bytes Inhalt. Von links nach rechts gelesen sind sie eindeutig: Tag A0, ein Anfangs-Längenoktett 82, das zwei weitere ankündigt, dann 05 DC. Von rechts nach links gelesen, beginnend beim ersten Inhaltsbyte, ist das erste Oktett, dem man begegnet, DC. Sein oberstes Bit ist gesetzt, seine unteren sieben Bits sagen 92; ein Helper, der ihm vertraut, meldet das Tag also 94 Bytes vor dem Inhalt. Der echte Abstand ist 4

Niemand läuft absichtlich rückwärts. Es passiert, weil die meisten DER-Reader den Content-Offset und die Länge jedes Elements zurückgeben, genau das, was man braucht, um in es hinabzusteigen, während das unveränderte Kopieren eines Geschwister-Elements den Offset seines Tags braucht. Vom Content-Offset zu subtrahieren sieht nach einer Zwei-Zeilen-Aufgabe aus, und sie bleibt korrekt, solange jede Länge in ein einzelnes Oktett passt. Die Länge eines echten Zertifikatsets passt nie in eines

Was ein Signaturzeitstempel mit einem signierten CMS macht

Ein PAdES-B-T-Zeitstempel zu ergänzen, erzwingt, dass jeder umschließende Header des CMS neu kodiert wird, während fünf Geschwister-Elemente Byte für Byte hinüberkommen müssen. Das Signaturzeitstempel-Attribut, das ETSI EN 319 122-1 Abschnitt 5.3 unter der OID 1.2.840.113549.1.9.16.2.14 definiert, lebt in den unsignedAttrs des SignerInfo aus RFC 5652 Abschnitt 5.3, und das RFC-3161-Token darin wird über den Signaturwert berechnet. Das CMS ist also bereits gebaut und bereits signiert, wenn das Token von der TSA ankommt. Ein zusätzliches Attribut verlängert das SignerInfo, was den signerInfos-SET verlängert, dann SignedData, dann den [0]-EXPLICIT-Wrapper, dann das äußere ContentInfo

In PDFium Component ist diese Sequenz drei exportierte Aufrufe in FPdfCms.pas und FPdfTsa.pas. SignPadesBytes führt sie für Sie aus, wenn TPadesSignOptions.Level auf plB_T oder höher steht und TsaUrl gesetzt ist, und wirft eine EPadesCrypto, wenn eine B-T-Stufe mit leerem TsaUrl verlangt wird, statt eine schlichte B-B-Signatur zu schreiben. Bauen Sie das CMS selbst mit BuildSignedData, sieht die Kette so aus

// CmsDer hält das ContentInfo, das BuildSignedData zurückgegeben hat.
// SignPadesBytes führt dieselben drei Schritte aus, wenn Options.Level >= plB_T
var
  SigValue: TBytes;
  Ts: TPadesTimestamp;
begin
  if not ExtractSignatureValue(CmsDer, SigValue) then
    raise Exception.Create('CMS carries no signature value');
  // Das TSA-messageImprint wird über die Signaturwert-Oktets berechnet
  Ts:= RequestTimestamp(TsaUrl, SigValue, Nonce);
  // Kodiert SignerInfo bis ContentInfo neu, kopiert die Geschwister unverändert
  AddSignatureTimestampToCms(CmsDer, Ts.TokenDer);
end;

AddSignatureTimestampToCms trägt fünf Elemente unangetastet hinüber: die contentType-OID, das SignedData-Version-INTEGER, den digestAlgorithms-SET, die encapContentInfo-SEQUENCE und, falls vorhanden, das certificates [0]-Set. In dem CMS, das BuildSignedData mit SHA-256 emittiert, sind vier davon winzig: 11 Bytes für die OID, 3 für die Version, 17 für den Digest-Algorithmus-Set, 13 für das abgetrennte encapContentInfo. Das Zertifikatset hält das Signer-Zertifikat gefolgt von jedem Kettenzertifikat, das Sie übergeben haben. Ein echtes X.509-Zertifikat läuft auf mehrere Hundert Bytes hinaus. Dies ist der eine Slice, dessen Längenoktets immer die Langform nutzen

Was das Ergänzen eines PAdES-B-T-Timestamps in einem CMS neu aufbaut: AddSignatureTimestampToCms 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?

Die Anzahl der Längenoktets steht im ersten von ihnen, und ein rückwärts laufender Gang begegnet dem letzten zuerst. 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. Ihr Anfangsoktett hat Bit 8 gesetzt, und Bits 7 bis 1 zählen die Folgoktets, die die Länge als vorzeichenlose Big-Endian-Ganzzahl halten. Nichts markiert ein Folgoktett als Folgoktett. Sein Bit 8 ist ein Stellenwert-Bit wie die anderen sieben. Ein Helper, der das oberste Bit von Buf[ContentOffs- 1] testet, prüft also Daten und liest dann Daten als Anzahl

// Der verführende Helper, mit nur dem Content-Offset. Nicht ausliefern
function NaiveHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte: 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
    Result:= P- (LenByte and $7F)- 1;
end;

// A0 82 05 DC (1500 Bytes): Buf[ContentOffs- 1]= $DC, $DC and $7F= 92
// Result= ContentOffs- 94, das Tag liegt bei ContentOffs- 4
// A0 82 05 10 (1296 Bytes): $10 hat Bit 8 gelöscht, wird als Kurzform gelesen
// Result= ContentOffs- 2, was auf 05 innerhalb der Längenoktets zeigt

Beide Fehlerrichtungen zeigen sich mit gewöhnlichen Größen. Ein 1500-Byte-Zertifikatset endet mit seinem Header in DC, und der Helper startet den Slice 90 Bytes zu früh. Ein 1296-Byte-Set endet in 10; der Helper hält es für eine Kurzform-Länge, und der Slice startet zwei Bytes zu spät, ganz ohne Tag. Ob eine bestimmte Kette im ersten oder zweiten Fall landet, hängt von nichts anderem ab als vom niederwertigsten Byte ihrer Gesamtlänge

Der frühe Fall kann auch über den Pufferanfang hinauslaufen. Für ein CMS, das so aufgebaut ist, wie BuildSignedData es aufbaut, mit jeder umschließenden Länge auf zwei Oktets, beginnt der Inhalt des Zertifikatsets 60 Bytes hinein: 4 für den ContentInfo-Header, 11 für die OID, je 4 für die [0]- und SignedData-Header, dann 3, 17 und 13 für die kleinen Geschwister und 4 für den Zertifikatset-Header. 60 minus 94, und der Helper liefert -34. Seine einzige Sicherung sitzt auf ContentOffs- 1; das negative Ergebnis geht also direkt in die Kopie

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
Ein rückwärts laufender Gang, der die Zwischensubtraktion statt ihres Endergebnisses bewacht, kann sogar vor dem Puffer beginnen und das neu gebaute SignedData mit diesem Slice dort lassen, wo sein Zertifikatset hingehört

Was X.690 Abschnitt 10.1 festnagelt

DER macht die Längenkodierung zu einer reinen Funktion der Länge. X.690 Abschnitt 10.1 beschränkt DER auf die bestimmte Form und verlangt die minimale Anzahl an Oktets, womit die beiden Freiheiten wegfallen, die BER erlaubt: die unbestimmte Form und eine Langform-Länge, mit führenden Null-Oktets aufgefüllt wird. Unter dieser Regel nimmt eine Content-Länge unter 128 stets exakt ein Längenoktett. Jede andere Länge nimmt ein Anfangsoktett plus exakt so viele Folgoktets, wie der Wert signifikante Bytes hat. Das ergibt 81 80 für 128. Bei 256 wächst der Header auf 82 01 00, und 65536 braucht 83 01 00 00. Code, der die Content-Länge bereits hält, kann die Headergröße berechnen, ohne ein einziges Byte des Puffers zu lesen

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
Inhalt unter 128 Bytes bleibt in der Kurzform, in der ein Rückwärtsgang aus dem falschen Grund richtig antwortet; Boundary-Tests müssen also 127, 128, 255 und 256 Byte kreuzen

Die Ableitung ist nur so gut wie die Behauptung, dass die Eingabe wirklich DER ist. TDerReader.TryReadTlvAt erzwingt diese Behauptung, bevor irgendein Slicing passiert. Es verwirft ein Anfangs-Längenoktett von $80 (die unbestimmte Form), eine Langform-Länge, deren erstes Folgoktett null ist, ein einzelnes Folgoktett unter $80, das eine Kurzform hätte sein müssen, und mehr als vier Folgoktets. ReadTlv baut auf TryReadTlvAt auf; jedes Element, das AddSignatureTimestampToCms abläuft, hat diese Prüfungen also bereits passiert. Eine BER-artige gepolsterte Länge erreicht die Arithmetik nie

In AddSignatureTimestampToCms wird der Tag-Offset jedes Geschwisters so aus seinem Content-Offset und seiner Content-Länge abgeleitet. Fällt der abgeleitete Start vor den Pufferanfang, was heißt, dass die Offsets nicht zusammengehören können, wirft die Prozedur eine Exception mit der Meldung CMS: TLV header precedes the buffer start und produziert überhaupt kein CMS. Ein missgestalteter Rebuild, der trotzdem parst, ist viel schwerer zurückzuverfolgen als eine Exception an der Stelle, an der die Offsets aufhörten zusammenzupassen

Die Ableitung trägt eine Annahme mehr mit sich: Der Identifikator nimmt ein einzelnes Oktett. X.690 erlaubt Mehr-Oktett-Identifikatoren für Tag-Nummern ab 31, und TryReadTlvAt parst sie. CMS SignedData nutzt nie einen, denn seine Tags sind universelle SEQUENCE, SET, INTEGER und OID plus das kontextspezifische [0] und [1]; die Annahme hält also für jedes Element, das der Timestamp-Rebuild anfasst

Geschwister-Elemente im eigenen DER-Code kopieren

Wenn Sie Code gegen TDerReader schreiben, lassen Sie die Ableitung komplett weg. PeekTlv und ReadNextTlv füllen einen TDerTlv-Record, dessen Offset bereits auf das Tag zeigt und dessen HeaderLength die gemessene Größe von Tag plus Längenoktets ist, und EncodedBytes liefert das Element von Offset bis NextOffset. Dieser Weg kommt auch mit Mehr-Oktett-Identifikatoren klar, die keine Längenarithmetik der Welt zurückholen kann. Das Vier-Out-Parameter-ReadTlv ist bequem fürs Hinabsteigen. Es meldet aber nur die Inhaltsseite; greifen Sie also zu ihm, wenn Sie den Inhalt brauchen, und zur Record-Form, wenn Sie das ganze Element brauchen

// Jedes Kind von SignedData als komplettes TLV: Tag, Längenoktets, Inhalt.
// TDerTlv.Offset zeigt auf das Tag, also keine Header-Arithmetik im Spiel
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);   // z. B. A0 82 05 DC ...
  end;
end;

Für die Fälle, in denen eine andere API nichts als einen Content-Offset und eine Länge liefert, ist die Vorwärtsableitung kurz. DerHeaderStart, mit seiner Grenzprüfung im nächsten Abschnitt gezeigt, ist Ihr eigener Code; er spiegelt die Regel aus Abschnitt 10.1 und liefert -1 für Offsets, die nicht zusammenpassen können, sodass der Aufrufer einen Wert erhält, den er verweigern kann, statt eines plausibel aussehenden Start-Bytes

Warum verstecken kurze Testzertifikate den Fehler?

Ein kleines Platzhalter-Zertifikat hält das Zertifikatset in der Kurzform, und der Rückwärtsgang ist exakt für diesen Fall korrekt. Ein 32-Byte-Set kodiert als A0 20, ein 64-Byte-Set als A0 40. Vom Inhalt rückwärts laufend landet man auf diesem einen Längenoktett, und sein oberstes Bit ist gelöscht, weil es das erste und einzige ist. Richtige Antwort, falscher Grund. Ein Timestamp-Test auf so kleinen Zertifikaten kann das Ergebnis neu parsen, das Attribut finden, B-T melden, und nie auch nur einmal ein Zertifikatset realistischer Größe durch den Slice drücken

Jeder Codepfad, der davon abhängt, wie eine Länge kodiert ist, braucht Testeingaben, die die Kodierungsgrenze kreuzen. Für DER heißt das: Inhalte ab 128 Bytes, die die Langform erzwingen, und ab 256 Bytes, die ein zweites Folgoktett erzwingen. Die Prüfung unten vergleicht DerHeaderStart mit dem Header, den der Reader beim Parsen gemessen hat, an beiden Grenzen und im 1500-Byte-Fall aus der Einstiegsnotiz. Ein unsortierter SET OF in signedAttrs schlüpft aus demselben strukturellen Grund an gleichherkunftigen Round Trips vorbei: Der Test füttert nur Eingaben, auf denen falscher und richtiger Code übereinstimmen

// Ihr eigener Helper für APIs, die nur Content-Offset und -Länge melden.
// Nimmt einen Ein-Oktett-Identifikator an, wahr für jedes Tag in CMS SignedData
function DerHeaderStart(ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  LengthOctets:= 1;                  // Kurzform, X.690 8.1.3.4
  if ContentLen>= 128 then
  begin
    Remaining:= ContentLen;          // Langform, X.690 8.1.3.5
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // eins pro signifikantem Byte
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= -1;                     // Inkonsistente Offsets: den Aufrufer werfen lassen
end;

// Boundary-Prüfung, als Ihr eigener Testcode geschrieben: der abgeleitete Tag-Offset
// muss dem Offset gleichen, den TDerReader beim Parsen des Headers gemessen hat
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;

Dieselbe Grenze ist durch die exportierten Funktionen hindurch erreichbar. Übergeben Sie BuildSignedData Kettenzertifikate, deren Gesamtgröße das Zertifikatset knapp unter und knapp über 128 und 256 Bytes legt, führen Sie AddSignatureTimestampToCms auf jedem Ergebnis aus, laufen Sie dann die Ausgabe mit SignedDataChildren ab und vergleichen Sie das Zertifikatset-Element Byte für Byte mit dem in dem CMS, bevor der Timestamp ergänzt wurde

Was AddSignatureTimestampToCms akzeptiert

AddSignatureTimestampToCms ist für das CMS geschrieben, das BuildSignedData emittiert: ein SignerInfo, ein optionales certificates [0]-Set, kein crls [1] und noch keine unsignedAttrs. Füttern Sie es mit einem signerInfos-SET, das mehr als ein SignerInfo hält, wirft es eine Exception, statt den ersten Signer zu behalten und den Rest wegzuwerfen. Ein CMS, das ein crls [1]-Set trägt, bleibt an der Exception CMS: signerInfos SET expected hängen, denn das Element nach den Zertifikaten ist nicht der SET, den der Gang sucht. Beide Verweigerungen passieren, bevor irgendetwas zurück in CmsDer geschrieben wird

Das neue unsignedAttrs hält exakt ein Attribut, die SET-OF-Ordnungsregel aus X.690 Abschnitt 11.6 ist also ohne Sortierung erfüllt. Der signierte Teil des SignerInfo, von seiner Version bis zum Signatur-OCTET STRING, wird als ein Block kopiert; ein Validator, der signedAttrs neu digested, sieht also dieselben Bytes vor und nach dem Ergänzen des Tokens. Verweigert ein Validator das Dokument trotzdem, sitzen die Ursachen meist anderswo und sind in dieser Checkliste von PAdES-Ablehnungsgründen behandelt. Was das Token über die Zeit einbringt und wie B-LT und B-LTA darauf aufbauen, steht im Walkthrough zu RFC-3161-Timestamps und dem DSS

Der DER-Reader und -Writer in FPdfAsn1.pas, der CMS-Builder und der Timestamp-Schritt in FPdfCms.pas sind alles Pascal-Quellen, die mit der PDFium Component for Delphi kommen. Verweigert ein neu gebautes SignedData das Parsen in einem Validator, können Sie durch den Code steppen, der jedes Element schnitt, und ihn gegen die X.690-Klausel prüfen, auf die er sich beruft