Skip to main content

oxideav_pdf/pubsec/
encode.rs

1//! PDF public-key security handler — *writer / encoder* side
2//! (round 11).
3//!
4//! Mirrors [`super::open_with_certificate`]: starting from a
5//! [`PubSecEncoderConfig`] (a SubFilter selection + a list of
6//! [`PubSecRecipient`]s, each carrying an X.509 cert + RSA public
7//! key), produce
8//!
9//! 1. A symmetric file encryption key the writer feeds to the
10//!    same [`crate::decrypt::StandardHandler`] the password-based
11//!    encoder uses for per-object string + stream encryption.
12//! 2. A CMS `EnvelopedData` (one per envelope; each envelope wraps
13//!    the same content-encryption key to every recipient slot)
14//!    encoded into a `/Recipients`-array blob.
15//! 3. The `/Encrypt` dictionary literal that goes into the trailer
16//!    (Filter `/Adobe.PPKLite` + SubFilter + V/R/Length/P/CF/StmF/StrF
17//!    /Recipients shaping per ISO 32000-1 §7.6.4 + ISO 32000-2 §7.6.5).
18//!
19//! Provenance: ISO 32000-1 §7.6.4 + ISO 32000-2 §7.6.5 + RFC 5652 §6
20//! + RFC 5280 §4.2.1.2 only.
21
22use crate::decrypt::{CryptMethod, StandardHandler};
23use crate::error::PdfError;
24use crate::objects::{Dict, Object};
25
26use super::cms_build::{
27    build_envelope_aes128, build_envelope_aes256, build_envelope_rc4, rsa_pkcs1_encrypt,
28    RecipientIdRef, RecipientPlain,
29};
30use super::PubSecSubFilter;
31
32/// Recipient identification + public key for an emitted public-key
33/// envelope. Either form of RFC 5652 §6.2.1 RecipientIdentifier is
34/// supported — `IssuerAndSerial` (CMS v0) or `SubjectKeyIdentifier`
35/// (CMS v2).
36#[derive(Debug, Clone)]
37pub struct PubSecRecipient {
38    /// Recipient identifier — IAS or SKI.
39    pub rid: RecipientIdRef,
40    /// Recipient's RSA public key. Used to wrap the
41    /// content-encryption key with `RSAES-PKCS1-v1_5`.
42    pub public_key: rsa::RsaPublicKey,
43}
44
45impl PubSecRecipient {
46    /// Build a recipient from an `IssuerAndSerialNumber` pair plus an
47    /// `rsa::RsaPublicKey`.
48    pub fn from_issuer_and_serial(
49        issuer_der: Vec<u8>,
50        serial: Vec<u8>,
51        public_key: rsa::RsaPublicKey,
52    ) -> Self {
53        Self {
54            rid: RecipientIdRef::IssuerAndSerial { issuer_der, serial },
55            public_key,
56        }
57    }
58
59    /// Build a recipient from a SubjectKeyIdentifier (20-byte SHA-1 of
60    /// the cert's SPKI BIT STRING contents — RFC 5280 §4.2.1.2 method
61    /// 1) plus an `rsa::RsaPublicKey`.
62    pub fn from_subject_key_identifier(ski: Vec<u8>, public_key: rsa::RsaPublicKey) -> Self {
63        Self {
64            rid: RecipientIdRef::SubjectKeyIdentifier { ski },
65            public_key,
66        }
67    }
68
69    /// Build a recipient from a parsed [`super::x509::Certificate`].
70    /// Uses the `IssuerAndSerial` form by default; callers wanting
71    /// SKI matching should use [`Self::from_subject_key_identifier`]
72    /// after pulling the SKI out of the cert.
73    pub fn from_certificate(
74        cert: &super::x509::Certificate,
75        public_key: rsa::RsaPublicKey,
76    ) -> Self {
77        Self::from_issuer_and_serial(cert.issuer_der.clone(), cert.serial.clone(), public_key)
78    }
79}
80
81/// Writer-side configuration for the public-key security handler.
82/// Picks one of the four PDF SubFilters and lists the recipients
83/// that may open the resulting file.
84#[derive(Debug, Clone)]
85pub struct PubSecEncoderConfig {
86    /// SubFilter — selects symmetric algorithm + (V, R) pair.
87    pub sub_filter: PubSecSubFilter,
88    /// 32-bit signed permissions value (§7.6.3.2 Table 22). Same
89    /// shape as the password-handler's `EncryptionConfig::p`.
90    pub p: i32,
91    /// Whether the document metadata stream is encrypted (R≥4).
92    /// Wired into both the `/EncryptMetadata` dict entry and the
93    /// `0xFFFFFFFF` opt-in tail of the SHA-1 / SHA-256 file-key
94    /// derivation when false (§7.6.4.3 / §7.6.5.3).
95    pub encrypt_metadata: bool,
96    /// Recipients that may open the document. Each gets its own
97    /// `KeyTransRecipientInfo` slot in the CMS `EnvelopedData` —
98    /// the wrapped CEK is the same content-encryption key for every
99    /// recipient in the same envelope, so any one of them can open
100    /// the PDF.
101    pub recipients: Vec<PubSecRecipient>,
102    /// 20-byte seed prefixed to the envelope plaintext. Pinned for
103    /// determinism in tests; production callers should use a fresh
104    /// random per file.
105    pub seed: [u8; 20],
106    /// Content-encryption key (CEK). Length must match the SubFilter:
107    /// 16 bytes for s3 (RC4-40 keys are 16 bytes per ISO 32000-1
108    /// §7.6.4.3 — the 40-bit subset is selected via /Length only),
109    /// 16 bytes for s4 / s5-V4-AESV2, 32 bytes for s5-V5-AESV3.
110    pub cek: Vec<u8>,
111    /// AES CBC IV for the envelope's encrypted content (s5 only).
112    /// Ignored for s3 / s4 (RC4 — no IV).
113    pub envelope_iv: [u8; 16],
114    /// IV used for per-object AES encryption (16 bytes). Tests pin;
115    /// production callers should override per-object.
116    pub aes_iv: [u8; 16],
117}
118
119impl PubSecEncoderConfig {
120    /// Default config for `adbe.pkcs7.s4` (RC4-128, V=2, SHA-1).
121    pub fn pkcs7_s4(recipients: Vec<PubSecRecipient>) -> Self {
122        Self {
123            sub_filter: PubSecSubFilter::Pkcs7S4,
124            p: -4,
125            encrypt_metadata: true,
126            recipients,
127            seed: [0x33; 20],
128            cek: vec![0xA1u8; 16],
129            envelope_iv: [0; 16],
130            aes_iv: [0; 16],
131        }
132    }
133
134    /// Default config for `adbe.pkcs7.s5` V=4 + AESV2 (AES-128, SHA-1).
135    pub fn pkcs7_s5_v4_aes128(recipients: Vec<PubSecRecipient>) -> Self {
136        Self {
137            sub_filter: PubSecSubFilter::Pkcs7S5V4 { aes: true },
138            p: -4,
139            encrypt_metadata: true,
140            recipients,
141            seed: [0x44; 20],
142            cek: vec![0xB2u8; 16],
143            envelope_iv: [0x77; 16],
144            aes_iv: [0; 16],
145        }
146    }
147
148    /// Default config for `adbe.pkcs7.s5` V=5 + AESV3 (AES-256, SHA-256).
149    pub fn pkcs7_s5_v5_aes256(recipients: Vec<PubSecRecipient>) -> Self {
150        Self {
151            sub_filter: PubSecSubFilter::Pkcs7S5V5,
152            p: -4,
153            encrypt_metadata: true,
154            recipients,
155            seed: [0x55; 20],
156            cek: vec![0xC3u8; 32],
157            envelope_iv: [0x77; 16],
158            aes_iv: [0; 16],
159        }
160    }
161}
162
163/// Result of building the writer-side public-key state — symmetric to
164/// the password-based [`crate::encrypt::EncryptionState`]. Callers
165/// install the handler / encrypt_dict on a `Document` exactly as the
166/// password-based encoder does.
167#[derive(Debug, Clone)]
168pub struct PubSecEncryptionState {
169    /// File encryption handler (key + per-object method + revision).
170    pub handler: StandardHandler,
171    /// `/Encrypt` dictionary literal to thread into the trailer.
172    pub encrypt_dict: Dict,
173    /// Per-object AES IV.
174    pub aes_iv: [u8; 16],
175    /// Permanent file identifier — placed in `/ID[0]`. Public-key
176    /// PDFs still need an /ID array; we generate a deterministic
177    /// 16-byte one (or accept a caller-supplied override via
178    /// [`crate::write_pdf_from_scene_pubsec_encrypted`]).
179    pub file_id: Vec<u8>,
180}
181
182impl PubSecEncryptionState {
183    /// Convert to the password-handler [`crate::encrypt::EncryptionState`]
184    /// shape so the writer can install it on `Document::encryption`
185    /// without duplicating the per-object encryption paths. The
186    /// resulting state's `encrypt_dict` is `/Filter /Adobe.PPKLite`
187    /// (not `/Filter /Standard`) — every other slot is identical.
188    pub fn into_encryption_state(self) -> crate::encrypt::EncryptionState {
189        crate::encrypt::EncryptionState {
190            handler: self.handler,
191            encrypt_dict: self.encrypt_dict,
192            file_id: self.file_id,
193            aes_iv: self.aes_iv,
194        }
195    }
196
197    /// Build the writer-side state from a [`PubSecEncoderConfig`]. The
198    /// returned `encrypt_dict` is symmetric to what
199    /// [`super::open_with_certificate`] consumes.
200    pub fn build(config: &PubSecEncoderConfig) -> Result<Self, PdfError> {
201        if config.recipients.is_empty() {
202            return Err(PdfError::other(
203                "PDF pubsec encode: at least one recipient is required",
204            ));
205        }
206        let key_length_bits = key_length_bits(config.sub_filter);
207        let n = key_length_bits / 8;
208        if config.cek.len() != n {
209            return Err(PdfError::other(format!(
210                "PDF pubsec encode: CEK must be {} bytes for SubFilter {:?} (got {})",
211                n,
212                config.sub_filter,
213                config.cek.len()
214            )));
215        }
216
217        // Build the envelope plaintext: 20-byte seed + 4-byte
218        // permissions. ISO 32000-1 §7.6.4.3 specifies LSB-first ("least
219        // significant byte first"); ISO 32000-2 §7.6.5.3 corrects that
220        // to MSB-first. Pick by SubFilter — V=5 takes MSB.
221        let mut plaintext = Vec::with_capacity(24);
222        plaintext.extend_from_slice(&config.seed);
223        let p_bytes = match config.sub_filter {
224            PubSecSubFilter::Pkcs7S5V5 => (config.p as u32).to_be_bytes(),
225            _ => (config.p as u32).to_le_bytes(),
226        };
227        plaintext.extend_from_slice(&p_bytes);
228
229        // Pre-compute every recipient's wrapped CEK. Each recipient
230        // gets its own RSA-PKCS1-v1.5 wrap (the random RSA padding
231        // makes the encrypted_key field different per recipient even
232        // when the underlying CEK + public key match).
233        let mut slots: Vec<RecipientPlain> = Vec::with_capacity(config.recipients.len());
234        for r in &config.recipients {
235            let encrypted_key = rsa_pkcs1_encrypt(&r.public_key, &config.cek)?;
236            slots.push(RecipientPlain {
237                rid: r.rid.clone(),
238                encrypted_key,
239            });
240        }
241
242        // Build the CMS envelope DER per content-encryption algorithm.
243        let envelope_der =
244            match config.sub_filter {
245                PubSecSubFilter::Pkcs7S3 | PubSecSubFilter::Pkcs7S4 => {
246                    build_envelope_rc4(&slots, &plaintext, &config.cek)
247                }
248                PubSecSubFilter::Pkcs7S5V4 { aes: false } => {
249                    // RC4 path (CFM=V2).
250                    build_envelope_rc4(&slots, &plaintext, &config.cek)
251                }
252                PubSecSubFilter::Pkcs7S5V4 { aes: true } => {
253                    let cek16: [u8; 16] =
254                        config.cek.as_slice().try_into().map_err(|_| {
255                            PdfError::other("PDF pubsec encode: AES-128 CEK length")
256                        })?;
257                    build_envelope_aes128(&slots, &plaintext, &cek16, &config.envelope_iv)
258                }
259                PubSecSubFilter::Pkcs7S5V5 => {
260                    let cek32: [u8; 32] =
261                        config.cek.as_slice().try_into().map_err(|_| {
262                            PdfError::other("PDF pubsec encode: AES-256 CEK length")
263                        })?;
264                    build_envelope_aes256(&slots, &plaintext, &cek32, &config.envelope_iv)
265                }
266            };
267
268        // File encryption key derivation per §7.6.4.3 / §7.6.5.3.
269        let recipients_blobs = vec![envelope_der.clone()];
270        let file_key = derive_file_key(
271            config.sub_filter,
272            &config.seed,
273            &recipients_blobs,
274            config.encrypt_metadata,
275            key_length_bits,
276        );
277
278        let (method, revision) = match config.sub_filter {
279            PubSecSubFilter::Pkcs7S3 => (CryptMethod::Rc4, 2u8),
280            PubSecSubFilter::Pkcs7S4 => (CryptMethod::Rc4, 3),
281            PubSecSubFilter::Pkcs7S5V4 { aes } => (
282                if aes {
283                    CryptMethod::Aes128
284                } else {
285                    CryptMethod::Rc4
286                },
287                4,
288            ),
289            PubSecSubFilter::Pkcs7S5V5 => (CryptMethod::Aes256, 6),
290        };
291        let handler = StandardHandler {
292            key: file_key,
293            method,
294            revision,
295        };
296
297        // Build the /Encrypt dict literal.
298        let encrypt_dict = build_encrypt_dict(config, &envelope_der, key_length_bits)?;
299
300        Ok(Self {
301            handler,
302            encrypt_dict,
303            aes_iv: config.aes_iv,
304            file_id: b"OXIDEAV-PUBSEC-ID-0123456789ABCD".to_vec(),
305        })
306    }
307
308    /// Override the file ID (16+ bytes recommended).
309    pub fn with_file_id(mut self, file_id: Vec<u8>) -> Self {
310        self.file_id = file_id;
311        self
312    }
313}
314
315// ───────── Round-12: per-permission-set recipient lists ─────────
316
317/// One permission-set inside a public-key-encrypted PDF. Each group
318/// emits one PKCS#7 `EnvelopedData` whose plaintext carries the
319/// supplied permission mask `p`; only the recipients listed here can
320/// open with those permissions.
321///
322/// Per ISO 32000-1 §7.6.4.2 + §7.6.5.4: "There shall be one PKCS#7
323/// object per unique set of access permissions; if a recipient appears
324/// in more than one list, the permissions used shall be those in the
325/// first matching list." The file encryption key is derived from
326/// `SHA(seed_of_matched_envelope ‖ all_envelope_blobs_in_array_order)`
327/// — every envelope in the `/Recipients` array contributes to the
328/// hash regardless of which one matched, so all recipients share the
329/// same per-object encryption key while seeing different permissions.
330///
331/// The round-12 multi-CF encoder ALSO supports separate
332/// [`PubSecMultiCfConfig::cf_groups`] when the caller wants the
333/// `/CF` dict to enumerate distinct CRYPT FILTERS (each with its
334/// own list); see [`PubSecMultiCfConfig`] for the shape.
335#[derive(Debug, Clone)]
336pub struct PubSecCfGroup {
337    /// Name under `/CF` — typically a descriptive label like
338    /// `OwnerCryptFilter` or `ReadOnlyCryptFilter`. Must match the
339    /// dictionary-key ASCII alphabet (no `/` prefix; the encoder adds
340    /// it).
341    pub name: String,
342    /// Permission mask for this group (4-byte signed integer per ISO
343    /// 32000-1 §7.6.3.2 Table 22).
344    pub p: i32,
345    /// Recipients allowed to open with this permission set. The same
346    /// recipient may appear in multiple groups; the round-12 reader's
347    /// first-CF-match rule applies (the group order is the order
348    /// supplied here, with the StmF entry sorted to the front).
349    pub recipients: Vec<PubSecRecipient>,
350    /// Per-group seed (round-trips deterministically; production
351    /// callers should override with fresh random per group).
352    pub seed: [u8; 20],
353    /// Per-group content-encryption key. Length must match the parent
354    /// config's SubFilter (16 / 32 bytes).
355    pub cek: Vec<u8>,
356    /// Per-group AES-CBC envelope IV (s5 only).
357    pub envelope_iv: [u8; 16],
358}
359
360impl PubSecCfGroup {
361    /// Convenience constructor: full access (`p = -4`) for AES-256.
362    pub fn full_access_aes256(name: impl Into<String>, recipients: Vec<PubSecRecipient>) -> Self {
363        Self {
364            name: name.into(),
365            p: -4,
366            recipients,
367            seed: [0xA1; 20],
368            cek: vec![0xCAu8; 32],
369            envelope_iv: [0x77; 16],
370        }
371    }
372
373    /// Convenience constructor: read-only (`p` clears the print + modify
374    /// + extract bits per Table 22). The mask `0xFFFF_F0BF` corresponds
375    ///   to "view + accessibility-extract only".
376    pub fn read_only_aes256(name: impl Into<String>, recipients: Vec<PubSecRecipient>) -> Self {
377        Self {
378            name: name.into(),
379            p: i32::from_be_bytes([0xFF, 0xFF, 0xF0, 0xBF]),
380            recipients,
381            seed: [0xB2; 20],
382            cek: vec![0xDBu8; 32],
383            envelope_iv: [0x88; 16],
384        }
385    }
386}
387
388/// Configuration for a multi-permission-set public-key-encrypted PDF
389/// — one `EnvelopedData` per [`PubSecCfGroup`], all threaded into a
390/// single `/Recipients` array (which is itself referenced from one
391/// or more `/CF` entries).
392///
393/// Every group's envelope wraps the SAME 20-byte seed and the SAME
394/// content-encryption key (per ISO 32000-1 §7.6.4.3 / ISO 32000-2
395/// §7.6.5.3 — the file encryption key is derived from
396/// `SHA(seed ‖ ALL recipient blobs)`, so the seed must be identical
397/// across envelopes for every reader to converge on the same file
398/// key). Per-recipient differences surface only as different
399/// permission masks in each envelope's plaintext trailer.
400#[derive(Debug, Clone)]
401pub struct PubSecMultiCfConfig {
402    /// Symmetric algorithm + key size — `s5` only. `s3` / `s4` reject
403    /// at build time.
404    pub sub_filter: PubSecSubFilter,
405    /// Whether the document metadata stream is encrypted.
406    pub encrypt_metadata: bool,
407    /// One permission-set per `PubSecCfGroup`. Must contain at least
408    /// one group; the first group's name becomes the dict-level
409    /// `/StmF` + `/StrF` CF entry. The CFs all reference the same
410    /// `/Recipients` array (containing every group's envelope), so
411    /// any matching recipient — regardless of which CF they were
412    /// nominally tied to — recovers the file key. Each group's
413    /// `seed` field is overridden by `shared_seed` at build time to
414    /// guarantee key-derivation convergence; the per-group `seed`
415    /// slot stays in the API for forward compatibility (round-13
416    /// might add per-stream key streams).
417    pub groups: Vec<PubSecCfGroup>,
418    /// Per-object AES IV (round-trip determinism only).
419    pub aes_iv: [u8; 16],
420    /// Shared content-encryption key bytes — must match the
421    /// SubFilter's key length (16 for AES-128, 32 for AES-256).
422    pub shared_cek: Vec<u8>,
423    /// Shared 20-byte seed mixed into the file-key derivation. Must
424    /// match across every envelope or the multi-recipient story
425    /// breaks (different recipients would derive different file
426    /// keys). Defaulted by the test fixtures to `[0xA1; 20]`.
427    pub shared_seed: [u8; 20],
428}
429
430impl PubSecMultiCfConfig {
431    /// Build the writer-side state. Every group's envelope wraps the
432    /// SAME shared CEK to that group's recipients with that group's
433    /// permission mask; all envelopes go into one `/Recipients`
434    /// array. The file encryption key is derived from
435    /// `SHA(seed_of_first_group ‖ all_envelopes)` per §7.6.4.3 /
436    /// §7.6.5.3.
437    pub fn build(self) -> Result<PubSecEncryptionState, PdfError> {
438        if self.groups.is_empty() {
439            return Err(PdfError::other(
440                "PDF pubsec multi-CF: at least one group required",
441            ));
442        }
443        if !matches!(
444            self.sub_filter,
445            PubSecSubFilter::Pkcs7S5V4 { .. } | PubSecSubFilter::Pkcs7S5V5
446        ) {
447            return Err(PdfError::other(
448                "PDF pubsec multi-CF: only s5 SubFilters support per-CF recipients",
449            ));
450        }
451        let key_length_bits = key_length_bits(self.sub_filter);
452        let n = key_length_bits / 8;
453        if self.shared_cek.len() != n {
454            return Err(PdfError::other(format!(
455                "PDF pubsec multi-CF: shared CEK must be {} bytes (got {})",
456                n,
457                self.shared_cek.len()
458            )));
459        }
460        for g in &self.groups {
461            if g.recipients.is_empty() {
462                return Err(PdfError::other(format!(
463                    "PDF pubsec multi-CF: group {} has no recipients",
464                    g.name
465                )));
466            }
467        }
468        let mut group_envelopes: Vec<Vec<u8>> = Vec::with_capacity(self.groups.len());
469        for g in &self.groups {
470            let mut plaintext = Vec::with_capacity(24);
471            // The seed is SHARED across every envelope so every
472            // reader (regardless of which envelope matched) hashes
473            // over the same input and derives the same file key.
474            plaintext.extend_from_slice(&self.shared_seed);
475            // ISO 32000-2 stores MSB-first for V=5; ISO 32000-1
476            // (V≤4) stores LSB-first.
477            let p_bytes = match self.sub_filter {
478                PubSecSubFilter::Pkcs7S5V5 => (g.p as u32).to_be_bytes(),
479                _ => (g.p as u32).to_le_bytes(),
480            };
481            plaintext.extend_from_slice(&p_bytes);
482            // Each recipient gets its own RSA-wrap of the SHARED CEK.
483            let mut slots: Vec<RecipientPlain> = Vec::with_capacity(g.recipients.len());
484            for r in &g.recipients {
485                let encrypted_key = rsa_pkcs1_encrypt(&r.public_key, &self.shared_cek)?;
486                slots.push(RecipientPlain {
487                    rid: r.rid.clone(),
488                    encrypted_key,
489                });
490            }
491            let envelope = match self.sub_filter {
492                PubSecSubFilter::Pkcs7S5V4 { aes: false } => {
493                    super::cms_build::build_envelope_rc4(&slots, &plaintext, &self.shared_cek)
494                }
495                PubSecSubFilter::Pkcs7S5V4 { aes: true } => {
496                    let cek16: [u8; 16] =
497                        self.shared_cek.as_slice().try_into().map_err(|_| {
498                            PdfError::other("PDF pubsec multi-CF: AES-128 CEK length")
499                        })?;
500                    super::cms_build::build_envelope_aes128(
501                        &slots,
502                        &plaintext,
503                        &cek16,
504                        &g.envelope_iv,
505                    )
506                }
507                PubSecSubFilter::Pkcs7S5V5 => {
508                    let cek32: [u8; 32] =
509                        self.shared_cek.as_slice().try_into().map_err(|_| {
510                            PdfError::other("PDF pubsec multi-CF: AES-256 CEK length")
511                        })?;
512                    super::cms_build::build_envelope_aes256(
513                        &slots,
514                        &plaintext,
515                        &cek32,
516                        &g.envelope_iv,
517                    )
518                }
519                _ => unreachable!(),
520            };
521            group_envelopes.push(envelope);
522        }
523        // File-encryption key — derived from the SHARED seed hashed
524        // over EVERY envelope in declaration order. Any reader who
525        // matches a slot in ANY envelope recovers the same key
526        // because the seed is identical across envelopes and they
527        // all hash over the full envelope set.
528        let file_key = derive_file_key(
529            self.sub_filter,
530            &self.shared_seed,
531            &group_envelopes,
532            self.encrypt_metadata,
533            key_length_bits,
534        );
535        let (method, revision) = match self.sub_filter {
536            PubSecSubFilter::Pkcs7S5V4 { aes } => (
537                if aes {
538                    CryptMethod::Aes128
539                } else {
540                    CryptMethod::Rc4
541                },
542                4u8,
543            ),
544            PubSecSubFilter::Pkcs7S5V5 => (CryptMethod::Aes256, 6),
545            _ => unreachable!(),
546        };
547        let handler = StandardHandler {
548            key: file_key,
549            method,
550            revision,
551        };
552        let encrypt_dict = build_multi_cf_encrypt_dict(
553            self.sub_filter,
554            self.encrypt_metadata,
555            &self.groups,
556            &group_envelopes,
557            key_length_bits,
558        )?;
559        Ok(PubSecEncryptionState {
560            handler,
561            encrypt_dict,
562            aes_iv: self.aes_iv,
563            file_id: b"OXIDEAV-PUBSEC-MULTICF-ID-12345!".to_vec(),
564        })
565    }
566}
567
568/// Build the `/Encrypt` dictionary literal for a multi-permission-set
569/// public-key-encrypted PDF.
570///
571/// Each named CF in the `/CF` dict carries the FULL `/Recipients`
572/// array (every envelope, not just that CF's own group). This is the
573/// only shape that lets every reader — regardless of which envelope
574/// they matched — derive the same file encryption key per ISO 32000-1
575/// §7.6.4.3 (the hash is over the entire ordered envelope set).
576///
577/// Readers tell which CF they're "in" by which envelope they matched:
578/// the round-12 match path exposes `crypt_filter_name` so the caller
579/// can map matched-envelope index → CF group.
580fn build_multi_cf_encrypt_dict(
581    sub_filter: PubSecSubFilter,
582    encrypt_metadata: bool,
583    groups: &[PubSecCfGroup],
584    envelopes: &[Vec<u8>],
585    key_length_bits: usize,
586) -> Result<Dict, PdfError> {
587    let (sub_filter_name, v, r, cfm) = match sub_filter {
588        PubSecSubFilter::Pkcs7S5V4 { aes: true } => ("adbe.pkcs7.s5", 4, 4, "AESV2"),
589        PubSecSubFilter::Pkcs7S5V4 { aes: false } => ("adbe.pkcs7.s5", 4, 4, "V2"),
590        PubSecSubFilter::Pkcs7S5V5 => ("adbe.pkcs7.s5", 5, 6, "AESV3"),
591        _ => {
592            return Err(PdfError::other(
593                "PDF pubsec multi-CF: only s5 SubFilters supported",
594            ))
595        }
596    };
597    let cf_length_bytes = (key_length_bits / 8) as i64;
598    // Each CF entry holds the SAME (full) /Recipients array — only
599    // the per-envelope permission masks differ. This shape mirrors
600    // the way Adobe Acrobat emits multi-permission PPKLite docs.
601    let full_recipients = Object::Array(
602        envelopes
603            .iter()
604            .map(|e| Object::LiteralString(e.clone()))
605            .collect(),
606    );
607    let mut cf = Dict::new();
608    for g in groups {
609        let inner = Dict::new()
610            .with("Type", Object::Name("CryptFilter".into()))
611            .with("CFM", Object::Name(cfm.into()))
612            .with("Length", Object::Integer(cf_length_bytes))
613            .with("Recipients", full_recipients.clone());
614        cf.set(&g.name, Object::Dict(inner));
615    }
616    let stmf_name = groups[0].name.clone();
617    let mut dict = Dict::new()
618        .with("Filter", Object::Name("Adobe.PPKLite".into()))
619        .with("SubFilter", Object::Name(sub_filter_name.into()))
620        .with("V", Object::Integer(v))
621        .with("R", Object::Integer(r))
622        .with("Length", Object::Integer(key_length_bits as i64))
623        // Dict-level /P is the FIRST group's permission mask
624        // (round-trippable via the round-12 multi-match path).
625        .with("P", Object::Integer(groups[0].p as i64))
626        .with("CF", Object::Dict(cf))
627        .with("StmF", Object::Name(stmf_name.clone()))
628        .with("StrF", Object::Name(stmf_name));
629    if !encrypt_metadata {
630        dict.set("EncryptMetadata", Object::Bool(false));
631    }
632    Ok(dict)
633}
634
635fn key_length_bits(sub: PubSecSubFilter) -> usize {
636    match sub {
637        PubSecSubFilter::Pkcs7S3 => 40,
638        PubSecSubFilter::Pkcs7S4 => 128,
639        PubSecSubFilter::Pkcs7S5V4 { .. } => 128,
640        PubSecSubFilter::Pkcs7S5V5 => 256,
641    }
642}
643
644fn derive_file_key(
645    sub: PubSecSubFilter,
646    seed: &[u8],
647    recipients_blobs: &[Vec<u8>],
648    encrypt_metadata: bool,
649    key_length_bits: usize,
650) -> Vec<u8> {
651    let n = key_length_bits / 8;
652    let mut input = Vec::with_capacity(20);
653    input.extend_from_slice(seed);
654    for blob in recipients_blobs {
655        input.extend_from_slice(blob);
656    }
657    if !encrypt_metadata {
658        input.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
659    }
660    let digest: Vec<u8> = match sub {
661        PubSecSubFilter::Pkcs7S5V5 => {
662            use sha2::Digest;
663            sha2::Sha256::digest(&input).to_vec()
664        }
665        _ => {
666            use sha1::Digest;
667            sha1::Sha1::digest(&input).to_vec()
668        }
669    };
670    digest[..n.min(digest.len())].to_vec()
671}
672
673fn build_encrypt_dict(
674    config: &PubSecEncoderConfig,
675    envelope_der: &[u8],
676    key_length_bits: usize,
677) -> Result<Dict, PdfError> {
678    let (sub_filter_name, v, r) = match config.sub_filter {
679        PubSecSubFilter::Pkcs7S3 => ("adbe.pkcs7.s3", 1, 2),
680        PubSecSubFilter::Pkcs7S4 => ("adbe.pkcs7.s4", 2, 3),
681        PubSecSubFilter::Pkcs7S5V4 { .. } => ("adbe.pkcs7.s5", 4, 4),
682        PubSecSubFilter::Pkcs7S5V5 => ("adbe.pkcs7.s5", 5, 6),
683    };
684
685    let mut dict = Dict::new()
686        .with("Filter", Object::Name("Adobe.PPKLite".into()))
687        .with("SubFilter", Object::Name(sub_filter_name.into()))
688        .with("V", Object::Integer(v))
689        .with("R", Object::Integer(r))
690        .with("Length", Object::Integer(key_length_bits as i64))
691        .with("P", Object::Integer(config.p as i64));
692
693    if !config.encrypt_metadata {
694        dict.set("EncryptMetadata", Object::Bool(false));
695    }
696
697    let recipients_arr = Object::Array(vec![Object::LiteralString(envelope_der.to_vec())]);
698
699    match config.sub_filter {
700        PubSecSubFilter::Pkcs7S3 | PubSecSubFilter::Pkcs7S4 => {
701            // s3 / s4: /Recipients lives at the top level.
702            dict.set("Recipients", recipients_arr);
703        }
704        PubSecSubFilter::Pkcs7S5V4 { aes } => {
705            // s5 V=4: /Recipients lives in /CF /<F>. Per ISO 32000-1
706            // Table 27 the recipients array also appears at the
707            // top-level /Recipients for compatibility; we mirror the
708            // round-10 fixture builder which emits both.
709            let cfm = if aes { "AESV2" } else { "V2" };
710            let std_cf = Dict::new()
711                .with("Type", Object::Name("CryptFilter".into()))
712                .with("CFM", Object::Name(cfm.into()))
713                .with("Length", Object::Integer(16))
714                .with("Recipients", recipients_arr.clone());
715            let cf = Dict::new().with("DefaultCryptFilter", Object::Dict(std_cf));
716            dict.set("CF", Object::Dict(cf));
717            dict.set("StmF", Object::Name("DefaultCryptFilter".into()));
718            dict.set("StrF", Object::Name("DefaultCryptFilter".into()));
719            dict.set("Recipients", recipients_arr);
720        }
721        PubSecSubFilter::Pkcs7S5V5 => {
722            // s5 V=5: AESV3 crypt filter, recipients in /CF.
723            let std_cf = Dict::new()
724                .with("Type", Object::Name("CryptFilter".into()))
725                .with("CFM", Object::Name("AESV3".into()))
726                .with("Length", Object::Integer(32))
727                .with("Recipients", recipients_arr.clone());
728            let cf = Dict::new().with("DefaultCryptFilter", Object::Dict(std_cf));
729            dict.set("CF", Object::Dict(cf));
730            dict.set("StmF", Object::Name("DefaultCryptFilter".into()));
731            dict.set("StrF", Object::Name("DefaultCryptFilter".into()));
732            dict.set("Recipients", recipients_arr);
733        }
734    }
735
736    Ok(dict)
737}
738
739// ───────── Round-15: writer-side KARI envelope ─────────
740
741/// One KARI recipient slot the writer will emit. The recipient is
742/// identified by their X.509 certificate (issuer + serial — KARI
743/// `RecipientEncryptedKey` IAS form), and `recipient_pub_bytes` carries
744/// their public key in the curve's encoded form (SEC1 uncompressed for
745/// P-256 / P-384 / P-521, raw 32-byte u-coordinate for X25519).
746#[derive(Debug, Clone)]
747pub struct KariRecipient {
748    /// Recipient certificate's issuer DER (a SEQUENCE — same shape as
749    /// [`super::cms_build::RecipientIdRef::IssuerAndSerial::issuer_der`]).
750    pub issuer_der: Vec<u8>,
751    /// Recipient cert's serial number INTEGER body bytes.
752    pub serial: Vec<u8>,
753    /// Curve the recipient's keypair lives on. Determines the ECDH
754    /// primitive + ephemeral keypair shape the writer will emit.
755    pub curve: super::kari::KariCurve,
756    /// KDF binding the writer will encode in
757    /// `KeyAgreeRecipientInfo.keyEncryptionAlgorithm`. Defaults via the
758    /// per-curve constructors to [`super::kari::KariCurve::default_kdf`]:
759    /// RFC 5753 §7.1.4 X9.63 with the matching hash for NIST curves;
760    /// RFC 8418 §2.1 X9.63-SHA-256 for X25519. Use the
761    /// `x25519_hkdf_*` constructors to switch X25519 to the modern RFC
762    /// 8418 §2.2 HKDF binding.
763    pub kdf: super::kari::KariKdf,
764    /// Recipient's encoded public key. SEC1 uncompressed point for
765    /// NIST curves; raw 32-byte u-coordinate for X25519.
766    pub recipient_pub_bytes: Vec<u8>,
767    /// Ephemeral private scalar used for THIS recipient's wrap. Each
768    /// recipient gets its own ephemeral keypair so the writer can mix
769    /// curves across recipients in a single envelope (one KARI per
770    /// curve / ephemeral). Tests pin to a deterministic value;
771    /// production callers should use a fresh random scalar per
772    /// recipient.
773    pub ephemeral_scalar: Vec<u8>,
774}
775
776impl KariRecipient {
777    /// Build a P-256 recipient (X9.63-SHA-256 KDF per RFC 5753 §7.1.4).
778    pub fn p256(
779        issuer_der: Vec<u8>,
780        serial: Vec<u8>,
781        recipient_pub_sec1: Vec<u8>,
782        ephemeral_scalar: Vec<u8>,
783    ) -> Self {
784        Self {
785            issuer_der,
786            serial,
787            curve: super::kari::KariCurve::P256,
788            kdf: super::kari::KariKdf::X963Sha256,
789            recipient_pub_bytes: recipient_pub_sec1,
790            ephemeral_scalar,
791        }
792    }
793
794    /// Build a P-384 recipient (X9.63-SHA-384 KDF per RFC 5753 §7.1.4).
795    pub fn p384(
796        issuer_der: Vec<u8>,
797        serial: Vec<u8>,
798        recipient_pub_sec1: Vec<u8>,
799        ephemeral_scalar: Vec<u8>,
800    ) -> Self {
801        Self {
802            issuer_der,
803            serial,
804            curve: super::kari::KariCurve::P384,
805            kdf: super::kari::KariKdf::X963Sha384,
806            recipient_pub_bytes: recipient_pub_sec1,
807            ephemeral_scalar,
808        }
809    }
810
811    /// Round-16: build a P-521 recipient (X9.63-SHA-512 KDF per RFC
812    /// 5753 §7.1.4 — `dhSinglePass-stdDH-sha512kdf-scheme`,
813    /// OID 1.3.132.1.11.3).
814    pub fn p521(
815        issuer_der: Vec<u8>,
816        serial: Vec<u8>,
817        recipient_pub_sec1: Vec<u8>,
818        ephemeral_scalar: Vec<u8>,
819    ) -> Self {
820        Self {
821            issuer_der,
822            serial,
823            curve: super::kari::KariCurve::P521,
824            kdf: super::kari::KariKdf::X963Sha512,
825            recipient_pub_bytes: recipient_pub_sec1,
826            ephemeral_scalar,
827        }
828    }
829
830    /// Build an X25519 recipient with the legacy X9.63-SHA-256 KDF
831    /// binding (RFC 8418 §2.1).
832    pub fn x25519(
833        issuer_der: Vec<u8>,
834        serial: Vec<u8>,
835        recipient_pub_x25519: Vec<u8>,
836        ephemeral_scalar: Vec<u8>,
837    ) -> Self {
838        Self {
839            issuer_der,
840            serial,
841            curve: super::kari::KariCurve::X25519,
842            kdf: super::kari::KariKdf::X963Sha256,
843            recipient_pub_bytes: recipient_pub_x25519,
844            ephemeral_scalar,
845        }
846    }
847
848    /// Round-16: build an X25519 recipient with the modern HKDF-SHA-256
849    /// KDF binding (RFC 8418 §2.2 — `dhSinglePass-stdDH-hkdf-sha256-scheme`,
850    /// smime-alg 19).
851    pub fn x25519_hkdf_sha256(
852        issuer_der: Vec<u8>,
853        serial: Vec<u8>,
854        recipient_pub_x25519: Vec<u8>,
855        ephemeral_scalar: Vec<u8>,
856    ) -> Self {
857        Self {
858            issuer_der,
859            serial,
860            curve: super::kari::KariCurve::X25519,
861            kdf: super::kari::KariKdf::HkdfSha256,
862            recipient_pub_bytes: recipient_pub_x25519,
863            ephemeral_scalar,
864        }
865    }
866
867    /// Round-16: build an X25519 recipient with HKDF-SHA-384 (RFC 8418
868    /// §2.2 — `dhSinglePass-stdDH-hkdf-sha384-scheme`, smime-alg 20).
869    pub fn x25519_hkdf_sha384(
870        issuer_der: Vec<u8>,
871        serial: Vec<u8>,
872        recipient_pub_x25519: Vec<u8>,
873        ephemeral_scalar: Vec<u8>,
874    ) -> Self {
875        Self {
876            issuer_der,
877            serial,
878            curve: super::kari::KariCurve::X25519,
879            kdf: super::kari::KariKdf::HkdfSha384,
880            recipient_pub_bytes: recipient_pub_x25519,
881            ephemeral_scalar,
882        }
883    }
884
885    /// Round-16: build an X25519 recipient with HKDF-SHA-512 (RFC 8418
886    /// §2.2 — `dhSinglePass-stdDH-hkdf-sha512-scheme`, smime-alg 21).
887    pub fn x25519_hkdf_sha512(
888        issuer_der: Vec<u8>,
889        serial: Vec<u8>,
890        recipient_pub_x25519: Vec<u8>,
891        ephemeral_scalar: Vec<u8>,
892    ) -> Self {
893        Self {
894            issuer_der,
895            serial,
896            curve: super::kari::KariCurve::X25519,
897            kdf: super::kari::KariKdf::HkdfSha512,
898            recipient_pub_bytes: recipient_pub_x25519,
899            ephemeral_scalar,
900        }
901    }
902
903    /// Round-24: build an X448 recipient with the default X9.63-SHA-512
904    /// KDF binding (RFC 8418 §2.1 — `dhSinglePass-stdDH-sha512kdf-scheme`).
905    /// `recipient_pub_x448` is the recipient's 56-byte raw u-coordinate;
906    /// `ephemeral_scalar` is the 56-byte ephemeral private key (RFC 7748
907    /// §5 clamping is applied by the underlying `x448` crate at scalar
908    /// load).
909    pub fn x448(
910        issuer_der: Vec<u8>,
911        serial: Vec<u8>,
912        recipient_pub_x448: Vec<u8>,
913        ephemeral_scalar: Vec<u8>,
914    ) -> Self {
915        Self {
916            issuer_der,
917            serial,
918            curve: super::kari::KariCurve::X448,
919            kdf: super::kari::KariKdf::X963Sha512,
920            recipient_pub_bytes: recipient_pub_x448,
921            ephemeral_scalar,
922        }
923    }
924
925    /// Round-24: build an X448 recipient with the modern HKDF-SHA-256
926    /// KDF binding (RFC 8418 §2.2 — `dhSinglePass-stdDH-hkdf-sha256-scheme`,
927    /// smime-alg 19). Same shape as [`Self::x448`] but with the HKDF
928    /// flavour swapped in.
929    pub fn x448_hkdf_sha256(
930        issuer_der: Vec<u8>,
931        serial: Vec<u8>,
932        recipient_pub_x448: Vec<u8>,
933        ephemeral_scalar: Vec<u8>,
934    ) -> Self {
935        Self {
936            issuer_der,
937            serial,
938            curve: super::kari::KariCurve::X448,
939            kdf: super::kari::KariKdf::HkdfSha256,
940            recipient_pub_bytes: recipient_pub_x448,
941            ephemeral_scalar,
942        }
943    }
944
945    /// Round-24: build an X448 recipient with HKDF-SHA-384 (RFC 8418
946    /// §2.2 — `dhSinglePass-stdDH-hkdf-sha384-scheme`, smime-alg 20).
947    pub fn x448_hkdf_sha384(
948        issuer_der: Vec<u8>,
949        serial: Vec<u8>,
950        recipient_pub_x448: Vec<u8>,
951        ephemeral_scalar: Vec<u8>,
952    ) -> Self {
953        Self {
954            issuer_der,
955            serial,
956            curve: super::kari::KariCurve::X448,
957            kdf: super::kari::KariKdf::HkdfSha384,
958            recipient_pub_bytes: recipient_pub_x448,
959            ephemeral_scalar,
960        }
961    }
962
963    /// Round-24: build an X448 recipient with HKDF-SHA-512 (RFC 8418
964    /// §2.2 — `dhSinglePass-stdDH-hkdf-sha512-scheme`, smime-alg 21 —
965    /// the security-strength match for X448's 224-bit level under HKDF).
966    pub fn x448_hkdf_sha512(
967        issuer_der: Vec<u8>,
968        serial: Vec<u8>,
969        recipient_pub_x448: Vec<u8>,
970        ephemeral_scalar: Vec<u8>,
971    ) -> Self {
972        Self {
973            issuer_der,
974            serial,
975            curve: super::kari::KariCurve::X448,
976            kdf: super::kari::KariKdf::HkdfSha512,
977            recipient_pub_bytes: recipient_pub_x448,
978            ephemeral_scalar,
979        }
980    }
981}
982
983/// Configuration for a writer-side KARI public-key envelope. AES-256
984/// content + AES-256-WRAP (the round-15 baseline; AES-128 / AES-192
985/// wrap variants are reachable through [`super::kari::wrap_cek_for_recipient`]
986/// directly if a caller needs them).
987///
988/// Each recipient gets its own KARI in the RecipientInfos SET — that's
989/// the only way to mix curves cleanly because a single KARI's
990/// `keyEncryptionAlgorithm` binds one (curve, KDF) pair (RFC 5652
991/// §6.2.2 + RFC 5753 §3.1). All KARIs wrap the same CEK so any
992/// matching recipient recovers the same AES-256 file content.
993#[derive(Debug, Clone)]
994pub struct PubSecKariConfig {
995    /// 32-bit signed permissions value (§7.6.3.2 Table 22).
996    pub p: i32,
997    /// Whether the document metadata stream is encrypted. Plumbed into
998    /// both the `/EncryptMetadata` dict entry and the `0xFFFFFFFF`
999    /// opt-in tail of the SHA-256 file-key derivation when false.
1000    pub encrypt_metadata: bool,
1001    /// One [`KariRecipient`] per recipient certificate.
1002    pub recipients: Vec<KariRecipient>,
1003    /// Optional UKM (UserKeyingMaterial) mixed into the X9.63 KDF on
1004    /// both sides. Same UKM is used for every recipient's wrap (RFC
1005    /// 5753 §7.2 — the UKM is per-KARI). `None` for "absent".
1006    pub ukm: Option<Vec<u8>>,
1007    /// 20-byte seed prefixed to the envelope plaintext. Tests pin for
1008    /// determinism.
1009    pub seed: [u8; 20],
1010    /// 32-byte content-encryption key. AES-256.
1011    pub cek: [u8; 32],
1012    /// AES-256-CBC envelope IV.
1013    pub envelope_iv: [u8; 16],
1014    /// Per-object AES IV.
1015    pub aes_iv: [u8; 16],
1016}
1017
1018impl PubSecKariConfig {
1019    /// Default config for AES-256 KARI with deterministic test
1020    /// constants. Production callers should override `seed`, `cek`,
1021    /// `envelope_iv`, `aes_iv`, and each recipient's `ephemeral_scalar`
1022    /// with fresh random bytes.
1023    pub fn aes256(recipients: Vec<KariRecipient>) -> Self {
1024        Self {
1025            p: -4,
1026            encrypt_metadata: true,
1027            recipients,
1028            ukm: None,
1029            seed: [0x6Au8; 20],
1030            cek: [0x9Cu8; 32],
1031            envelope_iv: [0x77; 16],
1032            aes_iv: [0; 16],
1033        }
1034    }
1035}
1036
1037impl PubSecEncryptionState {
1038    /// Round-15: build the writer-side state for a KARI-encrypted PDF.
1039    /// Emits one CMS `EnvelopedData` containing one
1040    /// `KeyAgreeRecipientInfo` per recipient (each one sized to its own
1041    /// curve), all wrapping the same shared CEK with AES-256-WRAP.
1042    /// Symmetric to the round-14 reader path: the resulting
1043    /// `/Encrypt` dict opens via [`super::open_with_certificate`] when
1044    /// the recipient passes a [`PubSecCredential`] carrying their EC
1045    /// scalar.
1046    pub fn build_kari(config: &PubSecKariConfig) -> Result<Self, PdfError> {
1047        if config.recipients.is_empty() {
1048            return Err(PdfError::other(
1049                "PDF pubsec KARI encode: at least one recipient is required",
1050            ));
1051        }
1052        // Plaintext: 20-byte seed + 4-byte permissions (V=5 / AES-256
1053        // takes MSB-first per ISO 32000-2 §7.6.5.3).
1054        let mut plaintext = Vec::with_capacity(24);
1055        plaintext.extend_from_slice(&config.seed);
1056        plaintext.extend_from_slice(&(config.p as u32).to_be_bytes());
1057
1058        // Build each recipient's KARI: ephemeral keypair, ECDH against
1059        // recipient pub, KDF, AES-KW. Each KARI carries one
1060        // RecipientEncryptedKey because the KEA pinpoints one curve.
1061        let wrap = super::kari::WrapAlgorithm::Aes256;
1062        let mut karis: Vec<Vec<u8>> = Vec::with_capacity(config.recipients.len());
1063        for r in &config.recipients {
1064            if r.recipient_pub_bytes.len() != r.curve.pub_point_len() {
1065                return Err(PdfError::other(format!(
1066                    "PDF pubsec KARI encode: recipient pub_bytes {} != expected {} for {:?}",
1067                    r.recipient_pub_bytes.len(),
1068                    r.curve.pub_point_len(),
1069                    r.curve
1070                )));
1071            }
1072            let (originator_pub, wrapped) = super::kari::wrap_cek_for_recipient_with_kdf(
1073                r.curve,
1074                r.kdf,
1075                &r.ephemeral_scalar,
1076                &r.recipient_pub_bytes,
1077                config.ukm.as_deref(),
1078                &config.cek,
1079                wrap,
1080            )?;
1081            // KEA params = AlgorithmIdentifier of the wrap.
1082            let kea_params = super::der::write_sequence(&super::der::write_oid(wrap.oid()));
1083            let originator = super::cms_build::OriginatorIdRef::OriginatorKey {
1084                algorithm_oid: r.curve.algorithm_oid().to_vec(),
1085                algorithm_params: r.curve.algorithm_params(),
1086                public_key: originator_pub,
1087            };
1088            let recipient_slot = super::cms_build::KariRecipientPlain {
1089                rid: super::cms_build::KariRecipientIdRef::IssuerAndSerial {
1090                    issuer_der: r.issuer_der.clone(),
1091                    serial: r.serial.clone(),
1092                },
1093                encrypted_key: wrapped,
1094            };
1095            // We build ONE envelope per KARI with the same CEK + IV
1096            // for the content; the per-recipient envelope's CMS layout
1097            // carries just that recipient's KARI. Then we splice all
1098            // KARIs into the one outer EnvelopedData below. The KEA
1099            // OID is the recipient's KDF OID (so the same envelope can
1100            // mix X9.63 + HKDF X25519 recipients).
1101            let envelope = super::cms_build::build_envelope_kari_aes256(
1102                &originator,
1103                config.ukm.as_deref(),
1104                r.kdf.kea_oid(),
1105                &kea_params,
1106                &[recipient_slot],
1107                &plaintext,
1108                &config.cek,
1109                &config.envelope_iv,
1110            );
1111            karis.push(envelope);
1112        }
1113        // The /Recipients array carries one envelope blob per
1114        // recipient — every reader hashes over the entire ordered set
1115        // to derive the file key, so all recipients converge on the
1116        // same AES-256 file key (the seed is identical across
1117        // envelopes via the shared `config.seed`).
1118        let key_length_bits = 256usize;
1119        let file_key = derive_file_key(
1120            PubSecSubFilter::Pkcs7S5V5,
1121            &config.seed,
1122            &karis,
1123            config.encrypt_metadata,
1124            key_length_bits,
1125        );
1126        let handler = StandardHandler {
1127            key: file_key,
1128            method: CryptMethod::Aes256,
1129            revision: 6,
1130        };
1131        // /Encrypt dict — same shape as the round-11/12 KTRI s5/V=5
1132        // path, just with the KARI envelopes in /Recipients.
1133        let recipients_arr = Object::Array(
1134            karis
1135                .iter()
1136                .map(|e| Object::LiteralString(e.clone()))
1137                .collect(),
1138        );
1139        let std_cf = Dict::new()
1140            .with("Type", Object::Name("CryptFilter".into()))
1141            .with("CFM", Object::Name("AESV3".into()))
1142            .with("Length", Object::Integer(32))
1143            .with("Recipients", recipients_arr.clone());
1144        let cf = Dict::new().with("DefaultCryptFilter", Object::Dict(std_cf));
1145        let mut dict = Dict::new()
1146            .with("Filter", Object::Name("Adobe.PPKLite".into()))
1147            .with("SubFilter", Object::Name("adbe.pkcs7.s5".into()))
1148            .with("V", Object::Integer(5))
1149            .with("R", Object::Integer(6))
1150            .with("Length", Object::Integer(256))
1151            .with("P", Object::Integer(config.p as i64))
1152            .with("CF", Object::Dict(cf))
1153            .with("StmF", Object::Name("DefaultCryptFilter".into()))
1154            .with("StrF", Object::Name("DefaultCryptFilter".into()))
1155            .with("Recipients", recipients_arr);
1156        if !config.encrypt_metadata {
1157            dict.set("EncryptMetadata", Object::Bool(false));
1158        }
1159        Ok(PubSecEncryptionState {
1160            handler,
1161            encrypt_dict: dict,
1162            aes_iv: config.aes_iv,
1163            file_id: b"OXIDEAV-PUBSEC-KARI-ID-12345!XYZ".to_vec(),
1164        })
1165    }
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170    use super::super::open_with_certificate;
1171    use super::*;
1172    use crate::pubsec::x509::Certificate;
1173    use crate::pubsec::PubSecCredential;
1174
1175    fn keypair() -> (rsa::RsaPrivateKey, rsa::RsaPublicKey) {
1176        let mut rng = rsa::rand_core::OsRng;
1177        let priv_key = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("RSA");
1178        let pub_key = rsa::RsaPublicKey::from(&priv_key);
1179        (priv_key, pub_key)
1180    }
1181
1182    fn fake_cert(issuer_der: Vec<u8>, serial: Vec<u8>) -> Certificate {
1183        Certificate {
1184            issuer_der,
1185            serial,
1186            ..Default::default()
1187        }
1188    }
1189
1190    #[test]
1191    fn s4_writer_then_reader_round_trip_via_ias() {
1192        let (priv_key, pub_key) = keypair();
1193        let issuer_der = super::super::der::write_sequence(b"O=enc-test");
1194        let serial = vec![0x10, 0x20];
1195        let cfg = PubSecEncoderConfig::pkcs7_s4(vec![PubSecRecipient::from_issuer_and_serial(
1196            issuer_der.clone(),
1197            serial.clone(),
1198            pub_key,
1199        )]);
1200        let state = PubSecEncryptionState::build(&cfg).expect("build");
1201        // Open the resulting /Encrypt dict via the round-10 reader
1202        // path — it should derive the same handler.
1203        let cred = PubSecCredential::from_parsed(fake_cert(issuer_der, serial), priv_key);
1204        let handler = open_with_certificate(&state.encrypt_dict, &cred)
1205            .expect("open ok")
1206            .expect("matched");
1207        assert_eq!(handler.method, state.handler.method);
1208        assert_eq!(handler.revision, state.handler.revision);
1209        assert_eq!(handler.key, state.handler.key);
1210    }
1211
1212    #[test]
1213    fn s5_v4_aes128_writer_then_reader_round_trip() {
1214        let (priv_key, pub_key) = keypair();
1215        let issuer_der = super::super::der::write_sequence(b"O=enc-aes-128");
1216        let serial = vec![0x05];
1217        let cfg =
1218            PubSecEncoderConfig::pkcs7_s5_v4_aes128(vec![PubSecRecipient::from_issuer_and_serial(
1219                issuer_der.clone(),
1220                serial.clone(),
1221                pub_key,
1222            )]);
1223        let state = PubSecEncryptionState::build(&cfg).expect("build");
1224        let cred = PubSecCredential::from_parsed(fake_cert(issuer_der, serial), priv_key);
1225        let handler = open_with_certificate(&state.encrypt_dict, &cred)
1226            .expect("open")
1227            .expect("matched");
1228        assert_eq!(handler.method, CryptMethod::Aes128);
1229        assert_eq!(handler.revision, 4);
1230    }
1231
1232    #[test]
1233    fn s5_v5_aes256_writer_then_reader_round_trip() {
1234        let (priv_key, pub_key) = keypair();
1235        let issuer_der = super::super::der::write_sequence(b"O=enc-aes-256");
1236        let serial = vec![0x42, 0x01];
1237        let cfg =
1238            PubSecEncoderConfig::pkcs7_s5_v5_aes256(vec![PubSecRecipient::from_issuer_and_serial(
1239                issuer_der.clone(),
1240                serial.clone(),
1241                pub_key,
1242            )]);
1243        let state = PubSecEncryptionState::build(&cfg).expect("build");
1244        let cred = PubSecCredential::from_parsed(fake_cert(issuer_der, serial), priv_key);
1245        let handler = open_with_certificate(&state.encrypt_dict, &cred)
1246            .expect("open")
1247            .expect("matched");
1248        assert_eq!(handler.method, CryptMethod::Aes256);
1249        assert_eq!(handler.revision, 6);
1250        assert_eq!(handler.key.len(), 32);
1251    }
1252
1253    #[test]
1254    fn s5_v5_writer_via_ski_recipient_form() {
1255        // Build a synthetic full-SPKI cert and use its SKI to match.
1256        let (priv_key, pub_key) = keypair();
1257        // Fake "SPKI BIT STRING contents" — sha1(it) is the SKI.
1258        let pubkey_bits = b"OXIDEAV-PUBSEC-WRITER-SKI-MATCH!".to_vec();
1259        use sha1::Digest;
1260        let ski = sha1::Sha1::digest(&pubkey_bits).to_vec();
1261        let mut cfg = PubSecEncoderConfig::pkcs7_s5_v5_aes256(vec![
1262            PubSecRecipient::from_subject_key_identifier(ski.clone(), pub_key),
1263        ]);
1264        cfg.seed = [0xAB; 20];
1265        let state = PubSecEncryptionState::build(&cfg).expect("build");
1266        // Construct a credential whose cert has the same SPKI bytes —
1267        // open_with_certificate computes SHA-1 internally.
1268        let cred = PubSecCredential::from_parsed(
1269            Certificate {
1270                spki_pubkey_bits: Some(pubkey_bits),
1271                ..Default::default()
1272            },
1273            priv_key,
1274        );
1275        let handler = open_with_certificate(&state.encrypt_dict, &cred)
1276            .expect("open")
1277            .expect("SKI matched");
1278        assert_eq!(handler.method, CryptMethod::Aes256);
1279        assert_eq!(handler.key.len(), 32);
1280    }
1281
1282    #[test]
1283    fn empty_recipients_rejected() {
1284        let cfg = PubSecEncoderConfig::pkcs7_s5_v5_aes256(vec![]);
1285        let err = PubSecEncryptionState::build(&cfg).unwrap_err();
1286        assert!(format!("{err}").contains("at least one recipient"));
1287    }
1288}