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}