Το PDFium Component έκδοση 3.114.20 διορθώνει την κωδικοποίηση RSASSA-PSS-params και στα τρία PAdES signing backends, Windows CNG, macOS Keychain και PKCS#11. Το RFC 4055 §3.1 δίνει σε κάθε πεδίο των RSASSA-PSS-params explicit tag ειδικό περιεχομένου, [0] έως [3], και τα backends εξέπεμπαν το saltLength ως γυμνό universal INTEGER ενώ έγραφαν trailerField ίσο με το default του. Τα bytes της υπογραφής ήταν σωστά όλο αυτό τον καιρό. Το AlgorithmIdentifier που τα περιέγραφε δεν ήταν, και αυτό από μόνο του φτάνει για να απορρίψει ένας verifier την υπογραφή
Το εκνευριστικό μέρος είναι πού κρύβεται το bug. Μια υπογραφή CMS έχει δύο μισά, την κρυπτογραφική πράξη και το ASN.1 που λέει στον verifier πώς εκτελέστηκε η πράξη. Κάνεις το πρώτο σωστό και το δεύτερο λάθος, και το αποτέλεσμα είναι ένα έγγραφο που κανένα εργαλείο πιστό στις προδιαγραφές δεν μπορεί να ξεχωρίσει από πλαστογραφία. Το άρθρο αυτό αφορά μόνο εκείνο το δεύτερο μισό: πώς πρέπει να μαρκάρονται τα RSASSA-PSS-params, πώς τρία backends το έκαναν λάθος με τον ίδιο τρόπο, και πώς μοιάζει το διορθωμένο DER σε όρους TDerWriter
Γιατί ένας verifier απορρίπτει υπογραφή RSASSA-PSS της οποίας τα bytes είναι σωστά;
Επειδή το RSASSA-PSS είναι η μία σχήμα RSA όπου ο verifier δεν μπορεί να ανακτήσει τις παραμέτρους από την ίδια την υπογραφή. Το padding PKCS#1 v1.5 προσδιορίζεται πλήρως από το OID sha256WithRSAEncryption, οπότε οι παράμετροί του είναι γυμνό NULL και δεν υπάρχει τίποτα να κάνεις λάθος. Το PSS παραμετροποιείται από hash function, από mask generation function με δικό της hash, και από μήκος salt, και το RFC 8017 §A.2.3 αφήνει και τα τρία ανοιχτά. Ο υπογράφων τα διαλέγει, το AlgorithmIdentifier τα κουβαλάει, και ο verifier πρέπει να τα αναπαράγει ακριβώς πριν το EMSA-PSS-VERIFY αρχίσει καν
Οπότε όταν το PDFium Component υπογράφει με SHA-256, MGF1 πάνω από SHA-256 και salt 32 bytes, εκείνα τα τρία γεγονότα πρέπει να επιβιώσουν από κωδικοποίηση DER εδώ και αποκωδικοποίηση DER σε άλλη υλοποίηση. Ένα block παραμέτρων που ο verifier δεν μπορεί να parse κόβει την επαλήθευση πριν συμβεί οποιαδήποτε modular exponentiation. Ένα block που parseάρει διαφορετικά είναι χειρότερο, επειδή το RFC 4055 §3.1 δίνει στο saltLength default 20. Ένας decoder που προσπερνά πεδίο που δεν αναγνωρίζει προσγειώνεται σε εκείνο το default, τρέχει EMSA-PSS-VERIFY με salt 20 bytes απέναντι σε υπογραφή υπολογισμένη με 32, και αναφέρει κακή υπογραφή χωρίς καμία ένδειξη ότι το πρόβλημα είναι στα metadata και όχι στο κλειδί. Και τα δύο αποτελέσματα παρήγαγε η κωδικοποίηση 3.114.19, ανάλογα με το πόσο αυστηρός ήταν ο verifier, και κανένα δεν δείχνει το AlgorithmIdentifier
Τι απαιτεί πράγματι το RFC 4055 §3.1 από τα RSASSA-PSS-params
Το RFC 4055 §3.1 ορίζει τα RSASSA-PSS-params ως SEQUENCE τεσσάρων πεδίων, το καθένα με explicit tag ειδικό περιεχομένου και τιμή DEFAULT:
// RSASSA-PSS-params ::= SEQUENCE {
// hashAlgorithm [0] HashAlgorithm DEFAULT sha1,
// maskGenAlgorithm [1] MaskGenAlgorithm DEFAULT mgf1SHA1,
// saltLength [2] INTEGER DEFAULT 20,
// trailerField [3] TrailerField DEFAULT trailerFieldBC
// }
Το explicit tagging στο DER σημαίνει ότι κάθε πεδίο τυλίγεται σε constructed TLV ειδικού περιεχομένου, A0 για [0], A1 για [1], A2 για [2] και A3 για [3], με το universal κωδικοποίηση της τιμής φωλιασμένη μέσα. Κάθε πεδίο μαρκάρεται ακριβώς επειδή κάθε πεδίο είναι προαιρετικό μέσα από το default του. Χωρίς tags ένας decoder δεν θα μπορούσε να πει αν SEQUENCE που κρατά μεμονωμένο AlgorithmIdentifier κουβαλάει hashAlgorithm ή maskGenAlgorithm, αφού και τα δύο είναι τύποι SEQUENCE· με αυτά, ο αριθμός tag κατονομάζει το πεδίο ανεξαρτήτως ποιοι γείτονες είναι παρόντες. Οι τιμές που εκπέμπει το PDFium Component ακολουθούν το profile του ETSI TS 119 312 §7, SHA-256, MGF1 με SHA-256 και salt ίσο με το μήκος digest, και κατοπτρίζουν ακριβώς ό,τι λέγεται σε κάθε platform signing κλήση: ένα BCRYPT_PSS_PADDING_INFO με cbSalt 32 για NCryptSignHash, ένα CK_RSA_PKCS_PSS_PARAMS με sLen 32 για τον μηχανισμό PKCS#11, και τον αλγόριθμο PSS digest-signing SHA-256 στο Security framework
Πώς τρία backends έκαναν το ίδιο λάθος
Η κωδικοποίηση 3.114.19 μαρκάριαζε τα δύο πρώτα πεδία και άφηνε τα δύο τελευταία γυμνά, πανομοιότυπα στο TWinCmsSigner, στο TKeychainCmsSigner και στο TPkcs11CmsSigner. Εκείνη η συμμετρία δεν είναι τυχαία: και τα τρία υλοποιούν τη διεπαφή ICmsSigner από το FPdfCms.pas, και τα σώματα GetSignatureAlgorithmParams τους γράφτηκαν πάνω σε ένα template. Το template έλεγε έτσι:
// Πριν το 3.114.20: [0] και [1] με tag, [2] και [3] όχι
Result := W.Sequence(Concat4(
W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
W.IntegerOf(32), // γυμνό INTEGER όπου απαιτούνταν [2] EXPLICIT
W.IntegerOf(1))); // trailerField, ίσο με DEFAULT, πρέπει να λείπει
Ένας decoder που περπατά εκείνη τη SEQUENCE βλέπει A0, διαβάζει τον hash αλγόριθμο, βλέπει A1, διαβάζει τη mask generation function, και μετά συναντά 02 01 20. Εκείνο είναι universal INTEGER, και τα RSASSA-PSS-params δεν έχουν πουθενά untagged μέλος INTEGER. Ένας αυστηρός decoder σταματά εκεί. Ένας επιεικής προσπερνά το μη αναγνωρισμένο στοιχείο, δεν βρίσκει ποτέ A2, αναθέτει στο saltLength το default του 20, μετά χτυπά δεύτερο αδέσποτο INTEGER, 02 01 01, και έχει το ίδιο πρόβλημα ξανά. Καμία διαδρομή δεν καταλήγει σε salt 32 bytes. Ένα κοινό template είναι αποδοτικό όταν είναι σωστό και εξίσου αποδοτικός τρόπος να κάνεις λάθος τρεις φορές όταν δεν είναι, γι' αυτό το fix προσγειώθηκε και στις τρεις μονάδες σε ένα commit και γι' αυτό τα τρία σώματα μεθόδων παραμένουν δομικά πανομοιότυπα από τότε. Μελλοντικό backend πρέπει να αντιγράψει το block από ένα από αυτά αντί να το ξαναπαραγάγει, γιατί η παραγωγή είναι ακριβώς το σημείο όπου έγινε το λάθος
Γιατί το trailerField παραλείπεται αντί να μαρκαριστεί ως [3];
Επειδή το X.690 §11.5 λέει ότι DER encoder δεν πρέπει να κωδικοποιεί component του οποίου η τιμή ισούται με το DEFAULT του, και το trailerField έχει DEFAULT trailerFieldBC, που είναι ο ακέραιος 1. Η προφανής διόρθωση του παλιού κώδικα, αντικαθιστώντας το γυμνό W.IntegerOf(1) με W.ContextSpecific(3, W.IntegerOf(1), True), παράγει block που ένας επιεικής BER decoder δέχεται και ένας αυστηρός DER decoder δικαιούται να απορρίψει. Η τιμή δεν είναι λάθος. Η παρουσία της είναι. Ο ίδιος κανόνας είναι και ο λόγος που τα άλλα τρία πεδία είναι παρόντα: το SHA-256 δεν είναι το default sha1, το MGF1 με SHA-256 δεν είναι το default mgf1SHA1, και το 32 δεν είναι το default 20. Αν το backend υπέγραφε με SHA-1 και salt 20 bytes, το RFC 4055 §3.1 θα συρρικνώνει τις παραμέτρους σε κενή SEQUENCE, 30 00, και εκείνη η κενή SEQUENCE και όχι NULL είναι ό,τι περιμένει ένας verifier. Το PDFium Component δεν εκπέμπει ποτέ εκείνο το σχήμα γιατί δεν υπογράφει ποτέ με εκείνες τις τιμές, αλλά είναι η περίπτωση που πιάνει όποιον υποθέτει ότι το «χωρίς παραμέτρους» γράφεται πάντα 05 00
Αυτή είναι η διάκριση DER απέναντι σε BER που μετράει για υπογραφές ειδικά. Το BER αφήνει encoder να συμπεριλάβει component με τιμή default· το DER το απαγορεύει, επειδή το DER υπάρχει ώστε μια τιμή να έχει ακριβώς μία κωδικοποίηση, και μια υπογραφή πάνω σε δομή με δύο νόμιμες κωδικοποιήσεις είναι υπογραφή που μπορεί να αμφισβητηθεί. Όλα μέσα στα CMS signedAttrs είναι DER για αυτόν τον λόγο, και το block παραμέτρων ταξιδεύει μέσα στα signedAttrs μέσω attribute cmsAlgorithmProtection καθώς και στο εξωτερικό signatureAlgorithm, οπότε δεν παίρνει καμία εξαίρεση
Η διορθωμένη κωδικοποίηση TDerWriter
Το PDFium Component χτίζει πλέον τις παραμέτρους με τρεις κλήσεις TDerWriter.ContextSpecific από το FPdfAsn1.pas, μία ανά μη-default πεδίο, καθεμία με όρισμα Constructed True για να παράγει το wrapper explicit-tag, και καθόλου γραμμή για το trailer field. Αυτό είναι το σώμα του TWinCmsSigner.GetSignatureAlgorithmParams με τα OIDs γραμμένα αναλυτικά· οι μονάδες Keychain και PKCS#11 ονοματίζουν τις ίδιες τιμές ως OID_SHA256, OID_MGF1 και OID_RSASSA_PSS:
function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if FPaddingScheme = psRsaPss then
begin
W := TDerWriter.Create;
try
// Το RFC 4055 3.1 μαρκάριαζει και τα τέσσερα πεδία. Το saltLength είναι [2]; γυμνό
// INTEGER εδώ διαβάζεται ως αρχή άλλου πεδίου. Το trailerField
// είναι [3] με DEFAULT 1, και το X.690 11.5 απαγορεύει κωδικοποίηση τιμής
// ίσης με το default, οπότε παραλείπεται ολότελα
Result := W.Sequence(Concat3(
W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID('1.2.840.113549.1.1.8'), // id-mgf1
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.ContextSpecific(2, W.IntegerOf(32), True)));
finally
W.Free;
end;
end
else
Result := nil; // PKCS#1 v1.5 και ECDSA: το AlgIdWithParams γράφει NULL
end;
Δύο λεπτομέρειες της γύρω μηχανής μετράνε. Το TDerWriter.AlgId παράγει AlgorithmIdentifier με παραμέτρους NULL, που είναι ό,τι λέει το RFC 4055 §2.1 στους encoders να παράγουν για το φωλιασμένο hashAlgorithm και για το εσωτερικό hash του MGF1. Και ο builder CMS στο FPdfCms.pas ζευγαρώνει το OID υπογραφής με αυτά τα bytes μέσω TDerWriter.AlgIdWithParams, που υποκαθιστά NULL όταν οι παράμετροι είναι nil· γι' αυτό τα psRsaPkcs1v15 και psEcdsa απλώς γυρνάνε nil και δεν επηρεάστηκαν ποτέ, και γι' αυτό το 1.2.840.113549.1.1.10, id-RSASSA-PSS, είναι το μόνο OID υπογραφής από τα τρία που κουβαλάει πραγματικό block παραμέτρων. Τα προκύπτοντα bytes για το profile SHA-256 είναι σταθερά και αρκετά κοντά για έλεγχο με το μάτι: εξωτερική SEQUENCE 30 34 που κρατά A0 0F γύρω από το AlgorithmIdentifier SHA-256 15 bytes, A1 1C γύρω από το AlgorithmIdentifier MGF1 28 bytes του οποίου οι ίδιες παράμετροι είναι εκείνο το ίδιο AlgorithmIdentifier SHA-256, και A2 03 02 01 20 για το salt. Αν ένα dump του signatureAlgorithm σου δείχνει 02 01 20 στο top level της SEQUENCE παραμέτρων αντί μέσα σε A2, κοιτάζεις την κωδικοποίηση 3.114.19
Γιατί η σουίτα tests δεν έπιασε κακοσχηματισμένο AlgorithmIdentifier;
Επειδή τα tests PAdES κινούν τον builder CMS μέσω fake signer που αναφέρει sha256WithRSAEncryption και γυρνά nil από το GetSignatureAlgorithmParams, οπότε το block παραμέτρων PSS δεν χτίστηκε ποτέ μέσα σε test καθόλου. Εκείνο είναι λογικό design για tests που πρέπει να τρέξουν χωρίς certificate store, Keychain ή token, και έχει τυφλό σημείο με ακριβές σχήμα: οτιδήποτε μόνο ένα πραγματικό backend παράγει εξασκείται μόνο από πραγματικό backend. Το δεύτερο επίπεδο είναι πιο ενδιαφέρον. Το PDFium Component τοποθετεί επίσης το AlgorithmIdentifier υπογραφής, παράμετρους συμπεριλαμβανομένων, μέσα στο υπογεγραμμένο attribute cmsAlgorithmProtection από το RFC 6211, και ένας verifier συγκρίνει εκείνο το αντίγραφο με το εξωτερικό signatureAlgorithm. Και τα δύο αντίγραφα προέρχονταν από την ίδια κλήση, οπότε ταιριάζανε τέλεια και κάθε εσωτερικός έλεγχος συνέπειας περνούσε. Η κωδικοποίηση ήταν αυτοσυνεπής και λάθος, η κατηγορία bug που καμία ποσότητα σύγκρισης δομής με τον εαυτό της δεν μπορεί να αποκαλύψει, και το ίδιο μάθημα με διαφορετική δομή λέγεται στο CMS signedAttrs και DER SET OF sorting, όπου SET hashed σε μία σειρά και εκπεμπόμενο σε άλλη έμοιαζε μια χαρά μέχρι ξένος verifier να ξαναϋπολογίσει το hash
Αυτό που πιάνει αυτή την κατηγορία bug είναι decoder γραμμένος όχι από τον συγγραφέα του encoder, εκτελεσμένος απέναντι στην πραγματική έξοδο του πραγματικού backend. Το Windows verification path στο PDFium Component περνά από CryptoAPI και όχι από τον δικό του reader της βιβλιοθήκης, και μια απορριφθείσα υπογραφή PSS εκεί είναι αυτή που οδήγησε πίσω στις παραμέτρους. Οποιοδήποτε ASN.1 εκπέμπει μια υλοποίηση για να το διαβάσουν άλλες υλοποιήσεις αξίζει τουλάχιστον ένα round trip μέσω decoder που δεν ελέγχει, και όσο περισσότερα defaults και tags έχει η δομή, τόσο περισσότερο αξίζει εκείνο το round trip
Πού χωράει αυτό στην υπόλοιπη ιστορία PSS
Αυτό το fix είναι ανεξάρτητο από τα άλλα δύο σημεία όπου το PSS μπορεί να πάει στραβά σε υπογραφή PAdES, και το να μείνουν χωριστά κονταίνει το debugging. Το macOS backend μπορεί να βρει ότι συγκεκριμένο κλειδί ή παλαιότερο σύστημα αρνείται PSS και να υποβαθμίσει σε PKCS#1 v1.5, και το AlgorithmIdentifier πρέπει να ακολουθήσει την υποβάθμιση· εκείνο είναι ερώτημα δυνατοτήτων, που καλύπτεται στο υπογραφή PAdES με ταυτότητα macOS Keychain. Το PKCS#11 backend μπορεί να δώσει στο token CK_RSA_PKCS_PSS_PARAMS του οποίου το layout το token διαβάζει διαφορετικά λόγω ασυμφωνίας πλάτους ακεραίου· εκείνο είναι ερώτημα ABI, που καλύπτεται στο CK_ULONG και η παγίδα packing PKCS#11. Το άρθρο αυτό αφορά την τρίτη αποτυχία, όπου το κλειδί ήταν πρόθυμο, το token υπολόγισε τα σωστά bytes, και το DER που περιέγραφε το αποτέλεσμα δεν ήταν κατά το RFC 4055 §3.1
Ένας signer που δηλώνει PSS αναλαμβάνει υποχρέωση που ο v1.5 signer δεν είχε ποτέ: να περιγράψει τις δικές του παραμέτρους σε μορφή που άλλη υλοποίηση αποκωδικοποιεί στις ίδιες τρεις τιμές. Το RFC 4055 §3.1 καρφώνει τα tags, το X.690 §11.5 καρφώνει ποια πεδία επιτρέπεται να εμφανιστούν, και το ETSI TS 119 312 §7 καρφώνει τις τιμές που αξίζει να διαλέξεις. Και τα τρία backends του PDFium Delphi component κυκλοφορούν ως source, οπότε το σώμα GetSignatureAlgorithmParams παραπάνω είναι αυτό που μπορείς να διαβάσεις, να κάνεις dump και να συγκρίνεις με τον δικό σου verifier αντί να το δεχτείς στα τυφλά