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

Ανάγνωση CMS υπογραφών στο Delphi: όρια DER και OID

Το PDF Library for Delphi (PDFlibPas) εξάγει τα πιστοποιητικά μέσα σε μια υπογραφή PDF με μια καθαρή διάσχιση DER πάνω στο CMS SignedData που είναι αποθηκευμένο στο /Contents, χωρίς CryptoAPI στη μέση. Από το v3.539.10 κάθε εμφωλευμένη ανάγνωση φράσσεται από το γονικό στοιχείο της, το μηδενικό padding μετά το CMS κόβεται στο μήκος που δηλώνει το CMS, και τα object identifiers κωδικοποιούν το συνδυασμένο πρώτο subidentifier τους σε base-128. Ο κανόνας των ορίων και το OID fix αντικατέστησαν και οι δύο κώδικα που έδιναν λάθος απαντήσεις χωρίς να πετάνουν error, και ο κανόνας του padding κρατά τον αυστηρότερο reader από το να απορρίπτει πραγματικές υπογραφές

Η πλευρά ανάγνωσης μετράει περισσότερο απ' όσο φαίνεται. Τα εργαλεία long-term validation πρέπει να τραβήξουν το πιστοποιητικό του υπογράφοντος και τους εκδότες του έξω από μια υπάρχουσα υπογραφή πριν πάνε να ψάξουν revocation δεδομένα, μια αναφορά ελέγχου πρέπει να λέει ποιος υπέγραψε, και ένα Lazarus build σε Linux δεν έχει Windows message functions να ακουμπήσει. Ένας parser σε αυτή τη θέση σπάνια κρασάρει πάνω σε κακό input. Ο τρόπος αποτυχίας που πονάει είναι ένας μετρητής πιστοποιητικών που μετράει και bytes γείτονα, ένα ταίριασμα υπογράφοντος στο λάθος πεδίο, ή ένα OID που μετατρέπεται αθόρυβα σε άλλο OID. Μια υπογραφή pipeline χτισμένη πάνω σε αυτά αναφέρει με σιγουριά ανοησίες

Ανάγνωση των πιστοποιητικών του υπογράφοντος από υπογεγραμμένο PDF

Πέντε μέθοδοι του TPDFlib καλύπτουν την πλευρά ανάγνωσης, και όλες παίρνουν InputFile, Password, FieldName: κάθε κλήση ανοίγει το αρχείο μόνο για ανάγνωση, απαντά, και το κλείνει ξανά. Το GetSignatureEmbeddedCertificateCount και το GetSignatureEmbeddedCertificateDER απαριθμούν το σύνολο των πιστοποιητικών με τη σειρά κωδικοποίησης, το GetSignatureSignerCertificateDER επιστρέφει το πιστοποιητικό που παρήγαγε ένα δεδομένο SignerInfo, και το GetSignatureCertificateChainLength / GetSignatureCertificateChainDER προχωρούν από αυτόν τον υπογράφοντα προς τον πιο μακρινό εκδότη που κουβαλά η ίδια η υπογραφή. Οι δείκτες είναι zero-based. Κρατήστε τα αποτελέσματα σε AnsiString, γι' αυτό άλλωστε η βιβλιοθήκη τα επιστρέφει έτσι: ένα DER blob που περάσει μέσα από string ή TStrings περνάει από μετατροπή charset και γυρίζει χαλασμένο

uses
  SysUtils, Classes, PDFlibrary;

procedure SaveDer(const FileName: string; const Der: AnsiString);
var
  Fs: TFileStream;
begin
  Fs := TFileStream.Create(FileName, fmCreate);
  try
    if Der <> '' then
      Fs.WriteBuffer(Der[1], Length(Der));
  finally
    Fs.Free;
  end;
end;

const
  Src = 'contract-signed.pdf';
  Field = 'Signature1';
var
  Pdf: TPDFlib;
  ChainLen, I: Integer;
  SignerDer, LastDer: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    WriteLn('Certificates in the CMS: ',
      Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
    SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
    if SignerDer = '' then
      raise Exception.Create('signer certificate missing or not matched');
    SaveDer('signer.cer', SignerDer);

    ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
    for I := 0 to ChainLen - 1 do
      SaveDer(Format('chain-%d.cer', [I]),
        Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
    if ChainLen > 0 then
    begin
      LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
      WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
    end;
  finally
    Pdf.Free;
  end;
end;

Δύο πράγματα σε αυτό το output θέλουν προσοχή. Ένας μετρητής 0 δεν είναι διάγνωση: πεδίο που λείπει, λάθος password, ένα blob που δεν είναι DER, και ένα SignedData που απλώς παραλείπει το προαιρετικό σύνολο πιστοποιητικών επιστρέφουν όλα 0 ή κενό string, οπότε κρατήστε στο log το όνομα πεδίου δίπλα στον αριθμό. Και μια αλυσίδα που σταματά πριν από κάποιο self-issued πιστοποιητικό δεν είναι κι αυτή error. Ο chain builder χρησιμοποιεί μόνο πιστοποιητικά ενσωματωμένα στην υπογραφή, οπότε οι υπόλοιποι εκδότες πρέπει να τραβηχτούν από τις διευθύνσεις που αναφέρει το GetCertificateIssuerURLs

Πόσο από το /Contents είναι στην πραγματικότητα το CMS;

Μόνο το πρόθεμα που δηλώνει το εξωτερικό SEQUENCE ανήκει στο CMS, και το PLTrimCMSPadding κόβει ό,τι ακολουθεί. Ο υπογράφων κρατάει το hex string του /Contents δεσμευμένο πριν υπάρξει καν το CMS, επειδή το /ByteRange που περιγράφεται στο ISO 32000-1 §12.8.1 πρέπει να παγώσει πρώτο, οπότε η θέση έχει γενναιόδωρες διαστάσεις και η αχρησιμοποίητη ουρά είναι μηδενικά. Το PLTrimCMSPadding διαβάζει το πρώτο TLV, απαιτεί tag $30, και επιστρέφει τα bytes μέχρι το τέλος εκείνου του στοιχείου· ό,τι δεν ξεκινά με ένα καλοσχηματισμένο SEQUENCE γυρίζει κενό. Αυτό το top level είναι το ένα σημείο όπου τα bytes στο τέλος είναι νόμιμα, και η διάκριση μετράει για την επόμενη ενότητα: ένας αυστηρός κανόνας «το στοιχείο πρέπει να καταναλώσει όλο το buffer» θα απέρριπτε κάθε υπογραφή του πραγματικού κόσμου, ενώ ένας χαλαρός κανόνας σε κάθε βάθος αφήνει εμφωλευμένα πεδία να διαβάσουν bytes που δεν τα κατέχουν

Το PLTrimCMSPadding του PDFlibPas διαβάζει το πρώτο TLV του δεσμευμένου hex string /Contents, απαιτεί tag $30 και κόβει το μηδενικό padding στο μήκος που δηλώνει το εξωτερικό SEQUENCE, και επιστρέφει κενό αποτέλεσμα όταν το buffer δεν ξεκινά με καλοσχηματισμένο SEQUENCE
Τα bytes στο τέλος είναι νόμιμα μόνο στο top level, όπου η δεσμευμένη θέση πρέπει να μείνει παγωμένη για το /ByteRange — οι βαθύτερες αναγνώσεις παίρνουν τον κανόνα του γονικού ορίου

Γιατί χρειάζεται ένας DER reader το end offset του γονέα;

Ένα εμφωλευμένο στοιχείο είναι έγκυρο μόνο αν τελειώνει μέσα στον γονέα του, και ο έλεγχος ως προς το τέλος του buffer δεν αποδεικνύει αυτό. Το χαμηλού επιπέδου DERReadTLV στο PDFlibASN1 φράσσει κάθε στοιχείο ως προς ολόκληρη τη συμβολοσειρά, που είναι ο σωστός έλεγχος για το εξώτατο αντικείμενο και ο λάθος για ό,τι από κάτω. Φανταστείτε ένα SignerInfo του οποίου το issuerAndSerialNumber δηλώνει 40 bytes ενώ το issuer Name μέσα του διεκδικεί 60. Κάθε byte είναι ακόμα μέσα στο buffer, οπότε ένας reader φραγμένος στο buffer δέχεται το Name, διαβάζει τον αριθμό σειράς από τον digest αλγόριθμο που ακολουθεί, και μετά συγκρίνει αυτό το ζεύγος με τα ενσωματωμένα πιστοποιητικά. Πριν το v3.539.10 ο CMS walker διάβαζε ακριβώς έτσι. Το fix είναι ένα μικρό wrapper που κουβαλά τη θέση τέλους του γονέα σε κάθε ανάγνωση

Το PDFlibPas φράσσει κάθε εμφωλευμένη ανάγνωση DER με το γονικό στοιχείο: ένα issuer Name 60 bytes μέσα σε ένα issuerAndSerialNumber 40 bytes γίνεται δεκτό από το παλιό DERReadTLV φραγμένο στο buffer, που μετά διαβάζει τον αριθμό σειράς από το digestAlgorithm, ενώ το ReadTLVWithin απορρίπτει κάθε στοιχείο που τελειώνει πέρα από το ParentEnd
Μέσα στο buffer είναι memory safety, μέσα στον γονέα είναι ορθότητα — το PDFlibPas περνά το parent end offset μέσα από κάθε επίπεδο CMS ώστε ένα εχθρικό μήκος να μην μπορεί να δανειστεί bytes γείτονα
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // τίποτα δεν έχει απομείνει μέσα στον γονέα: αρνείται να ξεκινήσει ανάγνωση
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // το Offset κάθεται τώρα ένα πέρα από το στοιχείο· δεν πρέπει να περάσει τον γονέα
  Result := Offset <= ParentEnd;
end;

// κάθε επίπεδο καταγράφει το δικό του τέλος και το περνά παρακάτω:
//   OuterEnd    := τέλος του ContentInfo         (RFC 5652 section 3)
//   ExplicitEnd := τέλος του content [0] EXPLICIT
//   ContentEnd  := τέλος του SignedData          (RFC 5652 section 5.1)
//   SignerEnd / InnerEnd για SignerInfo και issuerAndSerialNumber

Η μονάδα, PDFlibCMSRead, τώρα περνά αυτά τα τέλη μέσα από το ContentInfo, το wrapper [0] EXPLICIT, τα πεδία SignedData μέχρι το signerInfos, το SignerIdentifier και στις δύο μορφές του, issuerAndSerialNumber και [0] subjectKeyIdentifier (RFC 5652 §5.3), και τα πεδία tbsCertificate που διαβάζονται από κάθε ενσωματωμένο πιστοποιητικό όταν γίνεται το ταίριασμα του υπογράφοντος. Μέσα στο σύνολο certificates και στο σύνολο signerInfos, ένα στοιχείο που τρέχει πέρα από το τέλος του συνόλου σταματά τη λούπα: το PLExtractCMSCertificates επιστρέφει τα πιστοποιητικά που είχε ήδη δεχτεί και ποτέ δεν κολλά τα bytes των επόμενων crls ή signerInfos πάνω στο τελευταίο. Το ταίριασμα issuer-and-serial απαιτεί επίσης και τα δύο μισά, αφού ένας αριθμός σειράς είναι μοναδικός μόνο μέσα σε έναν εκδότη

Γιατί το 2.999.3 βγήκε ως 1.15.3;

Τα δύο πρώτα arcs ενός OID συνδυάζονται σε ένα subidentifier, όχι σε ένα byte, και αυτό το subidentifier κωδικοποιείται σε base-128 όπως κάθε άλλο arc. Το X.690 §8.19.4 το ορίζει ως 40 * arc1 + arc2· το παλιότερο DER_OID έγραφε αυτή την τιμή με Byte(...), που είναι σωστό μόνο μέχρι το 127, την τιμή του 2.47. Για το 2.999 το άθροισμα είναι 1079, το cast σε byte κρατά το 55, και το 55 αποκωδικοποιείται ως 1.15, οπότε το identifier ονοματίζει αθόρυβα έναν διαφορετικό κλάδο του δέντρου. Οι τιμές από 128 έως 255 αποτυγχάνουν αλλιώς, εκπέμποντας ένα byte με αναμμένο continuation bit που καταπίνει το επόμενο arc. Οι περισσότεροι PKI identifiers (1.2.840..., 2.5.29..., 0.4.0...) δεν φτάνουν ποτέ το όριο, γι' αυτό και επέζησε· τα arcs joint-iso-itu-t από το 2.48 και πάνω φτάνουν. Το DER_OID υπηρετεί και τον encoder για signed attributes και τον matcher στο DERFindExtensionByOID και τον έλεγχο content-type του SignedData, οπότε μια λάθος κωδικοποίηση χαλούσε εξίσου τη συγγραφή και την αναζήτηση

uses
  SysUtils, PDFlibASN1;

function Hex(const S: AnsiString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
  Result := Trim(Result);
end;

begin
  WriteLn(Hex(DER_OID('2.999.3')));               // 06 03 88 37 03
  WriteLn(Hex(DER_OID('2.47.1')));                // 06 02 7F 01
  WriteLn(Hex(DER_OID('2.48.1')));                // 06 03 81 00 01
  WriteLn(Hex(DER_OID('2.5.29.14')));             // 06 03 55 1D 0E
  WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2')));  // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
Το DER_OID του PDFlibPas συνδυάζει τα δύο πρώτα arcs του OID ως 40 * arc1 + arc2 και κωδικοποιεί το άθροισμα σε base-128 μέσα σε UInt64, οπότε το 2.999.3 γίνεται 06 03 88 37 03, ενώ το παλιό cast σε Byte κρατούσε το 55 και αποκωδικοποιούσε αθόρυβα το identifier ως 1.15.3
Τα περισσότερα PKI arcs δεν φτάνουν ποτέ το όριο, γι' αυτό επέζησε το bug — τα arcs joint-iso-itu-t από το 2.48 και πάνω θέλουν δύο bytes, και το τεστ κρατά το 2.47 και το 2.48 από τις δύο πλευρές

Η συνδυασμένη τιμή κρατιέται σε UInt64 επίτηδες. Το DER_OID κάνει parse τα arcs σε Int64, οπότε ένα νόμιμο δεύτερο arc μπορεί να φτάσει έως Int64.MaxValue, και η προσθήκη 80 για arc1 = 2 ξεχειλίζει έναν προσήμονο 64-bit ακέραιο. Το UInt64 κουβαλά το Int64.MaxValue + 80 χωρίς wrap-around, και ο scratch buffer των δέκα bytes χωράει τις δέκα 7-bit ομάδες που χρειάζεται μια 64-bit τιμή. Test vectors που αξίζει να κρατήσετε είναι εκείνοι στις δύο πλευρές του ορίου: το 2.47 πρέπει να μείνει ένα byte, και το 2.48 πρέπει να γίνει δύο

Τι εγγυάται ο CMS walker της πλευράς ανάγνωσης;

Το PDFlibCMSRead εγγυάται δομή και τίποτα άλλο: επιστρέφει bytes που κάθονται εκεί όπου λέει το RFC 5652, και δεν επαληθεύει ούτε υπογραφή, ούτε digest, ούτε περίοδο εγκυρότητας. Ο walker δέχεται μόνο DER, οπότε το DERReadTLV απορρίπτει αόριστα μήκη και αριθμούς tag πολλών bytes, και ένα BER-encoded CMS από μη συμμορφούμενο υπογράφοντα αναφέρει μηδέν πιστοποιητικά αντί για μια μερική εικασία. Τα attribute certificates και οι άλλες εναλλακτικές του CertificateChoices παραλείπονται επειδή τίποτα downstream δεν μπορεί να τα χρησιμοποιήσει. Η κρυπτογραφική επαλήθευση μένει στον κώδικα που της ανήκει, που ξεκινά με τους ελέγχους κάλυψης bytes που περιγράφονται στο υπογραφή PAdES και επικύρωση ByteRange στο Delphi και συνεχίζεται με το ταξινόμηση του τι άλλαξε μετά την υπογραφή ενός PDF

Το ευρύτερο μάθημα ισχύει για κάθε binary format: «μέσα στο buffer» είναι ιδιότητα memory safety, «μέσα στον γονέα» είναι ιδιότητα ορθότητας, και ένας parser θέλει και τα δύο. Η ίδια σκέψη για εχθρικά μήκη διατρέχει το θωράκιση ενός Pascal PDF parser εναντίον κακόβουλων αρχείων. Η εξαγωγή πιστοποιητικών, το χτίσιμο αλυσίδας, και τα APIs long-term validation που συζητήθηκαν εδώ κυκλοφορούν με το losLab PDF Library for Delphi, για Delphi, C++Builder και Lazarus