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

PDF κρυπτογράφηση πιστοποιητικών στο Delphi: RSA-OAEP, ECDH

Το HotPDF κρυπτογραφεί PDF για συγκεκριμένους κατόχους πιστοποιητικών μέσω του security handler public-key του ISO 32000: το EnablePubKeyEncryption παίρνει random seed 20 bytes, και κάθε παραλήπτης παίρνει δικό του CMS envelope, χτισμένο από το AddPubKeyRecipientCertificate για κλειδιά RSA (RSA-OAEP key transport) ή από το AddPubKeyAgreementRecipientWithSecret για κλειδιά ελλειπτικών καμπυλών (ECDH σε P-256, P-384, P-521, X25519 ή X448). Κανείς δεν μοιράζεται κωδικό· όποιος κρατά matching private key ανοίγει το αρχείο

Η περίπτωση χρήσης είναι πάντα κάποια εκδοχή της ίδιας ιστορίας. Ένα τριμηνιαίο πακέτο audit πάει σε τρεις εξωτερικούς ελέγχοντες, η νομική θέλει καθένας να το διαβάσει, μόνο ένας επιτρέπεται να το τυπώσει, και κανείς δεν θέλει κωδικό να κάθεται σε email thread δίπλα στο attachment. Η κρυπτογράφηση με κωδικό δεν μπορεί να το εκφράσει αυτό. Η κρυπτογράφηση με πιστοποιητικά μπορεί, επειδή κάθε παραλήπτης ξεκλειδώνει το έγγραφο με κλειδί που ήδη κρατά, και κάθε παραλήπτης μπορεί να κουβαλά διαφορετικό σύνολο δικαιωμάτων μέσα στο δικό του envelope

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

Ένα PDF κρυπτογραφημένο με public-key παράγει το file key του από random seed συν τα ακριβή bytes κάθε recipient envelope, όχι από τίποτα που πληκτρολογεί άνθρωπος. Ο handler περιγράφεται στο ISO 32000-1 §7.6.4 (§7.6.5 στο ISO 32000-2), και τα envelopes είναι δομές CMS EnvelopedData όπως ορίζονται στο RFC 5652. Το HotPDF γράφει /Filter /Adobe.PubSec με /SubFilter /adbe.pkcs7.s5· για AES-256 αυτό σημαίνει /V 5 και εγγραφή /DefaultCryptFilter κάτω από /CF με /CFM /AESV3, και ο πίνακας /Recipients ζει μέσα σε εκείνο το crypt filter. Κάθε envelope κρυπτογραφεί 24 bytes: το seed 20 bytes ακολουθούμενο από τη λέξη δικαιωμάτων 32-bit εκείνου του παραλήπτη. Η τιμή /P στο encryption dictionary είναι μόνο placeholder, επειδή τα πραγματικά δικαιώματα ταξιδεύουν μέσα σε κάθε envelope. Στη φόρτωση ένας reader ξετυλίγει ένα envelope, ανακτά το seed, και κάνει hash το seed μαζί με κάθε envelope σε σειρά /Recipients (SHA-256 για AES-256, SHA-1 για τα παλιότερα ciphers) για να ξαναχτίσει το file key. Αν ακόμα αποφασίζετε ανάμεσα σε αυτό το μοντέλο και συνηθισμένους κωδικούς, ο οδηγός κρυπτογράφησης AES-256 με κωδικούς και σημαίες δικαιωμάτων καλύπτει την άλλη πλευρά αυτού του συμβιβασμού

Διάγραμμα κρυπτογράφησης public-key του HotPDF: το EnablePubKeyEncryption καρφώνει seed 20 bytes, κάθε envelope CMS EnvelopedData κρυπτογραφεί εκείνα τα 20 bytes συν μία λέξη δικαιωμάτων 32-bit μέσα σε /Filter /Adobe.PubSec με /SubFilter /adbe.pkcs7.s5 και /CFM /AESV3, και ο reader ξετυλίγει ένα envelope, ανακτά το seed και το κάνει hash με κάθε εγγραφή /Recipients σε σειρά πίνακα για να ξαναχτίσει το file key
Η τιμή /P στο encryption dictionary είναι μόνο placeholder επειδή τα πραγματικά δικαιώματα ταξιδεύουν μέσα σε κάθε envelope, και τίποτα κατάντη δεν επιτρέπεται να αναδιατάξει ή να ξανακωδικοποιήσει τον πίνακα πάνω στον οποίο τρέχει ο digest

Γράψιμο παραληπτών RSA με EnablePubKeyEncryption

Για πιστοποιητικά RSA, καλέστε το EnablePubKeyEncryption με aes256, μετά καλέστε το AddPubKeyRecipientCertificate μία φορά ανά πιστοποιητικό DER πριν το BeginDoc. Ο helper χτίζει envelope RSAES-OAEP in-process με τιμές THPDFRSAOAEPHash για τον digest OAEP και τον digest MGF1 (rohSHA256, rohSHA384 ή rohSHA512), και κρυπτογραφεί το περιεχόμενο του envelope με AES-256-CBC

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;

procedure WriteAuditPack(const OutFile: string);
var
  Pdf: THotPDF;
  Seed: AnsiString;
begin
  SetLength(Seed, 20);                      // ακριβώς 20 bytes, ακόμα και για AES-256
  AESGenerateRandomBytes(@Seed[1], Length(Seed));
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := OutFile;
    Pdf.EnablePubKeyEncryption(Seed, aes256, True);   // ο default τύπος κλειδιού είναι aes128
    // Ο ελέγχων A μπορεί να τυπώσει· ο ελέγχων B μόνο να διαβάσει και να εξάγει
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
      [prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
    Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
      [prExtractContent]);
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Τρεις λεπτομέρειες σε εκείνη τη λίστα είναι φέρουσες. Πρώτον, το μήκος του seed είναι καρφωμένο στα 20 bytes για κάθε τύπο κλειδιού, AES-256 συμπεριλαμβανομένου· το EnablePubKeyEncryption πετάει εξαίρεση σε οποιοδήποτε άλλο μήκος. Δεύτερον, το EnablePubKeyEncryption defaults σε aes128, και οι δύο certificate helpers αρνούνται να τρέξουν εκτός αν ο τύπος κλειδιού είναι aes256, οπότε το ξέχασμα του δεύτερου ορίσματος σας χαρίζει την εξαίρεση «certificate envelopes require aes256». Τα κληρονομικά ciphers (k40, k128, aes128) δουλεύουν ακόμα, αλλά μόνο μέσω AddPubKeyRecipient με envelope που χτίσατε αλλού. Τρίτον, η κρυπτογράφηση public-key AES-256 είναι χαρακτηριστικό PDF 2.0, οπότε το HotPDF ανεβάζει την έκδοση εγγράφου σε 2.0 αυτόματα. Με StrictVersionLock set σε χαμηλότερη έκδοση, το EnablePubKeyEncryption επιστρέφει χωρίς να ενεργοποιήσει τίποτα, και η αποτυχία φαίνεται μόνο στην επόμενη γραμμή ως «call EnablePubKeyEncryption first». Η αλλαγή κρυπτογράφησης κατά τη διάρκεια incremental update πετάει EInvalidOpException αμέσως

Προσθήκη παραληπτών ECDH: P-256, P-384, P-521, X25519 και X448

Για πιστοποιητικά ελλειπτικών καμπυλών, το AddPubKeyAgreementRecipientWithSecret γράφει παραλήπτη key-agreement CMS (KeyAgreeRecipientInfo, τη δομή KARI από το RFC 5753, με το προφίλ X25519 και X448 από το RFC 8418) και υπολογίζει το κοινό μυστικό ECDH in-process. Διαλέγετε την καμπύλη με τιμή THPDFPubKeyAgreementScheme: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 ή pkasX448. Το scheme πρέπει να ταιριάζει το κλειδί στο πιστοποιητικό, αλλιώς η κλήση πετάει «Certificate key does not match the requested agreement scheme». Στα μέσα, κάθε envelope παίρνει φρέσκο random UKM 32 bytes, ένα key-encryption key παραγόμενο με το KDF stdDH (SHA-256 για P-256 και X25519, SHA-384 για P-384, SHA-512 για P-521 και X448), και ένα AES-256 key wrap όπως ορίζεται στο RFC 3394. Το ίδιο το κοινό μυστικό βγαίνει από κώδικα καμπυλών pure Pascal, χωρίς καμία εμπλοκή platform crypto provider· το άρθρο αριθμητικής NIST καμπυλών σε pure Pascal εξηγεί πώς χτίστηκε και επαληθεύτηκε εκείνη η στρώση. Για τις Montgomery καμπύλες όλο το ephemeral ζευγάρι κλειδιών μπορεί να παραχθεί τοπικά:

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
  HPDFKeyAgreement;

procedure AddLegalRecipient(Pdf: THotPDF);
var
  Scalar, OriginatorPublic: TBytes;
begin
  // Φρέσκο ephemeral scalar ανά envelope· το clamping γίνεται μέσα στη ladder
  SetLength(Scalar, 32);
  AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
  try
    OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
    Pdf.AddPubKeyAgreementRecipientWithSecret(
      TFile.ReadAllBytes('legal-x25519.cer'),
      [prPrint, prExtractContent], pkasX25519,
      OriginatorPublic, Scalar,
      []);   // OwnPublicPoint: έχει νόημα μόνο για τις NIST καμπύλες
  finally
    HPDFSecureClearBytes(Scalar);
  end;
end;

Οι NIST καμπύλες θέλουν περισσότερα από τον καλούντα. Το HotPDF παραδίδει public-key helpers μόνο για X25519 και X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar), οπότε για P-256, P-384 και P-521 παράγετε το ephemeral ζευγάρι κλειδιών με τα δικά σας εργαλεία και περνάτε big-endian scalar ακριβώς του μεγέθους πεδίου (32, 48 ή 66 bytes) συν το αντίστοιχο uncompressed σημείο 0x04||X||Y ως OriginatorPublicKey. Το HotPDF επικυρώνει το σημείο παραλήπτη απέναντι στην εξίσωση της καμπύλης, αλλά δεν μπορεί να ελέγξει ότι το originator public key σας ανήκει όντως στο scalar σας. Ασυνάρτητα μισά παράγουν ούτως ή άλλως ένα απόλυτα καλοσχηματισμένο envelope που κανένας παραλήπτης δεν μπορεί να ανοίξει, γι' αυτό ένα round-trip load ανήκει στη σουίτα τεστ σας, όχι απλώς ένας έλεγχος μεγέθους αρχείου

Διάγραμμα συμφωνίας ECDH του HotPDF: το AddPubKeyAgreementRecipientWithSecret παράγει το κοινό μυστικό με κώδικα καμπυλών pure Pascal, αναμειγνύει φρέσκο UKM 32 bytes μέσα από το stdDH KDF με SHA-256 για P-256 και X25519, SHA-384 για P-384, SHA-512 για P-521 και X448, μετά τυλίγει το content key με το AES-256 key wrap του RFC 3394 για να χτίσει το envelope KeyAgreeRecipientInfo
Η τιμή scheme από pkasECDHP256 έως pkasX448 πρέπει να ταιριάζει το κλειδί του πιστοποιητικού, και ασυνάρτητα μισά scalar και public point παράγουν ούτως ή άλλως καλοσχηματισμένο envelope που κανένας παραλήπτης δεν μπορεί να ανοίξει

Γιατί έχει σημασία η σειρά των /Recipients;

Η σειρά του /Recipients έχει σημασία επειδή το file key είναι digest πάνω στο seed και κάθε envelope σε σειρά πίνακα, οπότε writer και reader πρέπει να κάνουν hash τα ίδια bytes στην ίδια αλληλουχία. Το HotPDF κρατά τα envelopes στη σειρά που τα προσθέτετε και τα γράφει αμετάβλητα, που σημαίνει ότι μπορείτε να προσθέσετε παραλήπτες με οποιαδήποτε σειρά θέλετε, αλλά τίποτα κατάντη δεν επιτρέπεται να αναδιατάξει, να ξανακωδικοποιήσει ή να «καθαρίσει» εκείνον τον πίνακα. Τα περισσότερα πραγματικά bugs σε αυτή την περιοχή ήταν παραλλαγή εκείνου του θέματος, όπου οι δύο πλευρές έκαναν hash ελαφρώς διαφορετικά bytes:

  • Η αποθήκευση dynamic πινάκων σε TList μέσω Add κρατά μόνο raw pointer ενώ ο reference count μένει με την τοπική μεταβλητή. Το επόμενο SetLength απελευθερώνει τον buffer και μπορεί να τον ξαναχρησιμοποιήσει, οπότε κάθε slot κατέληξε να κάνει alias το τελευταίο envelope και αρχεία πολλών παραληπτών παρήγαν λάθος κλειδί. Το fix είναι να αποθηκεύετε owned αντίγραφο με List.Add(Pointer(System.Copy(Bytes)))
  • Το ξέτυλιγμα envelope αναλύει το DER στη θέση του, και το πέρασμα ανάκτησης κλειδιού αρχικά έκανε hash εκείνους τους ίδιους ζωντανούς πίνακες. Ο reader τώρα τραβάει snapshots καθαρών αντιγράφων κάθε envelope πριν οποιοδήποτε unwrap τα αγγίξει, και ο digest τρέχει πάνω στα snapshots
  • Δυαδικό DER που περνά μέσα από Unicode TStringList παίρνει bytes στο $80 και πάνω ξανακωδικοποιημένα από το code page, οπότε το HotPDF αποθηκεύει envelopes ως hex κείμενο εσωτερικά
  • Κρυπτογραφημένα και δυαδικά strings πρέπει να γράφονται ως hex strings. Ένα literal string υπόκειται σε end-of-line κανονικοποίηση, όπου CR, LF και CRLF γίνονται όλα ένα μοναδικό LF (ISO 32000-1 §7.3.4.2), και αυτό ξαναγράφει σιωπηλά το ciphertext. Το HotPDF βγάζει κάθε εγγραφή /Recipients ως hex string και την εξαιρεί από string encryption, αφού κάθε reader χρειάζεται τα envelopes πριν κρατά οποιοδήποτε κλειδί
  • Το πρώτο byte ενός DER BIT STRING μετρά unused bits και πρέπει να είναι μηδέν για κλειδιά ευθυγραμμισμένα σε bytes. Το να μείνει uninitialized μετά από SetLength έγραφε ό,τι είχε στο stack, και ένα αυστηρό unwrapper απέρριπτε το originator key, οπότε ένα αρχείο μπορούσε περιστασιακά να αποτύχει να ανοίξει με το ίδιο το κλειδί για το οποίο γράφτηκε
  • Όταν το ίδιο κλειδί εξακολουθεί να μην αποκρυπτογραφεί, συγκρίνετε στρώμα στρώμα: το file key, μετά το πρόθεμα ciphertext (το IV), μετά το object key, μετά το plaintext. Το bug κατοικεί αμέσως μετά την πρώτη στρώση που διαφωνεί

Πώς ανοίγετε PDF κρυπτογραφημένο με πιστοποιητικά χρησιμοποιώντας private key;

Για να ανοίξετε PDF κρυπτογραφημένο με πιστοποιητικά, καταχωρίστε το υλικό private key πριν καλέσετε το LoadFromFile, επειδή το HotPDF ανακτά το file key κατά το δομικό πέρασμα. Αναθέστε κλειδί RSA ή EC αναλυμένο με HPDFParsePFX στο PubSecKeyMaterial, προσθέστε επιπλέον κλειδιά RSA με AddPubSecKeyMaterial, και καταχωρίστε raw ECDH scalars με AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint), χρησιμοποιώντας τις σταθερές HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 ή HPDFOIDECP521. Οι NIST καμπύλες απαιτούν το uncompressed public point του ίδιου του παραλήπτη· οι Montgomery καμπύλες το αγνοούν

uses
  System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;

procedure OpenAuditPack(const LegalScalar: TBytes);
var
  Reader: THotPDF;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    Reader.PubSecKeyMaterial :=
      HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
    Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
    // Προαιρετικό: διαλέξτε το envelope απευθείας αντί να δοκιμάζετε όλα
    Reader.PubSecRecipientQuery :=
      function(Context: Pointer; RecipientCount: Integer): Integer
      begin
        Result := -1;   // -1 = δοκιμάστε κάθε envelope με τη σειρά
      end;
    Reader.LoadFromFile('audit-pack.pdf', '');
    Writeln('Pages: ', Reader.GetLoadedPageCount);
  finally
    Reader.Free;
  end;
end;

Χωρίς callback, το HotPDF δοκιμάζει κάθε envelope απέναντι σε κάθε καταχωρισμένο κλειδί: πρώτα το primary κλειδί, μετά κάθε επιπλέον κλειδί RSA, μετά το EC υλικό. Το PubSecRecipientQuery δέχεται το πλήθος envelopes και επιστρέφει 0-based δείκτη ή -1, και δείκτης έξω από τον πίνακα πετάει εξαίρεση αντί να κοπεί. Σημειώστε ότι το AddPubSecKeyMaterial δέχεται μόνο υλικό RSA (επιμένει σε modulus και private exponent), οπότε κλειδιά EC ανήκουν στο PubSecKeyMaterial ή στο AddPubSecAgreementKeyMaterial. Όταν κανένα κλειδί δεν ξετυλίγει κανένα envelope, το βήμα ανάκτησης επιστρέφει χωρίς file key αντί να πετάει, οπότε επαληθεύστε ότι το περιεχόμενο που περιμένατε όντως αποκρυπτογραφήθηκε αντί να εμπιστευτείτε ότι η κλήση φόρτωσης επέστρεψε

Διάγραμμα φόρτωσης private key του HotPDF: το PubSecKeyMaterial κουβαλά το primary κλειδί RSA ή EC από HPDFParsePFX, το AddPubSecKeyMaterial προσθέτει μόνο κλειδιά RSA, το AddPubSecAgreementKeyMaterial καταχωρεί raw ECDH scalars κάτω από τα curve OIDs HPDFOIDX25519 έως HPDFOIDP521, και στο LoadFromFile ο provider δοκιμάζει το primary κλειδί, μετά κάθε επιπλέον κλειδί RSA, μετά το EC υλικό απέναντι σε κάθε envelope
Όταν κανένα κλειδί δεν ξετυλίγει κανένα envelope το βήμα ανάκτησης επιστρέφει χωρίς file key αντί να πετάει, οπότε επαληθεύστε ότι το περιεχόμενο όντως αποκρυπτογραφήθηκε ή καρφώστε το envelope μέσω PubSecRecipientQuery

Τι δεν εγγυάται το HotPDF

Το HotPDF εγγυάται ότι ο δικός του writer και reader συμφωνούν byte προς byte, και χτίζει envelopes που ακολουθούν τις δομές CMS που παραθέτονται παραπάνω. Δεν εγγυάται ότι κάθε PDF viewer ανοίγει κάθε συνδυασμό. Η υποστήριξη RSA-OAEP key transport και για παραλήπτες X25519 ή X448 ποικίλλει ανάμεσα σε readers και εκδόσεις, και δεν έχουμε δημοσιεύσει αποτελέσματα συμβατότητας για εκείνους τους συνδυασμούς. Αν ένα έγγραφο πρέπει να ανοίξει σε συγκεκριμένο viewer, κρυπτογραφήστε test αρχείο για test πιστοποιητικό ίδιου τύπου κλειδιού και ανοίξτε το εκεί πριν δεσμευτείτε σε scheme. Τα δικαιώματα που κουβαλά το envelope μένουν πολιτική που το conforming λογισμικό σέβεται, ακριβώς όπως κάτω από κρυπτογράφηση με κωδικό. Η ποιότητα του seed είναι κι αυτή δική σας ευθύνη: το AESGenerateRandomBytes υπάρχει για εκείνη τη δουλειά, και το HotPDF σβήνει το αντίγραφό του seed μόλις παραχθεί το file key. Αν χρειάζεστε επίσης string, stream ή attachment να χρησιμοποιήσει διαφορετικό crypt filter, ο οδηγός πολιτικής crypt filter για StmF, StrF και EFF δείχνει ποια ονόματα φίλτρων δέχεται ο public-key handler

Η κρυπτογράφηση με πιστοποιητικά, τα envelopes παραληπτών RSA-OAEP και ECDH, και η φόρτωση private key παραδίδονται όλα στο HotPDF Delphi PDF component, δίπλα στην κρυπτογράφηση με κωδικούς, τις ψηφιακές υπογραφές και το υπόλοιπο της εργαλειοθήκης ISO 32000 για Delphi και C++Builder