Το PDFium Component εντοπίζει την αρχή μιας φωλιασμένης δομής CMS από το content length της, ποτέ περπατώντας προς τα πίσω πάνω από τα length octets, γιατί το byte αμέσως πριν από το περιεχόμενο είναι ο τελευταίος length octet και δεν λέει τίποτα για το πόσοι τον προηγούνται. Το CmsHeaderStart στο FPdfCms.pas παράγει το μήκος του header από το ContentLen αντ' αυτού, που το DER το κάνει ακριβές, και αυτό είναι που κρατά το AddSignatureTimestampToCms από το να αλλοιώνει κάθε CMS του οποίου το σύνολο πιστοποιητικών ξεπερνά τα 127 bytes
Το σκηνικό είναι η αναβάθμιση PAdES B-T. Ένα attribute signature-time-stamp, εκείνο που ορίζει το ETSI EN 319 122-1 clause 5.3 κάτω από OID 1.2.840.113549.1.9.16.2.14, πρέπει να προσγειωθεί στα unsignedAttrs του SignerInfo που περιγράφει το RFC 5652 clause 5.3, και εξ ορισμού μπορεί να προστεθεί μόνο αφού υπάρχει η τιμή της υπογραφής, γιατί το timestamp token υπολογίζεται πάνω σε εκείνη την τιμή. Οπότε το CMS είναι ήδη χτισμένο και ήδη υπογεγραμμένο όταν φτάνει το token. Η προσθήκη ενός attribute αλλάζει το μήκος του SignerInfo, που αλλάζει το μήκος του SET signerInfos, μετά του SignedData, μετά του wrapper [0] EXPLICIT, μετά του εξωτερικού ContentInfo. Κάθε εσωκλείων header πρέπει να ξαναεκπεμφθεί, και όλα όσα δεν βρίσκονται σε εκείνο το μονοπάτι πρέπει να κουβαληθούν byte προς byte. Το walkthrough B-LT και B-LTA καλύπτει τι σου αγοράζει το token· το άρθρο αυτό αφορά τα τέσσερα bytes μπροστά από το σύνολο πιστοποιητικών που η αναδόμηση συνέχισε να τα κάνει λάθος
Γιατί η προσθήκη timestamp χρειάζεται το tag offset ενός αδερφιού;
Γιατί η αναδόμηση ξαναχρησιμοποιεί verbatim τέσσερα αδέρφια του SET signerInfos, και ο reader αναφέρει πού βρίσκεται το περιεχόμενό τους, όχι πού βρίσκεται το tag τους. Το TDerReader.ReadTlv επιστρέφει το byte tag, το offset περιεχομένου, το μήκος περιεχομένου και το offset του επόμενου TLV. Εκείνη είναι η σωστή επιφάνεια για κατάβαση μέσα σε μια δομή, αλλά για να αντιγράψεις ολόκληρο element χρειάζεσαι το octet όπου κάθεται το tag του, και το μόνο που κρατάει ένας caller είναι το ContentOffs. Το CmsSliceTlv υπάρχει για να γεφυρώσει εκείνο το κενό: δοσμένο ένα offset και μήκος περιεχομένου επιστρέφει tag, length octets και περιεχόμενο ως ένα buffer, και το AddSignatureTimestampToCms το καλεί για το OID contentType, το INTEGER version, το SET digestAlgorithms, το SEQUENCE encapContentInfo και, όταν υπάρχει, το σύνολο certificates [0]
// Μέσα στο AddSignatureTimestampToCms: κατέβα, κόψε τα αδέρφια verbatim
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;
// προαιρετικά 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 + length octets + content
R.Position:= CN;
end;
Από εκείνα τα πέντε κομμάτια, τέσσερα είναι μικροσκοπικά: ένα OID έντεκα bytes, ένα INTEGER τριών bytes, ένα σύνολο αλγορίθμου digest δεκαεφτά bytes, ένα detached encapContentInfo δεκατριών bytes. Το σύνολο πιστοποιητικών είναι αυτό που κουβαλάει το πιστοποιητικό του υπογράφοντα και την αλυσίδα του, και ένα πραγματικό πιστοποιητικό X.509 τρέχει τουλάχιστον σε αρκετές εκατοντάδες bytes. Το σύνολο πιστοποιητικών είναι επομένως το μοναδικό κομμάτι του οποίου τα length octets βρίσκονται ποτέ στη long form, και είναι το κομμάτι που ο παλιός helper δεν μπορούσε να εντοπίσει
Γιατί τα DER length octets δεν μπορούν να διασχιστούν προς τα πίσω;
Γιατί το πλήθος των length octets αποθηκεύεται στον πρώτο από αυτούς, και διαβάζοντας από το περιεχόμενο προς τα πίσω συναντάς πρώτα τον τελευταίο. Το X.690 clause 8.1.3.4 ορίζει τη short form: ένα octet, bit 8 σβηστό, bits 7 έως 1 κρατώντας μήκος από 0 έως 127. Το clause 8.1.3.5 ορίζει τη long form: ένα αρχικό octet με bit 8 στραμμένο του οποίου τα bits 7 έως 1 δίνουν τον αριθμό των επόμενων octets, ακολουθούμενο από εκείνα τα octets που κουβαλάνε το μήκος ως unsigned big-endian ακέραιο. Τίποτα στον κανόνα δεν μαρκάρει ένα επόμενο octet ως επόμενο. Το bit 8 του είναι bit μεγέθους όπως κάθε άλλο, οπότε μια backwards βόλτα που τεστάρει το top bit του Buf[ContentOffs- 1] τεστάρει ένα bit δεδομένων και μετά διαβάζει τα επτά χαμηλά bits του ως πλήθος
// Ο παλιός helper, δοσμένος μόνο με το content offset
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // προσγειώνεται στον ΤΕΛΕΥΤΑΙΟ length octet
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // έχει νόημα μόνο για τον ΠΡΩΤΟ
Result:= P- 1
else
begin
LongLen:= LenByte and $7F;
Result:= P- LongLen- 1;
end;
end;
// Header ενός certificate set 1500 bytes: A0 82 05 DC
// Buf[ContentOffs- 1]= $DC -> bit 8 στραμμένο, $DC and $7F= 92
// Result= ContentOffs- 94 (το tag είναι στο ContentOffs- 4)
Πάρε το header ενός συνόλου πιστοποιητικών που κρατά 1500 bytes πιστοποιητικών, A0 82 05 DC. Η βόλτα προσγειώνεται στο DC, βλέπει στραμμένο top bit, εξάγει 92 από τα επτά χαμηλά bits και αναφέρει το tag 94 bytes πριν από το περιεχόμενο, όταν είναι 4 bytes πριν από αυτό. Σε SignedData χτισμένο από το BuildSignedData, το περιεχόμενο του συνόλου πιστοποιητικών κάθεται μόνο μερικές ντουζίνες bytes μέσα στο CMS, οπότε το υπολογισμένο offset δεν ήταν απλώς πρόωρο αλλά αρνητικό, και ο παλιός κώδικας φύλαγε το ContentOffs- 1 από το να πάει κάτω από το μηδέν, όχι το τελικό του αποτέλεσμα. Το CmsSliceTlv έπαιρνε τότε κομμάτι ενενήντα-και-κάτι bytes μεγαλύτερο από το element, ξεκινώντας πριν από το buffer, και το ξαναχτισμένο SignedData κουβαλούσε εκείνο το κομμάτι εκεί όπου έπρεπε να είναι το σύνολο πιστοποιητικών του. Ένα τριών-octet μήκος του οποίου ο τελευταίος octet τύγχανε κάτω από $80, ας πούμε A0 82 05 10, απέτυχε ανάποδα: η βόλτα τον πήρε για short-form octet και ξεκίνησε το κομμάτι στο 05, δύο bytes αργά και μέσα στα length octets, χωρίς καθόλου tag. Το αποτέλεσμα ήταν λάθος με κάθε τρόπο, μόνο η κατεύθυνση άλλαζε
Τι εγγυάται το DER που κάνει τη μπροστινή παραγωγή ακριβή;
Το DER εγγυάται ότι η κωδικοποίηση του μήκους είναι καθαρή συνάρτηση του μήκους. Το X.690 clause 10.1 περιορίζει το DER στην definite form και απαιτεί τον ελάχιστο αριθμό octets, που αφαιρεί τις δύο ελευθερίες που επιτρέπει το BER: την indefinite form, και το γέμισμα ενός μήκους long form με leading zero octets. Κάτω από εκείνον τον κανόνα ένα μήκος περιεχομένου κάτω από 128 έχει ακριβώς ένα length octet, και κάθε άλλο μήκος έχει ένα αρχικό octet συν ακριβώς τόσους επόμενους octets όσα σημαντικά bytes χρειάζεται το μήκος. Ο caller του CmsHeaderStart κρατάει ήδη το ContentLen, αφού το ReadTlv μόλις το επέστρεψε, οπότε το μήκος του header είναι υπολογίσιμο χωρίς να κοιτάξεις ούτε ένα byte του buffer
// Ο helper που κυκλοφόρησε: παράγαγε το header από το content length.
// Κάτω από X.690 10.1 τα length octets είναι συνάρτηση του ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
LengthOctets, Remaining: Integer;
begin
if ContentLen< 128 then
LengthOctets:= 1 // short form, X.690 8.1.3.4
else
begin
LengthOctets:= 1; // το αρχικό octet, X.690 8.1.3.5
Remaining:= ContentLen;
while Remaining> 0 do
begin
Inc(LengthOctets); // ένα ανά σημαντικό byte
Remaining:= Remaining shr 8;
end;
end;
Result:= ContentOffs- LengthOctets- 1;
if Result< 0 then
Result:= ContentOffs;
end;
Δύο λεπτομέρειες κάνουν αυτό ασφαλές κι όχι απλώς πειστικό. Πρώτον, η υπόθεση ότι η είσοδος είναι DER επιβάλλεται ανάντη: το TDerReader.TryReadTlvAt, πάνω στο οποίο χτίζεται το ReadTlv, απορρίπτει την indefinite form, απορρίπτει μήκος long form του οποίου ο πρώτος επόμενος octet είναι μηδέν, και απορρίπτει μεμονωμένο επόμενο octet κάτω από $80. Ένα TLV που φτάνει στο CmsSliceTlv έχει ήδη περάσει εκείνους τους ελέγχους, οπότε ένα BER-style μη ελάχιστο μήκος δεν μπορεί να φτάσει στην παραγωγή και να την κάνει να ψέψει. Δεύτερον, το fallback για αρνητικό αποτέλεσμα φυλάει πλέον την πραγματική απάντηση, όχι ένα ενδιάμεσο. Αξίζει να ειπωθεί ότι ο reader ήξερε το tag offset εξαρχής: το TDerTlv κουβαλάει και Offset και HeaderLength, και μόνο η επιφάνεια ReadTlv με τέσσερα out παραμέτρους τα πετάει. Το να τα επιστρέφει θα ήταν η καθαρότερη μακροπρόθεσμη διεπαφή· το fix που κυκλοφόρησε κρατά εκείνη την επιφάνεια ανέπαφη και κάνει τον helper σωστό με τους δικούς του όρους
Γιατί τα tests του timestamp πέρναγαν με το bug στη θέση του;
Γιατί κάθε πιστοποιητικό fixture ήταν αρκετά κοντό για να χρησιμοποιήσει τη short form, και η backwards βόλτα είναι σωστή ακριβώς για εκείνη την περίπτωση. Το Tests.PadesTimestamp.pas χτίζει το πιστοποιητικό του υπογράφοντα με SetLength(SignerCertDer, 32) σε ένα test και 64 σε άλλο, γεμάτο με ramp byte. Ένα σύνολο πιστοποιητικών 32 bytes κωδικοποιείται ως A0 20 και ένα 64 bytes ως A0 40, από ένα length octet το καθένα. Περπατώντας προς τα πίσω από το περιεχόμενο προσγειώνεσαι πάνω σε εκείνο το ένα octet, το top bit του είναι σβηστό γιατί είναι ο πρώτος και μόνο length octet, και ο helper απαντά σωστά για λάθος λόγο. Η σουίτα των 1414 περιπτώσεων ήταν πράσινη, το timestamped CMS γινόταν parse, ο validator stage-1 ανέφερε B-T, και κάθε ένας από εκείνους τους ελέγχους εκτελούνταν πάνω σε σύνολο πιστοποιητικών που κανένα πραγματικό έγγραφο δεν έχει περιέχει ποτέ
Ο γενικός κανόνας είναι το χρήσιμο μέρος. Οποτεδήποτε ένα code path εξαρτάται από το πώς κωδικοποιείται ένα μήκος, το fixture πρέπει να διασχίζει το όριο κωδικοποίησης, και για το DER αυτό σημαίνει περιεχόμενο μεγαλύτερο από 127 bytes, που επιβάλλει τη long form, και ιδανικά μεγαλύτερο και από 255 bytes, που επιβάλλει δεύτερο επόμενο octet. Η ίδια πειθαρχία ισχύει για την άλλη περίπτωση σε εκείνο το review όπου η αυτο-επαλήθευση δεν μπορούσε να δει απόκλιση DER: το μη ταξινομημένο SET OF στα signedAttrs ήταν αόρατο σε ένα round trip ίδιας προέλευσης για δομικά πανομοιότυπο λόγο, το test εξασκούσε μόνο εισόδους πάνω στις οποίες ο λάθος κώδικας και ο σωστός συμφωνούν. Το σχήμα παρακάτω καλεί τον helper κομματιών απευθείας, που σημαίνει να εξαχθεί από το FPdfCms.pas για το test build· το ίδιο όριο φτάνεται μέσω της public επιφάνειας δίνοντας στο BuildSignedData πιστοποιητικό αλυσίδας κάθε μεγέθους και κάνοντας ξανά parse το timestamped αποτέλεσμα
// Κάρφωσε το όριο: ένα κόψιμο μέσα από long-form header πρέπει να ξεκινά στο tag
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 ξεκινά αμέσως μετά το header· το κόψιμο πρέπει να είναι όλο το 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;
Πού ακόμα χαράσσει τις γραμμές της η αναδόμηση
Το AddSignatureTimestampToCms είναι γραμμένο για το CMS που εκπέμπει το BuildSignedData, και τα όριά του ακολουθούν από αυτό. Η βόλτα περιμένει ένα μεμονωμένο SignerInfo και ξαναεκπέμπει μόνο εκείνο, οπότε ένα ξένο multi-signer CMS θα γύριζε με έναν υπογράφοντα· αναγνωρίζει προαιρετικό σύνολο certificates [0] αλλά όχι σύνολο crls [1], και ένα CMS που κουβαλάει ένα αποτυγχάνει δυνατά με το exception signerInfos SET expected αντί να κόβει λάθος σιωπηλά. Τα νέα unsignedAttrs κρατούν ένα attribute, οπότε ο κανόνας διάταξης SET OF του X.690 clause 11.6 ικανοποιείται τετριμμένα και δεν χρειάζεται sort. Και το υπογεγραμμένο τμήμα μένει ανέγγιχτο εκ κατασκευής: το πρόθεμα SignerInfo μέχρι το OCTET STRING της υπογραφής αντεγράφεται verbatim, γι' αυτό ένας validator που ξανα-κάνει digest τα signedAttrs βλέπει τα ίδια bytes πριν και μετά την προσθήκη του timestamp. Όταν ένα εξακολουθεί να απορρίπτει το έγγραφο, οι αιτίες συνήθως βρίσκονται αλλού και αξίζουν τη δική τους λίστα ελέγχου
Ο DER reader, ο writer, ο builder CMS και αυτή η έγχυση timestamp κυκλοφορούν όλα ως Pascal source με το PDFium Delphi component, και ένα bug αυτού του σχήματος είναι το επιχείρημα γι' αυτό: όταν ένα ξαναχτισμένο SignedData βγαίνει ενενήντα bytes μεγαλύτερο, θέλεις να διαβάσεις τον helper που έκοψε το κομμάτι και το clause του X.690 που διάβασε λάθος, όχι stack trace από black box