Skip to main content

pdf_objects/
pubsec.rs

1//! Public-key security handler for PDFs that use `/Filter /Adobe.PubSec`.
2//!
3//! Unlike the password-based [`StandardSecurityHandler`], the file
4//! encryption key is not derived from a password hash. The producer
5//! wraps a 20-byte seed (plus 4-byte permission word) inside a CMS
6//! `EnvelopedData` blob using each authorized recipient's RSA public
7//! key. Decryption requires the recipient's X.509 certificate (to find
8//! the right blob) plus its RSA private key (to unwrap the seed).
9//!
10//! The file encryption key is then derived from the unwrapped seed
11//! together with all recipient blobs concatenated:
12//! - V=4 (`/SubFilter /adbe.pkcs7.s4`):
13//!   `SHA-1(seed[0..20] ‖ recipients_blobs ‖ permission_bytes)` truncated
14//!   to the 16-byte AES-128 key.
15//! - V=5 (`/SubFilter /adbe.pkcs7.s5`):
16//!   `SHA-256(seed[0..20] ‖ recipients_blobs ‖ permission_bytes)`
17//!   truncated to the 32-byte AES-256 key.
18//!
19//! Once the file key is derived the rest of the decryption pipeline
20//! (per-object key derivation for V=4, direct file-key use for V=5,
21//! AES-CBC-PKCS#7 unwrap) is identical to the Standard handler, so
22//! [`open_pubsec`] returns a [`StandardSecurityHandler`] built via
23//! [`StandardSecurityHandler::from_file_key`].
24
25use cms::content_info::ContentInfo;
26use cms::enveloped_data::{EnvelopedData, RecipientIdentifier, RecipientInfo};
27use const_oid::ObjectIdentifier;
28use der::{Decode, Encode};
29use rsa::{Oaep, Pkcs1v15Encrypt, RsaPrivateKey};
30use sha1::{Digest as Sha1Digest, Sha1};
31use sha2::Sha256;
32use x509_cert::Certificate;
33
34use crate::crypto::{CryptMethod, StandardSecurityHandler, resolve_v4_crypt_filters};
35use crate::error::{PdfError, PdfResult};
36use crate::types::{PdfDictionary, PdfValue};
37
38/// RSA-PKCS1v15 (rsaEncryption).
39const OID_RSA_ENCRYPTION: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1");
40/// RSA-OAEP (id-RSAES-OAEP).
41const OID_RSA_OAEP: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.7");
42/// CMS EnvelopedData content type.
43const OID_ENVELOPED_DATA: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.7.3");
44
45/// Caller-supplied X.509 credential pair. Both buffers are DER-encoded:
46/// the certificate is a standard X.509 v3 cert; the private key is a
47/// PKCS#8 `PrivateKeyInfo` (or, less commonly, PKCS#1 `RSAPrivateKey`).
48pub struct PubSecCredential<'a> {
49    pub certificate_der: &'a [u8],
50    pub private_key_der: &'a [u8],
51}
52
53/// Authenticates against the `/Encrypt` dictionary of an Adobe.PubSec
54/// PDF and returns a configured decryption handler. Returns
55/// [`PdfError::InvalidPassword`] when no recipient blob unwraps with the
56/// supplied private key (matches the Standard handler's "credential did
57/// not authenticate" error semantics).
58pub fn open_pubsec(
59    encrypt_dict: &PdfDictionary,
60    credential: &PubSecCredential,
61) -> PdfResult<StandardSecurityHandler> {
62    let v = encrypt_dict
63        .get("V")
64        .and_then(PdfValue::as_integer)
65        .unwrap_or(0);
66    let sub_filter = encrypt_dict
67        .get("SubFilter")
68        .and_then(PdfValue::as_name)
69        .unwrap_or("");
70
71    if !matches!(sub_filter, "adbe.pkcs7.s4" | "adbe.pkcs7.s5") {
72        return Err(PdfError::Unsupported(format!(
73            "Adobe.PubSec /SubFilter /{sub_filter} is not supported (only adbe.pkcs7.s4 and adbe.pkcs7.s5)"
74        )));
75    }
76
77    // Locate /Recipients. For s4 it lives at the top level of the
78    // encrypt dict; for s5 it lives inside the active /CF crypt-filter
79    // dictionary.
80    let recipient_blobs = collect_recipient_blobs(encrypt_dict, sub_filter, v)?;
81    if recipient_blobs.is_empty() {
82        return Err(PdfError::Corrupt(
83            "Adobe.PubSec /Encrypt has no /Recipients".to_string(),
84        ));
85    }
86
87    // Parse caller's certificate so we can match it against each
88    // recipient's RecipientIdentifier.
89    let recipient_cert = Certificate::from_der(credential.certificate_der).map_err(|err| {
90        PdfError::Corrupt(format!("recipient certificate is not valid DER: {err}"))
91    })?;
92    // Load the RSA private key. Try PKCS#8 first (typical), then
93    // PKCS#1 RSAPrivateKey.
94    let private_key = load_rsa_private_key(credential.private_key_der)?;
95
96    // Concatenated recipient blob bytes feed into the file-key hash for
97    // both s4 and s5; capture them verbatim from the PDF before we
98    // start any RSA decryption.
99    let mut recipients_buffer: Vec<u8> = Vec::new();
100    for blob in &recipient_blobs {
101        recipients_buffer.extend_from_slice(blob);
102    }
103
104    // Try each recipient blob until one of them unwraps with our key.
105    let mut decrypted_seed_and_perms: Option<Vec<u8>> = None;
106    for blob in &recipient_blobs {
107        if let Some(plaintext) = try_unwrap_recipient(blob, &recipient_cert, &private_key)? {
108            decrypted_seed_and_perms = Some(plaintext);
109            break;
110        }
111    }
112
113    let plaintext = decrypted_seed_and_perms.ok_or(PdfError::InvalidPassword)?;
114    if plaintext.len() < 24 {
115        return Err(PdfError::Corrupt(
116            "decrypted PubSec seed must be at least 24 bytes (20-byte seed + 4-byte permissions)"
117                .to_string(),
118        ));
119    }
120    let seed = &plaintext[..20];
121    let permission_bytes = &plaintext[20..24];
122
123    // Derive the file encryption key per SubFilter.
124    let file_key: Vec<u8> = match sub_filter {
125        "adbe.pkcs7.s4" => {
126            let mut hasher = Sha1::new();
127            hasher.update(seed);
128            hasher.update(&recipients_buffer);
129            hasher.update(permission_bytes);
130            hasher.finalize().to_vec()
131        }
132        "adbe.pkcs7.s5" => {
133            let mut hasher = Sha256::new();
134            hasher.update(seed);
135            hasher.update(&recipients_buffer);
136            hasher.update(permission_bytes);
137            hasher.finalize().to_vec()
138        }
139        _ => unreachable!("sub_filter validated above"),
140    };
141
142    // Choose crypt methods.
143    let (string_method, stream_method) = match v {
144        4 => resolve_v4_crypt_filters(encrypt_dict)?,
145        5 => (CryptMethod::AesV3, CryptMethod::AesV3),
146        other => {
147            return Err(PdfError::Unsupported(format!(
148                "Adobe.PubSec V={other} is not supported (only V=4 and V=5)"
149            )));
150        }
151    };
152
153    // Truncate the file key to the symmetric algorithm's key length.
154    let key_length_bytes = match v {
155        4 => 16,
156        5 => 32,
157        _ => unreachable!("v validated above"),
158    };
159    let truncated_file_key = file_key[..key_length_bytes.min(file_key.len())].to_vec();
160
161    let encrypt_metadata = encrypt_dict
162        .get("EncryptMetadata")
163        .and_then(PdfValue::as_bool)
164        .unwrap_or(true);
165
166    Ok(StandardSecurityHandler::from_file_key(
167        truncated_file_key,
168        string_method,
169        stream_method,
170        encrypt_metadata,
171    ))
172}
173
174/// Return all recipient blob byte strings as raw bytes. For V=4 they
175/// live at `encrypt_dict["Recipients"]`; for V=5 they live inside the
176/// crypt filter named by `encrypt_dict["StmF"]` (or "DefaultCryptFilter"
177/// fallback) under `encrypt_dict["CF"]`.
178fn collect_recipient_blobs(
179    encrypt_dict: &PdfDictionary,
180    sub_filter: &str,
181    v: i64,
182) -> PdfResult<Vec<Vec<u8>>> {
183    let array = match (v, sub_filter) {
184        (4, "adbe.pkcs7.s4") => encrypt_dict.get("Recipients").and_then(PdfValue::as_array),
185        (5, "adbe.pkcs7.s5") => {
186            let cf = encrypt_dict
187                .get("CF")
188                .and_then(PdfValue::as_dictionary)
189                .ok_or_else(|| {
190                    PdfError::Corrupt("Adobe.PubSec V=5 requires /CF dictionary".to_string())
191                })?;
192            let stmf = encrypt_dict
193                .get("StmF")
194                .and_then(PdfValue::as_name)
195                .unwrap_or("DefaultCryptFilter");
196            let filter_dict = cf
197                .get(stmf)
198                .and_then(PdfValue::as_dictionary)
199                .ok_or_else(|| {
200                    PdfError::Corrupt(format!(
201                        "Adobe.PubSec V=5 /CF entry /{stmf} is missing or not a dictionary"
202                    ))
203                })?;
204            filter_dict.get("Recipients").and_then(PdfValue::as_array)
205        }
206        _ => {
207            return Err(PdfError::Unsupported(format!(
208                "Adobe.PubSec V={v} /SubFilter /{sub_filter} combination is not supported"
209            )));
210        }
211    };
212
213    let array = array.ok_or_else(|| {
214        PdfError::Corrupt(format!(
215            "Adobe.PubSec /Recipients array is missing for /SubFilter /{sub_filter}"
216        ))
217    })?;
218
219    let mut blobs = Vec::with_capacity(array.len());
220    for entry in array {
221        let bytes = match entry {
222            PdfValue::String(s) => s.0.clone(),
223            _ => {
224                return Err(PdfError::Corrupt(
225                    "Adobe.PubSec /Recipients entry must be a byte string".to_string(),
226                ));
227            }
228        };
229        blobs.push(bytes);
230    }
231    Ok(blobs)
232}
233
234/// Try to unwrap one recipient blob with the caller's RSA private key.
235/// Returns `Ok(Some(seed_and_perms))` when the blob's
236/// `RecipientIdentifier` matches the caller's certificate AND the full
237/// CMS decryption succeeds (RSA-unwrap of the CEK followed by symmetric
238/// decryption of the inner content). Returns `Ok(None)` when the blob
239/// is for a different recipient; `Err` for malformed CMS, an unsupported
240/// inner cipher, or a key-format mismatch.
241fn try_unwrap_recipient(
242    blob: &[u8],
243    recipient_cert: &Certificate,
244    private_key: &RsaPrivateKey,
245) -> PdfResult<Option<Vec<u8>>> {
246    let content_info = ContentInfo::from_der(blob).map_err(|err| {
247        PdfError::Corrupt(format!(
248            "Adobe.PubSec recipient blob is not a valid CMS ContentInfo: {err}"
249        ))
250    })?;
251    if content_info.content_type != OID_ENVELOPED_DATA {
252        return Err(PdfError::Corrupt(format!(
253            "Adobe.PubSec recipient blob has wrong CMS content type {:?}",
254            content_info.content_type
255        )));
256    }
257    let inner_der = content_info
258        .content
259        .to_der()
260        .map_err(|err| PdfError::Corrupt(format!("CMS inner re-encode failed: {err}")))?;
261    let enveloped = EnvelopedData::from_der(&inner_der)
262        .map_err(|err| PdfError::Corrupt(format!("CMS EnvelopedData decode failed: {err}")))?;
263
264    let mut content_encryption_key: Option<Vec<u8>> = None;
265    for ri in enveloped.recip_infos.0.iter() {
266        let ktri = match ri {
267            RecipientInfo::Ktri(ktri) => ktri,
268            RecipientInfo::Kari(_) => {
269                return Err(PdfError::Unsupported(
270                    "Adobe.PubSec key-agreement recipients (KeyAgreeRecipientInfo) are not supported"
271                        .to_string(),
272                ));
273            }
274            _ => continue, // PWRI / KEKRI / OtherRecipientInfo — skip.
275        };
276
277        if !rid_matches(&ktri.rid, recipient_cert) {
278            continue;
279        }
280
281        let cek = rsa_decrypt(private_key, ktri.key_enc_alg.oid, ktri.enc_key.as_bytes())?;
282        content_encryption_key = Some(cek);
283        break;
284    }
285
286    let Some(cek) = content_encryption_key else {
287        return Ok(None);
288    };
289
290    // Decrypt the inner symmetric layer: AES-CBC over (seed || perms).
291    let alg = &enveloped.encrypted_content.content_enc_alg;
292    let ciphertext = enveloped
293        .encrypted_content
294        .encrypted_content
295        .as_ref()
296        .ok_or_else(|| {
297            PdfError::Corrupt("CMS EnvelopedData encryptedContent is missing".to_string())
298        })?
299        .as_bytes();
300    let iv_param = alg.parameters.as_ref().ok_or_else(|| {
301        PdfError::Corrupt("CMS content encryption algorithm has no parameters".to_string())
302    })?;
303    let iv_bytes = iv_param
304        .decode_as::<der::asn1::OctetString>()
305        .map_err(|err| {
306            PdfError::Corrupt(format!(
307                "CMS content encryption IV is not an OCTET STRING: {err}"
308            ))
309        })?;
310    let iv = iv_bytes.as_bytes();
311
312    let plaintext = decrypt_cms_inner(alg.oid, &cek, iv, ciphertext)?;
313    Ok(Some(plaintext))
314}
315
316/// AES-CBC PKCS#7 decrypt for the inner content layer of CMS
317/// `EnvelopedData`. Supports AES-128 / AES-192 / AES-256 (selected by
318/// the algorithm OID). The IV is supplied separately because CMS
319/// puts it in the algorithm parameters, not embedded as a prefix.
320fn decrypt_cms_inner(
321    algorithm_oid: ObjectIdentifier,
322    cek: &[u8],
323    iv: &[u8],
324    ciphertext: &[u8],
325) -> PdfResult<Vec<u8>> {
326    use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
327    use aes::{Aes128, Aes192, Aes256};
328
329    if iv.len() != 16 {
330        return Err(PdfError::Corrupt(format!(
331            "CMS AES-CBC IV must be 16 bytes, got {}",
332            iv.len()
333        )));
334    }
335    if ciphertext.is_empty() || ciphertext.len() % 16 != 0 {
336        return Err(PdfError::Corrupt(format!(
337            "CMS AES-CBC ciphertext length {} is not a positive multiple of 16",
338            ciphertext.len()
339        )));
340    }
341
342    const AES_128_CBC: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.1.2");
343    const AES_192_CBC: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.1.22");
344    const AES_256_CBC: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.1.42");
345
346    let mut prev: [u8; 16] = iv.try_into().expect("iv length checked");
347    let mut output = Vec::with_capacity(ciphertext.len());
348
349    macro_rules! decrypt_with {
350        ($cipher:ty, $expected_key:expr) => {{
351            if cek.len() != $expected_key {
352                return Err(PdfError::Corrupt(format!(
353                    "CEK length {} does not match algorithm key size {}",
354                    cek.len(),
355                    $expected_key
356                )));
357            }
358            let cipher = <$cipher>::new_from_slice(cek)
359                .map_err(|err| PdfError::Corrupt(format!("AES init failed: {err}")))?;
360            for chunk in ciphertext.chunks(16) {
361                let mut block = GenericArray::clone_from_slice(chunk);
362                cipher.decrypt_block(&mut block);
363                for (plain_byte, iv_byte) in block.iter_mut().zip(prev.iter()) {
364                    *plain_byte ^= iv_byte;
365                }
366                output.extend_from_slice(block.as_slice());
367                prev.copy_from_slice(chunk);
368            }
369        }};
370    }
371
372    if algorithm_oid == AES_128_CBC {
373        decrypt_with!(Aes128, 16);
374    } else if algorithm_oid == AES_192_CBC {
375        decrypt_with!(Aes192, 24);
376    } else if algorithm_oid == AES_256_CBC {
377        decrypt_with!(Aes256, 32);
378    } else {
379        return Err(PdfError::Unsupported(format!(
380            "CMS content encryption algorithm {algorithm_oid} is not supported (only AES-CBC)"
381        )));
382    }
383
384    // Strip PKCS#7 padding.
385    let pad = *output.last().ok_or_else(|| {
386        PdfError::Corrupt("CMS AES-CBC plaintext is empty after decrypt".to_string())
387    })?;
388    if pad == 0 || pad > 16 || pad as usize > output.len() {
389        return Err(PdfError::Corrupt(format!(
390            "invalid PKCS#7 padding length {pad} in CMS plaintext"
391        )));
392    }
393    let new_len = output.len() - pad as usize;
394    output.truncate(new_len);
395    Ok(output)
396}
397
398/// Compare a CMS `RecipientIdentifier` to the caller's certificate.
399/// `IssuerAndSerialNumber` must match issuer DN + serial; `SubjectKeyIdentifier`
400/// must equal the cert's `subjectKeyIdentifier` extension if present.
401fn rid_matches(rid: &RecipientIdentifier, cert: &Certificate) -> bool {
402    match rid {
403        RecipientIdentifier::IssuerAndSerialNumber(iasn) => {
404            iasn.issuer == cert.tbs_certificate.issuer
405                && iasn.serial_number == cert.tbs_certificate.serial_number
406        }
407        RecipientIdentifier::SubjectKeyIdentifier(ski) => {
408            // Walk the cert's extensions looking for SKI.
409            let Some(extensions) = cert.tbs_certificate.extensions.as_ref() else {
410                return false;
411            };
412            for ext in extensions {
413                if ext.extn_id == const_oid::db::rfc5912::ID_CE_SUBJECT_KEY_IDENTIFIER {
414                    return ext.extn_value.as_bytes() == ski.0.as_bytes();
415                }
416            }
417            false
418        }
419    }
420}
421
422/// RSA-decrypt the recipient's encrypted key. PKCS1v15 is the default
423/// for Acrobat output; OAEP is rare but spec-permitted.
424fn rsa_decrypt(
425    private_key: &RsaPrivateKey,
426    algorithm_oid: ObjectIdentifier,
427    ciphertext: &[u8],
428) -> PdfResult<Vec<u8>> {
429    if algorithm_oid == OID_RSA_ENCRYPTION {
430        private_key
431            .decrypt(Pkcs1v15Encrypt, ciphertext)
432            .map_err(|err| PdfError::Corrupt(format!("RSA-PKCS1v15 unwrap failed: {err}")))
433    } else if algorithm_oid == OID_RSA_OAEP {
434        private_key
435            .decrypt(Oaep::new::<Sha1>(), ciphertext)
436            .map_err(|err| PdfError::Corrupt(format!("RSA-OAEP unwrap failed: {err}")))
437    } else {
438        Err(PdfError::Unsupported(format!(
439            "Adobe.PubSec key-encryption OID {algorithm_oid} is not supported"
440        )))
441    }
442}
443
444/// Load a DER-encoded RSA private key. Accepts PKCS#8 `PrivateKeyInfo`
445/// (most common for browser-side PEM-to-DER conversions) and falls back
446/// to PKCS#1 `RSAPrivateKey` when PKCS#8 parsing fails.
447fn load_rsa_private_key(der: &[u8]) -> PdfResult<RsaPrivateKey> {
448    use rsa::pkcs1::DecodeRsaPrivateKey;
449    use rsa::pkcs8::DecodePrivateKey;
450
451    if let Ok(key) = RsaPrivateKey::from_pkcs8_der(der) {
452        return Ok(key);
453    }
454    RsaPrivateKey::from_pkcs1_der(der).map_err(|err| {
455        PdfError::Corrupt(format!(
456            "private key is neither valid PKCS#8 nor PKCS#1 RSA DER: {err}"
457        ))
458    })
459}