Skip to main content

oxideav_pdf/
encrypt.rs

1//! PDF *encryption* writer-side support — ISO 32000-1 §7.6.3 +
2//! ISO 32000-2 §7.6.4 Standard Security Handler.
3//!
4//! Mirror image of [`crate::decrypt`]: the reader recovers the file
5//! key from `(O, U, OE, UE, Perms)`; the writer goes the other way —
6//! starting from a user / owner password it produces those entries
7//! plus the file key itself, then encrypts every indirect-object
8//! string and stream payload before [`crate::objects::Document::write_to`]
9//! emits the file bytes.
10//!
11//! # Coverage
12//!
13//! - **R=2** — RC4-40 (V=1).
14//! - **R=3** — RC4-128 (V=2).
15//! - **R=4** — AES-128 CBC (`CFM=AESV2`) or RC4-128 (`CFM=V2`).
16//! - **R=5** — AES-256 CBC (V=5, `CFM=AESV3`); Adobe ext L3.
17//! - **R=6** — AES-256 CBC (V=5, `CFM=AESV3`); ISO 32000-2:2020.
18//!
19//! # Algorithms (numbered per ISO 32000)
20//!
21//! - **Algorithm 3** — compute `/O` from owner + user passwords (R≤4).
22//! - **Algorithm 4** (R=2) — compute `/U` from the file key + pad.
23//! - **Algorithm 5** (R≥3) — compute `/U` from MD5(pad ‖ ID) + 20× RC4.
24//! - **Algorithm 8** — compute `/O` + `/OE` for V=5.
25//! - **Algorithm 9** — compute `/U` + `/UE` for V=5.
26//! - **Algorithm 10** — encrypt the `/Perms` permissions block (V=5).
27//!
28//! Algorithms 8, 9, 10 are re-exported from [`crate::decrypt::r5_r6`]
29//! since they were already needed for the round-5 fixture builder; the
30//! V≤4 entries (Algorithms 3, 4, 5) live in this module.
31
32use crate::decrypt::r5_r6::{algorithm_10, algorithm_8, algorithm_9};
33use crate::decrypt::{md5, rc4, CryptMethod, StandardHandler};
34use crate::error::PdfError;
35use crate::objects::{Dict, Object};
36
37/// The 32-byte password-padding string from §7.6.3.3 Algorithm 2 step (a).
38const PAD: [u8; 32] = [
39    0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08,
40    0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80, 0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A,
41];
42
43/// Pad / truncate a password to exactly 32 bytes per Algorithm 2 step (a).
44fn pad_password(password: &[u8]) -> [u8; 32] {
45    let mut out = [0u8; 32];
46    let take = password.len().min(32);
47    out[..take].copy_from_slice(&password[..take]);
48    if take < 32 {
49        out[take..].copy_from_slice(&PAD[..32 - take]);
50    }
51    out
52}
53
54/// Writer-side configuration: what kind of encryption to apply, what
55/// the user / owner passwords are, the permissions, and the (optional)
56/// IVs / salts to feed into the AES paths so output stays
57/// deterministic for tests.
58#[derive(Clone, Debug)]
59pub struct EncryptionConfig {
60    /// Revision — 2..=6. Implies `length_bits`, `cfm`, and which
61    /// algorithm family (V≤4 vs V=5) to use.
62    pub revision: u8,
63    /// File-key length in bits. R=2: 40. R=3 / R=4: typically 128.
64    /// R=5 / R=6: always 256.
65    pub length_bits: usize,
66    /// User password (raw bytes — V=5 truncates to 127, V≤4 pads to 32).
67    pub user_password: Vec<u8>,
68    /// Owner password — `Algorithm 3` falls back to user password when
69    /// empty.
70    pub owner_password: Vec<u8>,
71    /// 32-bit signed permissions value (§7.6.3.2 Table 22).
72    pub p: i32,
73    /// Whether the document metadata stream is encrypted (R≥4).
74    pub encrypt_metadata: bool,
75    /// Per-stream / per-string crypt method. RC4 / AES-128 / AES-256.
76    pub method: CryptMethod,
77    /// Permanent file identifier — placed in `/ID[0]` and fed into the
78    /// V≤4 file-key derivation. Use ≥16 bytes of random data per the
79    /// spec; tests pin it for determinism.
80    pub file_id: Vec<u8>,
81    /// V=5 user-validation salt (8 bytes).
82    pub u_salt_validate: [u8; 8],
83    /// V=5 user-key salt (8 bytes).
84    pub u_salt_key: [u8; 8],
85    /// V=5 owner-validation salt (8 bytes).
86    pub o_salt_validate: [u8; 8],
87    /// V=5 owner-key salt (8 bytes).
88    pub o_salt_key: [u8; 8],
89    /// V=5 file encryption key (32 bytes) — random for production,
90    /// caller-pinned for tests. None defaults to a deterministic-but-
91    /// non-reused-across-callers value.
92    pub file_key_v5: Option<[u8; 32]>,
93    /// V=5 Algorithm 10 padding bytes (12..16).
94    pub perms_padding: [u8; 4],
95    /// IV for AES per-object encryption (16 bytes). Tests pin; production
96    /// callers should override per-object.
97    pub aes_iv: [u8; 16],
98}
99
100impl EncryptionConfig {
101    /// Sensible R=4 (AES-128) default — empty owner password, no
102    /// metadata encryption opt-out, full permissions.
103    pub fn aes_128(user_password: &[u8], file_id: &[u8]) -> Self {
104        Self {
105            revision: 4,
106            length_bits: 128,
107            user_password: user_password.to_vec(),
108            owner_password: Vec::new(),
109            p: -4,
110            encrypt_metadata: true,
111            method: CryptMethod::Aes128,
112            file_id: file_id.to_vec(),
113            u_salt_validate: [0; 8],
114            u_salt_key: [0; 8],
115            o_salt_validate: [0; 8],
116            o_salt_key: [0; 8],
117            file_key_v5: None,
118            perms_padding: [0; 4],
119            aes_iv: [0; 16],
120        }
121    }
122
123    /// R=3 RC4-128 default.
124    pub fn rc4_128(user_password: &[u8], file_id: &[u8]) -> Self {
125        Self {
126            revision: 3,
127            length_bits: 128,
128            user_password: user_password.to_vec(),
129            owner_password: Vec::new(),
130            p: -4,
131            encrypt_metadata: true,
132            method: CryptMethod::Rc4,
133            file_id: file_id.to_vec(),
134            u_salt_validate: [0; 8],
135            u_salt_key: [0; 8],
136            o_salt_validate: [0; 8],
137            o_salt_key: [0; 8],
138            file_key_v5: None,
139            perms_padding: [0; 4],
140            aes_iv: [0; 16],
141        }
142    }
143
144    /// R=2 RC4-40 default.
145    pub fn rc4_40(user_password: &[u8], file_id: &[u8]) -> Self {
146        Self {
147            revision: 2,
148            length_bits: 40,
149            user_password: user_password.to_vec(),
150            owner_password: Vec::new(),
151            p: -4,
152            encrypt_metadata: true,
153            method: CryptMethod::Rc4,
154            file_id: file_id.to_vec(),
155            u_salt_validate: [0; 8],
156            u_salt_key: [0; 8],
157            o_salt_validate: [0; 8],
158            o_salt_key: [0; 8],
159            file_key_v5: None,
160            perms_padding: [0; 4],
161            aes_iv: [0; 16],
162        }
163    }
164
165    /// R=5 AES-256 default (Adobe extension level 3).
166    pub fn aes_256_r5(user_password: &[u8], file_id: &[u8]) -> Self {
167        Self {
168            revision: 5,
169            length_bits: 256,
170            user_password: user_password.to_vec(),
171            owner_password: Vec::new(),
172            p: -4,
173            encrypt_metadata: true,
174            method: CryptMethod::Aes256,
175            file_id: file_id.to_vec(),
176            u_salt_validate: [0x55; 8],
177            u_salt_key: [0x55; 8],
178            o_salt_validate: [0xAA; 8],
179            o_salt_key: [0xAA; 8],
180            file_key_v5: None,
181            perms_padding: [0xCA, 0xFE, 0xBA, 0xBE],
182            aes_iv: [0; 16],
183        }
184    }
185
186    /// R=6 AES-256 default (ISO 32000-2 PDF 2.0).
187    pub fn aes_256_r6(user_password: &[u8], file_id: &[u8]) -> Self {
188        let mut c = Self::aes_256_r5(user_password, file_id);
189        c.revision = 6;
190        c
191    }
192
193    /// Apply an owner password.
194    pub fn with_owner_password(mut self, owner: &[u8]) -> Self {
195        self.owner_password = owner.to_vec();
196        self
197    }
198
199    /// Override permissions.
200    pub fn with_permissions(mut self, p: i32) -> Self {
201        self.p = p;
202        self
203    }
204}
205
206/// Resolved writer-side state — handler (file key + crypt method),
207/// the `/Encrypt` dictionary, and the `/ID` array. Built once at the
208/// start of [`crate::objects::Document::write_to`] and threaded
209/// through per-object string + stream encryption.
210#[derive(Clone, Debug)]
211pub struct EncryptionState {
212    /// Handler used to encrypt every string + stream.
213    pub handler: StandardHandler,
214    /// `/Encrypt` dictionary as it must appear in the file. Becomes a
215    /// new indirect object at write time.
216    pub encrypt_dict: Dict,
217    /// 16-byte file ID — placed in trailer `/ID[0]` AND `/ID[1]`.
218    pub file_id: Vec<u8>,
219    /// IV used for per-object AES encryption. Tests pin; the writer
220    /// uses one IV across all objects when this is the only knob (the
221    /// per-object Algorithm 1 key derivation already varies the
222    /// effective key per object).
223    pub aes_iv: [u8; 16],
224}
225
226impl EncryptionState {
227    /// Build the writer-side state from a config.
228    pub fn build(config: &EncryptionConfig) -> Result<Self, PdfError> {
229        // Validate cfg.
230        if !(2..=6).contains(&config.revision) {
231            return Err(PdfError::other(format!(
232                "PDF encrypt: revision R={} not supported (R∈[2,6])",
233                config.revision
234            )));
235        }
236        if config.revision >= 5 && config.length_bits != 256 {
237            return Err(PdfError::other(format!(
238                "PDF encrypt: V=5 requires Length=256 bits (got {})",
239                config.length_bits
240            )));
241        }
242        if config.revision <= 4
243            && (config.length_bits % 8 != 0 || !(40..=128).contains(&config.length_bits))
244        {
245            return Err(PdfError::other(format!(
246                "PDF encrypt: V≤4 requires Length∈[40..=128] (mult of 8); got {}",
247                config.length_bits
248            )));
249        }
250
251        if config.revision >= 5 {
252            Self::build_v5(config)
253        } else {
254            Self::build_v_le_4(config)
255        }
256    }
257
258    fn build_v_le_4(c: &EncryptionConfig) -> Result<Self, PdfError> {
259        let n = c.length_bits / 8;
260
261        // Algorithm 3 — compute /O.
262        let o = algorithm_3(&c.user_password, &c.owner_password, c.revision, n);
263
264        // Algorithm 2 — compute file encryption key.
265        let key = algorithm_2_filekey(
266            &c.user_password,
267            &o,
268            c.p,
269            &c.file_id,
270            c.revision,
271            n,
272            c.encrypt_metadata,
273        );
274
275        // Algorithm 4 / 5 — compute /U.
276        let u = if c.revision == 2 {
277            // Algorithm 4 — RC4(file_key, PAD).
278            let mut out = [0u8; 32];
279            out.copy_from_slice(&rc4(&key, &PAD));
280            out
281        } else {
282            // Algorithm 5 — RC4 ladder over MD5(PAD ‖ file_id).
283            let mut hash_input = Vec::with_capacity(32 + c.file_id.len());
284            hash_input.extend_from_slice(&PAD);
285            hash_input.extend_from_slice(&c.file_id);
286            let h = md5(&hash_input);
287            let mut data = rc4(&key, &h);
288            for i in 1u8..=19 {
289                let xkey: Vec<u8> = key.iter().map(|b| b ^ i).collect();
290                data = rc4(&xkey, &data);
291            }
292            let mut out = [0u8; 32];
293            out[..16].copy_from_slice(&data[..16]);
294            // Last 16 are arbitrary — zeros are fine; the reader only
295            // compares the first 16 for R≥3.
296            out
297        };
298
299        let handler = StandardHandler {
300            key: key.clone(),
301            method: c.method,
302            revision: c.revision,
303        };
304
305        // Build the /Encrypt dict.
306        let v: i64 = match (c.revision, c.method) {
307            (2, _) => 1,
308            (3, _) => 2,
309            (4, _) => 4,
310            _ => unreachable!("V≤4 path checked at entry"),
311        };
312        let mut dict = Dict::new()
313            .with("Filter", Object::Name("Standard".into()))
314            .with("V", Object::Integer(v))
315            .with("R", Object::Integer(c.revision as i64))
316            .with("Length", Object::Integer(c.length_bits as i64))
317            .with("O", Object::LiteralString(o.to_vec()))
318            .with("U", Object::LiteralString(u.to_vec()))
319            .with("P", Object::Integer(c.p as i64));
320        if !c.encrypt_metadata {
321            dict.set("EncryptMetadata", Object::Bool(false));
322        }
323
324        // V=4 carries a /CF dict picking the crypt method. For V<4,
325        // the choice is implicit (RC4) and /CF is not emitted.
326        if c.revision == 4 {
327            let cfm = match c.method {
328                CryptMethod::Aes128 => "AESV2",
329                CryptMethod::Rc4 => "V2",
330                CryptMethod::Aes256 => {
331                    return Err(PdfError::other(
332                        "PDF encrypt: AES-256 requires V=5/R=5+ (got V=4)",
333                    ));
334                }
335            };
336            let crypt_filter_len = match c.method {
337                CryptMethod::Aes128 => 16,
338                CryptMethod::Rc4 => 16,
339                CryptMethod::Aes256 => 32,
340            };
341            let std_cf = Dict::new()
342                .with("Type", Object::Name("CryptFilter".into()))
343                .with("CFM", Object::Name(cfm.into()))
344                .with("Length", Object::Integer(crypt_filter_len));
345            let cf = Dict::new().with("StdCF", Object::Dict(std_cf));
346            dict.set("CF", Object::Dict(cf));
347            dict.set("StmF", Object::Name("StdCF".into()));
348            dict.set("StrF", Object::Name("StdCF".into()));
349        }
350
351        Ok(EncryptionState {
352            handler,
353            encrypt_dict: dict,
354            file_id: c.file_id.clone(),
355            aes_iv: c.aes_iv,
356        })
357    }
358
359    fn build_v5(c: &EncryptionConfig) -> Result<Self, PdfError> {
360        // V=5 keys the file directly (no per-object Algorithm 1).
361        let file_key = c
362            .file_key_v5
363            .unwrap_or_else(default_file_key_v5_for_password);
364        let user_pw = if c.user_password.len() > 127 {
365            &c.user_password[..127]
366        } else {
367            &c.user_password[..]
368        };
369        let owner_pw = if c.owner_password.is_empty() {
370            user_pw
371        } else if c.owner_password.len() > 127 {
372            &c.owner_password[..127]
373        } else {
374            &c.owner_password[..]
375        };
376
377        // Algorithm 9 — /U + /UE.
378        let (u, ue) = algorithm_9(
379            c.revision,
380            user_pw,
381            &file_key,
382            &c.u_salt_validate,
383            &c.u_salt_key,
384        );
385        // Algorithm 8 — /O + /OE (depends on /U).
386        let (o, oe) = algorithm_8(
387            c.revision,
388            owner_pw,
389            &u,
390            &file_key,
391            &c.o_salt_validate,
392            &c.o_salt_key,
393        );
394        // Algorithm 10 — /Perms.
395        let perms = algorithm_10(&file_key, c.p, c.encrypt_metadata, &c.perms_padding);
396
397        let handler = StandardHandler {
398            key: file_key.to_vec(),
399            method: CryptMethod::Aes256,
400            revision: c.revision,
401        };
402
403        let std_cf = Dict::new()
404            .with("Type", Object::Name("CryptFilter".into()))
405            .with("CFM", Object::Name("AESV3".into()))
406            .with("Length", Object::Integer(32));
407        let cf = Dict::new().with("StdCF", Object::Dict(std_cf));
408        let mut dict = Dict::new()
409            .with("Filter", Object::Name("Standard".into()))
410            .with("V", Object::Integer(5))
411            .with("R", Object::Integer(c.revision as i64))
412            .with("Length", Object::Integer(256))
413            .with("CF", Object::Dict(cf))
414            .with("StmF", Object::Name("StdCF".into()))
415            .with("StrF", Object::Name("StdCF".into()))
416            .with("O", Object::LiteralString(o.to_vec()))
417            .with("U", Object::LiteralString(u.to_vec()))
418            .with("OE", Object::LiteralString(oe.to_vec()))
419            .with("UE", Object::LiteralString(ue.to_vec()))
420            .with("Perms", Object::LiteralString(perms.to_vec()))
421            .with("P", Object::Integer(c.p as i64));
422        if !c.encrypt_metadata {
423            dict.set("EncryptMetadata", Object::Bool(false));
424        }
425
426        Ok(EncryptionState {
427            handler,
428            encrypt_dict: dict,
429            file_id: c.file_id.clone(),
430            aes_iv: c.aes_iv,
431        })
432    }
433}
434
435/// Algorithm 3 — compute /O (32 bytes). When `owner_password` is empty
436/// the user password is used as the owner password's MD5 source.
437fn algorithm_3(user_password: &[u8], owner_password: &[u8], revision: u8, n: usize) -> [u8; 32] {
438    // (a) Pad owner (or user) password.
439    let owner_src = if owner_password.is_empty() {
440        user_password
441    } else {
442        owner_password
443    };
444    let opad = pad_password(owner_src);
445    // (b) MD5.
446    let mut h = md5(&opad);
447    // (c) for R≥3, loop 50 times.
448    if revision >= 3 {
449        for _ in 0..50 {
450            h = md5(&h[..n]);
451        }
452    }
453    let okey = h[..n].to_vec();
454    // (d) Pad user password.
455    let upad = pad_password(user_password);
456    // (e) RC4.
457    let mut buf = rc4(&okey, &upad);
458    // (f) For R≥3, 19 more rounds with byte-XOR'd keys.
459    if revision >= 3 {
460        for i in 1u8..=19 {
461            let xkey: Vec<u8> = okey.iter().map(|b| b ^ i).collect();
462            buf = rc4(&xkey, &buf);
463        }
464    }
465    let mut out = [0u8; 32];
466    out.copy_from_slice(&buf);
467    out
468}
469
470/// Algorithm 2 — compute file encryption key. Mirror of
471/// [`crate::decrypt::compute_key`] (which is private to `decrypt`).
472fn algorithm_2_filekey(
473    user_password: &[u8],
474    o: &[u8; 32],
475    p: i32,
476    file_id: &[u8],
477    revision: u8,
478    n: usize,
479    encrypt_metadata: bool,
480) -> Vec<u8> {
481    let pwd = pad_password(user_password);
482    let mut buf = Vec::with_capacity(32 + 32 + 4 + file_id.len() + 4);
483    buf.extend_from_slice(&pwd);
484    buf.extend_from_slice(o);
485    buf.extend_from_slice(&(p as u32).to_le_bytes());
486    buf.extend_from_slice(file_id);
487    if revision >= 4 && !encrypt_metadata {
488        buf.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
489    }
490    let mut h = md5(&buf);
491    if revision >= 3 {
492        for _ in 0..50 {
493            h = md5(&h[..n]);
494        }
495    }
496    h[..n].to_vec()
497}
498
499/// Default V=5 file-key — a well-known constant. NOT secure for
500/// production; callers supplying random data via `file_key_v5` get
501/// real security. The default is convenient for tests + lets users
502/// who haven't audited the API avoid hitting `unwrap` on `None`.
503fn default_file_key_v5_for_password() -> [u8; 32] {
504    [
505        0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
506        0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0,
507        0xF0, 0x01,
508    ]
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::decrypt::{open_with_password, CryptMethod};
515
516    #[test]
517    fn algorithm_3_with_empty_owner_password_falls_back_to_user() {
518        let o_a = algorithm_3(b"hello", b"", 3, 16);
519        let o_b = algorithm_3(b"hello", b"hello", 3, 16);
520        assert_eq!(o_a, o_b);
521    }
522
523    #[test]
524    fn build_v_le_4_round_trips_via_decrypt_authenticator() {
525        // After building O / U for revision 3 with our writer-side
526        // helper, the decrypt-side authenticator must accept the same
527        // user password.
528        let cfg = EncryptionConfig::rc4_128(b"hello", b"OXIDEAV-FIXTURE-ID-FIXED-VALUE!");
529        let state = EncryptionState::build(&cfg).unwrap();
530        // open_with_password expects the dict + file ID.
531        let opened = open_with_password(&state.encrypt_dict, &state.file_id, b"hello").expect("ok");
532        assert!(opened.is_some(), "user password should authenticate");
533    }
534
535    #[test]
536    fn build_v_le_4_rejects_wrong_password() {
537        let cfg = EncryptionConfig::rc4_128(b"correctpw", b"OXIDEAV-FIXTURE-ID-FIXED-VALUE!");
538        let state = EncryptionState::build(&cfg).unwrap();
539        let opened = open_with_password(&state.encrypt_dict, &state.file_id, b"wrong").unwrap();
540        assert!(opened.is_none());
541    }
542
543    #[test]
544    fn build_v_le_4_owner_password_authenticates() {
545        let cfg = EncryptionConfig::rc4_128(b"userpw", b"OXIDEAV-FIXTURE-ID-FIXED-VALUE!")
546            .with_owner_password(b"ownerpw");
547        let state = EncryptionState::build(&cfg).unwrap();
548        // Both passwords should succeed.
549        let user_ok = open_with_password(&state.encrypt_dict, &state.file_id, b"userpw").unwrap();
550        assert!(user_ok.is_some());
551        let owner_ok = open_with_password(&state.encrypt_dict, &state.file_id, b"ownerpw").unwrap();
552        assert!(owner_ok.is_some());
553    }
554
555    #[test]
556    fn build_v5_r5_round_trips() {
557        let cfg = EncryptionConfig::aes_256_r5(b"hunter2", b"FIXED-FILE-ID-32-BYTES-FOR-V5-XX");
558        let state = EncryptionState::build(&cfg).unwrap();
559        let opened = open_with_password(&state.encrypt_dict, &state.file_id, b"hunter2").unwrap();
560        assert!(opened.is_some(), "R=5 user pw should authenticate");
561        assert_eq!(opened.unwrap().method, CryptMethod::Aes256);
562    }
563
564    #[test]
565    fn build_v5_r6_round_trips() {
566        let cfg =
567            EncryptionConfig::aes_256_r6(b"correct horse", b"FIXED-FILE-ID-32-BYTES-FOR-R6-X");
568        let state = EncryptionState::build(&cfg).unwrap();
569        let opened =
570            open_with_password(&state.encrypt_dict, &state.file_id, b"correct horse").unwrap();
571        assert!(opened.is_some(), "R=6 user pw should authenticate");
572    }
573
574    #[test]
575    fn build_v5_r5_owner_password() {
576        let cfg = EncryptionConfig::aes_256_r5(b"userpw", b"FIXED-FILE-ID-32-BYTES-FOR-V5-OW")
577            .with_owner_password(b"ownerpw");
578        let state = EncryptionState::build(&cfg).unwrap();
579        let owner_ok = open_with_password(&state.encrypt_dict, &state.file_id, b"ownerpw").unwrap();
580        assert!(owner_ok.is_some());
581    }
582
583    #[test]
584    fn build_aes_128_r4_round_trips() {
585        let cfg = EncryptionConfig::aes_128(b"aespw", b"AES-FIXTURE-FILE-ID-LONG-ENOUGH!");
586        let state = EncryptionState::build(&cfg).unwrap();
587        let opened = open_with_password(&state.encrypt_dict, &state.file_id, b"aespw").unwrap();
588        assert!(opened.is_some(), "R=4 AES-128 should authenticate");
589        assert_eq!(opened.unwrap().method, CryptMethod::Aes128);
590    }
591
592    #[test]
593    fn build_rc4_40_r2_round_trips() {
594        let cfg = EncryptionConfig::rc4_40(b"shorty", b"R2-FIXTURE-FILE-ID-LONG-ENOUGH!");
595        let state = EncryptionState::build(&cfg).unwrap();
596        let opened = open_with_password(&state.encrypt_dict, &state.file_id, b"shorty").unwrap();
597        assert!(opened.is_some(), "R=2 RC4-40 should authenticate");
598        assert_eq!(opened.unwrap().key.len(), 5);
599    }
600}