Skip to main content

pdfrum_edit/write/
id.rs

1//! The file identifier (ISO 32000-1 §14.4).
2//!
3//! `/ID` is a two-element array. The **first** element is the document's
4//! permanent identity, minted once and preserved across every save the file
5//! ever sees. The **second** changes on each save, so two files sharing an
6//! `ID[0]` but differing in `ID[1]` are recognisably versions of one document.
7//!
8//! # Randomness is a parameter here, not a global
9//!
10//! The C++ draws both elements from a process-global Mersenne Twister, which
11//! makes its output unreproducible. This workspace takes no global state and
12//! a non-reproducible writer cannot be snapshot-tested, so the source is an
13//! explicit [`IdSource`]: [`IdSource::Random`] by default (matching the C++'s
14//! observable behavior — fresh bytes per save), [`IdSource::Fixed`] for tests
15//! and for byte-reproducible output.
16//!
17//! `/ID` and the subset tags are the whole of what this source decides. Key
18//! material is not among them: an encrypted save draws its file key and salts
19//! from [`pdfrum_crypt::KeyMaterial`], which only the operating system can
20//! fill, so a seed reproduces a file's identifiers and never its secrets.
21//!
22//! # The five branches
23//!
24//! | the input had | `ID[0]` | `ID[1]` |
25//! |---|---|---|
26//! | an `/ID` with a first element | that element, preserved | fresh random |
27//! | an `/ID`, incremental save, encrypted, with a second element | preserved | **preserved** |
28//! | an `/ID` with no first element | fresh random | fresh random |
29//! | no `/ID` at all | fresh random | **a copy of `ID[0]`** |
30//! | no `/ID` at all, encrypted at revision 2 or 3 | fresh random | copy of `ID[0]`, **and the key is rebuilt** |
31//!
32//! Row 2 exists because R2/R3 key derivation mixes `ID[0]` in: changing `ID[1]`
33//! on an incremental save would be harmless, but the C++ preserves it and a
34//! file's already-written ciphertext is what makes that the safe choice.
35//!
36//! Row 5 is the interesting one. A document with no `/ID` that *is* encrypted
37//! at revision 2 or 3 has just had a fresh `ID[0]` minted — and since R2/R3
38//! derive the file key from `ID[0]`, the old key is no longer derivable. The
39//! C++ answers by rebuilding the security handler from the new ID, which sets
40//! `security_changed_`, which in turn **forces a full save**: appending
41//! freshly-keyed objects after ciphertext under the old key would produce a
42//! file no reader could open. That interlock is reported here as
43//! [`FileId::rekeyed`] so the caller can honour it.
44
45use std::hash::{BuildHasher, RandomState};
46
47use pdfrum_object::{Array, Dict, Object, PdfString, names};
48
49/// How many bytes each `/ID` element carries — 16, written as 32 hex digits.
50const ID_LEN: usize = 16;
51
52/// Where the bytes of a fresh `/ID` element come from.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum IdSource {
55    /// Fresh bytes per save, the way a real writer behaves.
56    #[default]
57    Random,
58    /// A fixed seed, so the same input saves to the same bytes. Also seeds
59    /// the six-letter subset tag, for the same reason.
60    Fixed([u8; ID_LEN]),
61}
62
63impl IdSource {
64    /// A sequence of `ID_LEN` bytes. `nonce` distinguishes the two elements
65    /// of one array, so a fixed source still produces two different values
66    /// where the rules call for two.
67    fn bytes(self, nonce: u64) -> [u8; ID_LEN] {
68        match self {
69            Self::Fixed(seed) => mix(&seed, nonce),
70            // `RandomState` is std's per-process randomness, seeded by the OS
71            // — no global of ours, and no dependency added for it.
72            Self::Random => {
73                let a = RandomState::new().hash_one(nonce);
74                let b = RandomState::new().hash_one(nonce.wrapping_add(0x9E37_79B9));
75                let mut out = [0u8; ID_LEN];
76                for (slot, byte) in out
77                    .iter_mut()
78                    .zip(a.to_le_bytes().into_iter().chain(b.to_le_bytes()))
79                {
80                    *slot = byte;
81                }
82                out
83            }
84        }
85    }
86
87    /// A deterministic byte for the subset tag's letter `index` (D1/D4:
88    /// subsetting shares the save's seed so a fixed save is fully
89    /// reproducible).
90    #[must_use]
91    pub fn tag_byte(self, index: u64) -> u8 {
92        match self {
93            Self::Fixed(seed) => mix(&seed, TAG_NONCE ^ index).first().copied().unwrap_or(0),
94            Self::Random => {
95                #[expect(
96                    clippy::cast_possible_truncation,
97                    reason = "any byte of the hash is as good as any other"
98                )]
99                {
100                    RandomState::new().hash_one(index) as u8
101                }
102            }
103        }
104    }
105}
106
107/// Nonce separating subset-tag bytes from `/ID` elements drawn from the same
108/// fixed seed.
109const TAG_NONCE: u64 = 0x5375_6273_6574_0000;
110
111/// Stir a seed with a nonce into `ID_LEN` bytes.
112///
113/// Not a cryptographic function and not meant to be: `/ID` needs to be
114/// *distinct*, not unguessable, and the C++'s Mersenne Twister is no
115/// stronger. What it *does* need is for the whole seed to reach every output
116/// byte — absorbing the seed before emitting anything is what makes the first
117/// byte differ between two seeds, which a per-byte mix would not (the first
118/// output would then depend on one seed byte, and a six-letter subset tag is
119/// six such bytes).
120fn mix(seed: &[u8; ID_LEN], nonce: u64) -> [u8; ID_LEN] {
121    // Absorb: every seed byte and the nonce go into the state first.
122    let mut state = nonce ^ 0x243F_6A88_85A3_08D3;
123    for byte in seed {
124        state = state
125            .wrapping_mul(0x5851_F42D_4C95_7F2D)
126            .wrapping_add(u64::from(*byte).wrapping_add(1));
127    }
128
129    // Squeeze: one byte of output per round, from the high bits, which are
130    // the ones a multiplicative step actually stirs.
131    let mut out = [0u8; ID_LEN];
132    for slot in &mut out {
133        state = state
134            .wrapping_mul(0x5851_F42D_4C95_7F2D)
135            .wrapping_add(0x1405_7B7E_F767_814F);
136        *slot = u8::try_from(state >> 56).unwrap_or(0);
137    }
138    out
139}
140
141/// The `/ID` array a save will write, and whether minting it invalidated the
142/// document's encryption key.
143#[derive(Debug, Clone, PartialEq)]
144pub struct FileId {
145    /// The two-element array, both elements hex strings of 32 digits.
146    pub array: Array,
147    /// Row 5 of the table above: the file key was rebuilt from a fresh `ID[0]`,
148    /// so the original bytes can no longer be appended to and the save must
149    /// be a full one.
150    pub rekeyed: bool,
151}
152
153/// What the save needs to know about the document to decide `/ID`.
154#[derive(Debug, Clone, Copy)]
155pub(crate) struct IdContext<'a> {
156    /// The trailer's own `/ID`, if it had one.
157    pub(crate) old: Option<&'a Array>,
158    /// The `/Encrypt` dictionary, if the file declared one.
159    pub(crate) encrypt: Option<&'a Dict>,
160    /// Whether this save is an incremental one.
161    pub(crate) incremental: bool,
162}
163
164/// Build the `/ID` array for one save.
165pub(crate) fn build(ctx: IdContext<'_>, source: IdSource) -> FileId {
166    let fresh = |nonce: u64| Object::Str(PdfString::hex(source.bytes(nonce)));
167
168    let Some(old) = ctx.old else {
169        // No `/ID` at all: both elements are the same fresh value, and the
170        // R2/R3 rekey may follow.
171        let first = fresh(0);
172        return FileId {
173            array: Array::of([first.clone(), first]),
174            rekeyed: needs_rekey(ctx.encrypt),
175        };
176    };
177
178    // ID[0] is the document's identity: preserved whenever it exists.
179    let first = old
180        .raw_at(0)
181        .filter(|o| o.as_string().is_some())
182        .cloned()
183        .unwrap_or_else(|| fresh(0));
184
185    let second = old.raw_at(1).filter(|o| o.as_string().is_some());
186    // An incremental save of an encrypted document keeps ID[1], because the
187    // ciphertext already in the file was keyed with it.
188    if ctx.incremental
189        && ctx.encrypt.is_some()
190        && let Some(second) = second
191    {
192        return FileId {
193            array: Array::of([first, second.clone()]),
194            rekeyed: false,
195        };
196    }
197
198    FileId {
199        array: Array::of([first, fresh(1)]),
200        rekeyed: false,
201    }
202}
203
204/// Does minting a fresh `ID[0]` invalidate this handler's key?
205///
206/// Only for the standard handler at revision 2 or 3, whose key derivation
207/// mixes the first `/ID` element in. Revision 4 and up derive from `/O` and
208/// `/U` alone, so a new `/ID` costs them nothing.
209fn needs_rekey(encrypt: Option<&Dict>) -> bool {
210    let Some(dict) = encrypt else {
211        return false;
212    };
213    let revision = dict.direct_int(names::R).unwrap_or(0);
214    (revision == 2 || revision == 3) && dict.name(names::FILTER) == Some(names::STANDARD)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::{FileId, IdContext, IdSource, build};
220    use pdfrum_object::{Array, Dict, Object, PdfString, names};
221
222    fn hex(s: &str) -> Object {
223        Object::Str(PdfString::hex(s.as_bytes()))
224    }
225
226    fn ctx<'a>(
227        old: Option<&'a Array>,
228        encrypt: Option<&'a Dict>,
229        incremental: bool,
230    ) -> IdContext<'a> {
231        IdContext {
232            old,
233            encrypt,
234            incremental,
235        }
236    }
237
238    fn seed() -> IdSource {
239        IdSource::Fixed([7u8; 16])
240    }
241
242    fn elements(id: &FileId) -> (Vec<u8>, Vec<u8>) {
243        let get = |i: usize| {
244            id.array
245                .string_at(i)
246                .map(|s| s.bytes.to_vec())
247                .unwrap_or_default()
248        };
249        (get(0), get(1))
250    }
251
252    // Bug873: ID[0] is the document's permanent identity.
253    #[test]
254    fn the_first_element_is_preserved_when_the_file_had_one() {
255        let old = Array::of([hex("keepme"), hex("changeme")]);
256        let id = build(ctx(Some(&old), None, false), seed());
257        let (first, second) = elements(&id);
258        assert_eq!(first, b"keepme");
259        assert_ne!(second, b"changeme", "the second element is regenerated");
260        assert_eq!(second.len(), 16);
261        assert!(!id.rekeyed);
262    }
263
264    #[test]
265    fn a_document_with_no_id_gets_two_identical_elements() {
266        let id = build(ctx(None, None, false), seed());
267        let (first, second) = elements(&id);
268        assert_eq!(first, second);
269        assert_eq!(first.len(), 16);
270        assert!(!id.rekeyed);
271    }
272
273    // The one case that preserves ID[1]: the ciphertext already written was
274    // keyed with it.
275    #[test]
276    fn an_incremental_encrypted_save_keeps_the_second_element() {
277        let old = Array::of([hex("keepme"), hex("alsokeep")]);
278        let encrypt = Dict::from_pairs([(names::R.clone(), Object::Int(4))]);
279        let id = build(ctx(Some(&old), Some(&encrypt), true), seed());
280        let (first, second) = elements(&id);
281        assert_eq!(first, b"keepme");
282        assert_eq!(second, b"alsokeep");
283    }
284
285    #[test]
286    fn a_full_encrypted_save_still_regenerates_the_second_element() {
287        let old = Array::of([hex("keepme"), hex("changeme")]);
288        let encrypt = Dict::from_pairs([(names::R.clone(), Object::Int(4))]);
289        let id = build(ctx(Some(&old), Some(&encrypt), false), seed());
290        assert_ne!(elements(&id).1, b"changeme");
291    }
292
293    // Row 5: no /ID plus R2/R3 standard handler means the key is gone.
294    #[test]
295    fn no_id_plus_revision_three_forces_a_rekey() {
296        for revision in [2i64, 3] {
297            let encrypt = Dict::from_pairs([
298                (names::R.clone(), Object::Int(revision)),
299                (names::FILTER.clone(), Object::Name(names::STANDARD.clone())),
300            ]);
301            let id = build(ctx(None, Some(&encrypt), false), seed());
302            assert!(id.rekeyed, "revision {revision} derives its key from /ID");
303        }
304    }
305
306    #[test]
307    fn revision_four_and_up_survive_a_fresh_id() {
308        for revision in [4i64, 5, 6] {
309            let encrypt = Dict::from_pairs([
310                (names::R.clone(), Object::Int(revision)),
311                (names::FILTER.clone(), Object::Name(names::STANDARD.clone())),
312            ]);
313            assert!(!build(ctx(None, Some(&encrypt), false), seed()).rekeyed);
314        }
315    }
316
317    // A non-standard handler is not rekeyed however low its revision reads.
318    #[test]
319    fn a_non_standard_handler_is_never_rekeyed() {
320        let encrypt = Dict::from_pairs([
321            (names::R.clone(), Object::Int(2)),
322            (
323                names::FILTER.clone(),
324                Object::Name(pdfrum_object::Name::from("Custom")),
325            ),
326        ]);
327        assert!(!build(ctx(None, Some(&encrypt), false), seed()).rekeyed);
328    }
329
330    // Determinism is what makes snapshot tests of whole files possible.
331    #[test]
332    fn a_fixed_source_produces_the_same_id_every_time() {
333        let a = build(ctx(None, None, false), seed());
334        let b = build(ctx(None, None, false), seed());
335        assert_eq!(a, b);
336    }
337
338    #[test]
339    fn different_seeds_produce_different_ids() {
340        let a = build(ctx(None, None, false), IdSource::Fixed([1u8; 16]));
341        let b = build(ctx(None, None, false), IdSource::Fixed([2u8; 16]));
342        assert_ne!(a, b);
343    }
344
345    // Both elements of an array drawn from one seed must differ, or a save
346    // would claim ID[0] == ID[1] where the rules say otherwise.
347    #[test]
348    fn the_two_elements_of_one_array_differ() {
349        let old = Array::of([hex("keepme")]);
350        let id = build(ctx(Some(&old), None, false), seed());
351        let (first, second) = elements(&id);
352        assert_ne!(first, second);
353    }
354
355    // A random source really is random.
356    #[test]
357    fn a_random_source_differs_between_saves() {
358        let a = build(ctx(None, None, false), IdSource::Random);
359        let b = build(ctx(None, None, false), IdSource::Random);
360        assert_ne!(a, b);
361    }
362
363    // The array's elements are written hex, so the trailer reads
364    // `/ID[<32 hex><32 hex>]` — 16 bytes each, 32 digits each.
365    #[test]
366    fn elements_are_sixteen_bytes_spelled_as_hex() {
367        let id = build(ctx(None, None, false), seed());
368        for i in 0..2 {
369            let s = id.array.string_at(i).expect("a string");
370            assert!(s.hex, "the trailer spells /ID in hex");
371            assert_eq!(s.bytes.len(), 16);
372        }
373    }
374
375    // A non-string first element is not an identity worth keeping.
376    #[test]
377    fn a_junk_first_element_is_replaced() {
378        let old = Array::of([Object::Int(5), hex("second")]);
379        let id = build(ctx(Some(&old), None, false), seed());
380        assert_eq!(elements(&id).0.len(), 16);
381    }
382
383    #[test]
384    fn tag_bytes_are_stable_under_a_fixed_seed() {
385        let s = seed();
386        let first: Vec<u8> = (0..6).map(|i| s.tag_byte(i)).collect();
387        let again: Vec<u8> = (0..6).map(|i| s.tag_byte(i)).collect();
388        assert_eq!(first, again);
389    }
390
391    // The whole seed must reach every output byte. An earlier mix folded
392    // seed byte *i* into output byte *i* only, so two seeds differing past
393    // the sixth byte produced identical six-letter subset tags — two fonts
394    // with the same tag in one file.
395    #[test]
396    fn every_seed_byte_reaches_every_output_byte() {
397        let base = [0u8; 16];
398        let tag_of = |s: IdSource| -> Vec<u8> { (0..6).map(|i| s.tag_byte(i)).collect() };
399        let reference = tag_of(IdSource::Fixed(base));
400
401        for position in 0..16 {
402            let mut altered = base;
403            if let Some(slot) = altered.get_mut(position) {
404                *slot = 0xFF;
405            }
406            assert_ne!(
407                tag_of(IdSource::Fixed(altered)),
408                reference,
409                "changing seed byte {position} must change the tag"
410            );
411        }
412    }
413
414    // The same, for /ID: a seed differing anywhere gives a different array.
415    #[test]
416    fn every_seed_byte_reaches_the_id() {
417        let base = [0u8; 16];
418        let reference = build(ctx(None, None, false), IdSource::Fixed(base));
419        for position in 0..16 {
420            let mut altered = base;
421            if let Some(slot) = altered.get_mut(position) {
422                *slot = 0xFF;
423            }
424            assert_ne!(
425                build(ctx(None, None, false), IdSource::Fixed(altered)),
426                reference,
427                "changing seed byte {position} must change /ID"
428            );
429        }
430    }
431}