Τεχνικό Άρθρο

Μην περπατάς ποτέ τα DER length octets προς τα πίσω

Το 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 δεν μπορούσε να εντοπίσει

Τι ξαναχτίζει η προσθήκη timestamp PAdES B-T σε ένα CMS: το CmsSliceTlv αντεγράφει contentType, version, digestAlgorithms, encapContentInfo και το σύνολο πιστοποιητικών byte προς byte, το σύνολο πιστοποιητικών είναι το μοναδικό κομμάτι αρκετά μεγάλο για να αφήσει τη short length form, και κάθε εσωκλείων header από το SignerInfo ως το ContentInfo ξαναεκπέμπεται
Το υπογεγραμμένο τμήμα μένει ανέγγιχτο εκ κατασκευής επειδή το πρόθεμα SignerInfo μέχρι το OCTET STRING της υπογραφής αντεγράφεται verbatim, οπότε ένας validator που ξανα-κάνει digest τα signedAttrs βλέπει πανομοιότυπα bytes πριν και μετά την προσγείωση του timestamp

Γιατί τα 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 length octets δεν διασχίζονται προς τα πίσω: η ανάγνωση του A0 82 05 DC από το τέλος προσγειώνεται στον τελευταίο octet DC, του οποίου το στραμμένο top bit δίνει ψεύτικο πλήθος 92 και τοποθετεί το tag 94 octets νωρίς, ενώ το A0 82 05 10 αποτυγχάνει ανάποδα και ξεκινά το κομμάτι δύο octets αργά μέσα στα length octets
Ο παλιός CmsHeaderStart φύλαγε τη μεσαία αφαίρεση αντί για το τελικό της αποτέλεσμα, οπότε ένα κομμάτι μπορούσε ακόμα και να ξεκινήσει πριν από το buffer, και το ξαναχτισμένο SignedData κουβαλούσε εκείνο το κομμάτι εκεί όπου ανήκε το σύνολο πιστοποιητικών

Τι εγγυάται το 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

Η μπροστινή παραγωγή που εγγυάται το DER: μήκος περιεχομένου 127 κωδικοποιείται ως A0 7F, 128 ως A0 81 80, 255 ως A0 81 FF, 256 ως A0 82 01 00 και 1500 ως A0 82 05 DC, οπότε το μήκος του header βγαίνει από το ContentLen μόνο του και το TryReadTlvAt έχει ήδη απορρίψει κάθε μη ελάχιστη BER μορφή
Τα fixtures χτισμένα από πιστοποιητικά 32 και 64 bytes έμεναν μέσα στη short form όπου η backwards βόλτα απαντά σωστά για λάθος λόγο, γι' αυτό η σουίτα ορίων διασχίζει πλέον 127, 128, 255 και 256 bytes
// Ο 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