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
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
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 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