From 3a32cbb2aad5a83965908734e6595ea252557f7f Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 7 Sep 2026 20:52:35 -0700 Subject: [PATCH] feat: open native protected sections through an owned decode view Add optional known-password OneNote 2010 AES-128/CBC and SHA-1 decoding with bounded work, native read-only hash and graph checks, and zeroizing owned buffers. Reuse document traversal and reference-stream parsing; preserve opaque reads and reject protected writes. Extend the diagnostic exporter with exact password-file input and private Unix output directories. Retain an independent cold native oracle. Correct table comparison to distinguish documented auto-fit from locked widths; fixed-width mismatches remain failures. Validation: 174 Rust tests, 120 Python tests, eight doctests and Clippy pass; 14 live/private tests remain explicitly ignored in the public lane. Two ASan fuzz passes complete 25,676 cases. Native comparisons cover 12 pages and 1,947 format checks with exact assets; device and simulator executables link. All owned clones removed. Assisted-by: gpt-6-astra --- Cargo.lock | 53 +++ .../cold-2010-4763/manifest.json | 18 + ...0b7c57eb3b6110a06aa2114e06d69a7.attachment | 1 + .../cold-2010-4763/read/environment.json | 7 + .../cold-2010-4763/read/hierarchy.xml | 2 + .../cold-2010-4763/read/page-000.xml | 8 + .../cold-2010-4763/read/payloads.json | 10 + crates/onestore-notebook/Cargo.toml | 2 + crates/onestore-notebook/examples/document.rs | 63 +++- crates/onestore/Cargo.toml | 10 + crates/onestore/README.md | 41 +- crates/onestore/src/document.rs | 30 +- crates/onestore/src/lib.rs | 2 + crates/onestore/src/properties.rs | 69 ++-- crates/onestore/src/protected/crypto.rs | 349 ++++++++++++++++++ crates/onestore/src/protected/mod.rs | 275 ++++++++++++++ crates/onestore/tests/protected.rs | 110 ++++++ fuzz/Cargo.lock | 197 ++++++++++ fuzz/Cargo.toml | 10 +- fuzz/fuzz_targets/protected.rs | 78 ++++ tools/test_document_oracle.py | 15 + tools/test_protected.py | 77 ++++ tools/verify-document.py | 27 +- 23 files changed, 1405 insertions(+), 49 deletions(-) create mode 100644 corpus/native-encrypted/cold-2010-4763/manifest.json create mode 120000 corpus/native-encrypted/cold-2010-4763/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment create mode 100644 corpus/native-encrypted/cold-2010-4763/read/environment.json create mode 100644 corpus/native-encrypted/cold-2010-4763/read/hierarchy.xml create mode 100644 corpus/native-encrypted/cold-2010-4763/read/page-000.xml create mode 100644 corpus/native-encrypted/cold-2010-4763/read/payloads.json create mode 100644 crates/onestore/src/protected/crypto.rs create mode 100644 crates/onestore/src/protected/mod.rs create mode 100644 crates/onestore/tests/protected.rs create mode 100644 fuzz/fuzz_targets/protected.rs create mode 100644 tools/test_protected.py diff --git a/Cargo.lock b/Cargo.lock index c5e413c84686efa193d7964110b4e1c634b479ce..134b28812fc1fc962871306d0a332435f6c67ee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,7 @@ dependencies = [ "cipher", "cpubits", "cpufeatures", + "zeroize", ] [[package]] @@ -48,6 +49,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.1" @@ -59,6 +66,16 @@ name = "block-buffer" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" dependencies = [ "hybrid-array", ] @@ -75,6 +92,15 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.5" @@ -118,6 +144,7 @@ dependencies = [ "block-buffer", "crypto-common", "inout", + "zeroize", ] [[package]] @@ -206,6 +233,7 @@ dependencies = [ "const-oid", "crypto-common", "ctutils", + "zeroize", ] [[package]] @@ -342,6 +370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", + "zeroize", ] [[package]] @@ -360,6 +389,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ + "block-padding", "hybrid-array", ] @@ -503,11 +533,18 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "onestore" version = "0.1.0" dependencies = [ + "aes", + "base64", + "cbc", "getrandom", "md5", "nix", + "roxmltree", "serde", "serde_json", + "sha1", + "subtle", + "zeroize", ] [[package]] @@ -530,6 +567,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror", + "zeroize", ] [[package]] @@ -629,6 +667,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -1019,6 +1066,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.23" diff --git a/corpus/native-encrypted/cold-2010-4763/manifest.json b/corpus/native-encrypted/cold-2010-4763/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..5e511c886b3432176989ad1c4ef9f3a8ec34db55 --- /dev/null +++ b/corpus/native-encrypted/cold-2010-4763/manifest.json @@ -0,0 +1,18 @@ +{ + "source": "../encrypted-01/notebook/synthetic.one", + "source_sha256": "7a6e519cc0ef8de10357ffd52a32bd94c33ea09d8e420ff2d0b216ed141ff695", + "native_build": "14.0.4763.1000", + "cold_open": { + "pages": 1, + "hostname": "ONE-M6-31D693B8", + "cold": true + }, + "driver": { + "exitCode": 0, + "exited": true + }, + "teardown": { + "absent": true + }, + "observation": "Independent cold unlock of the unchanged encrypted image. The earlier selection-split empty T element is absent. Native auto-fits the first unlocked table column from stored 38.61 points to 39.146141 points without changing the source bytes." +} diff --git a/corpus/native-encrypted/cold-2010-4763/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/native-encrypted/cold-2010-4763/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 120000 index 0000000000000000000000000000000000000000..0316b8cfcb0eec21ab1516ddbfd09f8981d8b031 --- /dev/null +++ b/corpus/native-encrypted/cold-2010-4763/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +../../cold-encrypted-02/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment \ No newline at end of file diff --git a/corpus/native-encrypted/cold-2010-4763/read/environment.json b/corpus/native-encrypted/cold-2010-4763/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..3f32405fcec576188922f1ca3cb6b9d3e9ac880b --- /dev/null +++ b/corpus/native-encrypted/cold-2010-4763/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-M6-31D693B8", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/native-encrypted/cold-2010-4763/read/hierarchy.xml b/corpus/native-encrypted/cold-2010-4763/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..a24548b3f845af9161835bfc657ed5ceded3d4a4 --- /dev/null +++ b/corpus/native-encrypted/cold-2010-4763/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/native-encrypted/cold-2010-4763/read/page-000.xml b/corpus/native-encrypted/cold-2010-4763/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..93daaa6196eca996f4e7a69ecb8799dd2be176d0 --- /dev/null +++ b/corpus/native-encrypted/cold-2010-4763/read/page-000.xml @@ -0,0 +1,8 @@ + +Fictitious: café, 東京, مرحبا]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/native-encrypted/cold-2010-4763/read/payloads.json b/corpus/native-encrypted/cold-2010-4763/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..f2f5575177158d68a9316196afb6247a857ac4e3 --- /dev/null +++ b/corpus/native-encrypted/cold-2010-4763/read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{37B65EC3-5FE2-0133-022F-E64595E063A3}{32}{B0}", + "kind": "InsertedFile", + "page": "{75216EDF-30ED-03E0-13CD-C2FB6030B01D}{14}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/crates/onestore-notebook/Cargo.toml b/crates/onestore-notebook/Cargo.toml index 156fb00be96d586ef87e1fda176e66dad846e04d..476d94e25b04ebe68f127dc163221eb4a63840ed 100644 --- a/crates/onestore-notebook/Cargo.toml +++ b/crates/onestore-notebook/Cargo.toml @@ -6,12 +6,14 @@ publish = false [features] smb = ["dep:onestore-smb"] +protected = ["onestore/protected", "dep:zeroize"] [dependencies] onestore = { path = "../onestore" } onestore-smb = { path = "../onestore-smb", optional = true } serde = { version = "1", features = ["derive"] } thiserror = "2" +zeroize = { version = "1.9.0", optional = true } [dev-dependencies] serde_json = "1" diff --git a/crates/onestore-notebook/examples/document.rs b/crates/onestore-notebook/examples/document.rs index f894eb62368d2b46d541df5e0a1793067dc8b7d2..f38d1d5e6ad757f9faed31a8e013ecd12638fd9a 100644 --- a/crates/onestore-notebook/examples/document.rs +++ b/crates/onestore-notebook/examples/document.rs @@ -23,16 +23,67 @@ fn write_json( fn main() -> Result<(), Box> { let mut args = env::args_os().skip(1); let path = args.next().ok_or("Provide a .one or .onetoc2 file.")?; - let destination = args.next().map(PathBuf::from); - if args.next().is_some() { - return Err("Provide a source file and an optional new export directory.".into()); - } + let next = args.next(); + let (destination, option) = if next.as_deref() == Some(std::ffi::OsStr::new("--password-file")) + { + (None, next) + } else { + (next.map(PathBuf::from), args.next()) + }; + let password_file = match (option, args.next(), args.next()) { + (None, None, None) => None, + (Some(flag), Some(path), None) if flag == "--password-file" => Some(PathBuf::from(path)), + _ => return Err("Provide a source file, an optional new export directory, and optionally --password-file PATH.".into()), + }; let source_path = PathBuf::from(path); let bytes = onestore::read_file(&source_path)?; let store = Store::parse(&bytes)?; let index = RevisionIndex::parse(&store)?; + #[cfg(feature = "protected")] + let unlocked = if let Some(path) = &password_file { + use std::io::Read; + let mut password = zeroize::Zeroizing::new(String::new()); + fs::File::open(path)? + .take(65537) + .read_to_string(&mut password)?; + if password.len() > 65536 { + return Err("The password file exceeds 64 KiB.".into()); + } + Some(onestore::protected::UnlockedSection::open( + &index, + &password, + Default::default(), + )?) + } else { + None + }; + #[cfg(not(feature = "protected"))] + if password_file.is_some() { + return Err( + "Build this exporter with the protected feature to open a password-protected section." + .into(), + ); + } + #[cfg(feature = "protected")] + let document = match &unlocked { + Some(section) => section.document()?, + None => Document::parse(&index)?, + }; + #[cfg(not(feature = "protected"))] let document = Document::parse(&index)?; if let Some(destination) = destination { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new() + .mode(if password_file.is_some() { + 0o700 + } else { + 0o777 + }) + .create(&destination)?; + } + #[cfg(not(unix))] fs::create_dir(&destination)?; fs::create_dir(destination.join("assets"))?; let parent = source_path @@ -62,6 +113,10 @@ fn main() -> Result<(), Box> { Cow::Borrowed(payload.ok_or("Missing embedded file data")?) } FileDataReference::External(filename) => { + if password_file.is_some() { + assets.push(serde_json::json!({"reference":reference,"path":null,"error":{"kind":"Unsupported","message":"Protected external payload export is not supported"}})); + continue; + } match onestore_notebook::read_external_asset( &mut source, section, diff --git a/crates/onestore/Cargo.toml b/crates/onestore/Cargo.toml index 913bbfdecce76d9cc2e1309d3357d392b756871b..fbebf33c80c16582ae57c43426fdbd12eb2d9a42 100644 --- a/crates/onestore/Cargo.toml +++ b/crates/onestore/Cargo.toml @@ -4,10 +4,20 @@ version = "0.1.0" edition = "2024" publish = false +[features] +protected = ["dep:aes", "dep:base64", "dep:cbc", "dep:roxmltree", "dep:sha1", "dep:subtle", "dep:zeroize"] + [dependencies] md5 = "0.8.1" getrandom = "0.4.3" serde = { version = "1.0.229", features = ["derive"] } +aes = { version = "0.9.3", features = ["zeroize"], optional = true } +base64 = { version = "0.22.1", optional = true } +cbc = { version = "0.2.1", features = ["zeroize"], optional = true } +roxmltree = { version = "0.21.1", optional = true } +sha1 = { version = "0.11.0", features = ["zeroize"], optional = true } +subtle = { version = "2.6.1", optional = true } +zeroize = { version = "1.9.0", optional = true } [target.'cfg(target_os = "macos")'.dependencies] nix = { version = "0.31.3", default-features = false, features = ["fs"] } diff --git a/crates/onestore/README.md b/crates/onestore/README.md index d98f72a783fa9f1964e18a37d091251bac0f3f04..d6344cf19746fb4dc9617dbb7fdaf67d20d22a08 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -43,6 +43,7 @@ harness also accepts `--client-profile release`. | `PropertySets`, `Object::references` | Decode properties and ID streams while retaining raw values | | `Object::file_reference`, `Store::file_data` | Identify internal/external payloads and read internal payload bytes | | `document::Document`, `Revision::text_runs` | Interpret document objects and inherited text formatting while retaining unknown properties and revision identities | +| `protected::UnlockedSection` (optional feature) | Own decoded buffers for explicit known-password inspection; clear those buffers on drop; derived document strings/exports remain caller-owned | | `create_section` | Create one page containing one plain-text paragraph and an author, including Unicode | | `create_table_of_contents` | Create ordered section entries from filenames and file identities | | `replace_property_bytes` | Append one scalar-property revision; preserve prior revisions and unrelated property values and references | @@ -63,8 +64,11 @@ protected objects and split surrogate pairs are rejected before writing. Appended snapshots cap revision dependency depth at 512 while retaining historical revisions. TOC snapshots can remap encoded CompactIDs without changing their resolved references. Password-protected -sections retain their encrypted structure and payloads; the library does not -derive password keys or decrypt their pages. +sections retain their encrypted structure and payloads. With the optional +`protected` feature, `protected::UnlockedSection` opens native OneNote 2010 +AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect +passwords, unsupported protection profiles and work-limit failures remain distinct. +The source stays encrypted; protected writes are rejected. Insertions update child references, reference counts, modification times and automatic titles atomically. Paragraphs can be nested or inserted into table cells; outline coordinates use points. Retain the `Insertion` value for rebasing: creating another @@ -241,3 +245,36 @@ exact changes as well as retained image, ink, table, and attachment content. a dedicated loopback test session; its control JSON selects the successful response and occurrence to withhold. The captured trace and result files are the regression oracle; replaying the native experiments requires the supplied Windows/share setup. + +## Protected inspection + +```rust,no_run +# #[cfg(feature = "protected")] +# fn example() { +use onestore::{RevisionIndex, Store, protected::{Limits, UnlockedSection}}; +# fn inspect(bytes: &[u8], password: &str) -> Result<(), Box> { +let store = Store::parse(bytes)?; +let index = RevisionIndex::parse(&store)?; +let unlocked = UnlockedSection::open(&index, password, Limits::default())?; +let document = unlocked.document()?; +assert!(!document.pages()?.is_empty()); +drop(document); +drop(unlocked); +# Ok(()) } +# } +``` + +This example requires `features = ["protected"]`. The owner retains no password or +key after opening. Its source-buffer views cannot outlive it; copies of parsed +strings, serialized models and exports have their own lifetimes. These copies are +plaintext, and dropping the unlock owner does not clear them. CBC has no general +ciphertext-authentication guarantee; native read-only hashes and model validation +check the corresponding structure. Internal payloads are decoded; external payload +references remain references, and their protected decoding is not implemented. + +For a deliberate plaintext diagnostic export, build the notebook exporter with +`--features protected` and pass `--password-file PATH` after the source and optional +new output directory. The file contains exact UTF-8 password bytes; no newline is +removed or Unicode normalization applied. The exporter creates protected exports +under an owner-only directory on Unix and reports protected external payloads as +unsupported. It never rewrites the encrypted source. diff --git a/crates/onestore/src/document.rs b/crates/onestore/src/document.rs index 1d7e0c182a0ea0f73e08193e2621a568aaca253b..95d2ac31a361fd45b8bb7e31a78f048ec0bad69e 100644 --- a/crates/onestore/src/document.rs +++ b/crates/onestore/src/document.rs @@ -298,6 +298,7 @@ pub enum Kind<'a> { Table { rows: Option, columns: Option, + /// Stored widths in points; unlocked columns may fit their content in native layout. widths: Vec, locked: Vec, borders: Option, @@ -606,6 +607,18 @@ impl<'a> Document<'a> { /// Rejects checksum damage and follows referenced contexts, retaining encrypted payloads opaquely. pub fn parse(index: &RevisionIndex<'a>) -> Result { + Self::parse_with( + index, + |space, revision| index.resolve(space, revision), + |id| index.store.file_data(id), + ) + } + + pub(crate) fn parse_with( + index: &RevisionIndex<'a>, + mut resolve: impl FnMut(ExGuid, ExGuid) -> Result>, + mut file_data: impl FnMut([u8; 16]) -> Result<&'a [u8]>, + ) -> Result { if !index.store.checksum_mismatches.is_empty() { return Err(invalid("Document transaction checksum mismatch")); } @@ -628,7 +641,7 @@ impl<'a> Document<'a> { if space.revisions.contains_key(&rid) { continue; } - let revision = index.resolve(id, rid)?; + let revision = resolve(id, rid)?; let mut reachable = BTreeSet::new(); let mut objects: Vec<_> = revision.roots.values().copied().collect(); let mut encrypted = false; @@ -661,7 +674,10 @@ impl<'a> Document<'a> { ); pending.extend(refs.contexts.into_iter().map(|context| (id, context))); } - nodes.insert(oid, Element::parse(object, index.store)?); + nodes.insert( + oid, + Element::parse_with(object, index.store, &mut file_data)?, + ); } for node in nodes.values() { if let Kind::RichText { @@ -748,6 +764,14 @@ impl<'a> Document<'a> { impl<'a> Element<'a> { pub(crate) fn parse(object: &Object<'a>, store: &Store<'a>) -> Result { + Self::parse_with(object, store, &mut |id| store.file_data(id)) + } + + fn parse_with( + object: &Object<'a>, + store: &Store<'a>, + file_data: &mut impl FnMut([u8; 16]) -> Result<&'a [u8]>, + ) -> Result { let empty = |kind| Self { jcid: object.jcid, children: vec![], @@ -775,7 +799,7 @@ impl<'a> Element<'a> { .file_reference()? .ok_or_else(|| invalid("File object has no reference"))?; let payload = if let FileDataReference::Internal(guid) = &reference { - Some(store.file_data(*guid)?) + Some(file_data(*guid)?) } else { None }; diff --git a/crates/onestore/src/lib.rs b/crates/onestore/src/lib.rs index 1f8d1e2452be4f161b179a3784abb590ef027c19..70eb0e618e32bc1e3f9eea14ea1ad90ba1bbd717 100644 --- a/crates/onestore/src/lib.rs +++ b/crates/onestore/src/lib.rs @@ -12,6 +12,8 @@ mod formatting; mod insertion; mod objects; mod properties; +#[cfg(feature = "protected")] +pub mod protected; mod revisions; mod snapshot; mod store; diff --git a/crates/onestore/src/properties.rs b/crates/onestore/src/properties.rs index 0b427a36e7b776c3bb4da06916ba5f02fa69a35a..3990e69253670b4607bbc4c51622eb5d59224cec 100644 --- a/crates/onestore/src/properties.rs +++ b/crates/onestore/src/properties.rs @@ -41,38 +41,7 @@ enum Work<'a> { impl<'a> PropertySets<'a> { pub fn parse(bytes: &'a [u8]) -> Result { let mut c = Cursor { bytes, offset: 0 }; - let mut streams = std::array::from_fn::<_, 3, _>(|_| Cursor { - bytes: &[], - offset: 0, - }); - let mut extended = false; - for (index, stream) in streams.iter_mut().enumerate() { - let header = u32::from_le_bytes(c.read()?); - let count = usize::try_from(header & 0xffffff).unwrap(); - if (index > 0 && header & 0x80000000 != 0) - || (index == 0 && header & 0xc0000000 == 0xc0000000) - || (index == 1 && (header & 0x40000000 != 0) != extended) - || (index == 2 && header & 0x40000000 != 0) - { - return Err(Error { - offset: c.offset - 4, - message: "Inconsistent property reference-stream flags", - }); - } - let offset = c.offset; - *stream = Cursor { - bytes: c.take(count * 4)?, - offset, - }; - if index == 0 { - extended = header & 0x40000000 != 0; - if header & 0x80000000 != 0 { - break; - } - } else if index == 1 && !extended { - break; - } - } + let mut streams = reference_streams(&mut c)?; let mut sets = vec![Vec::new()]; let mut root_ids = &bytes[..0]; let mut work = vec![Work::Set(0)]; @@ -196,3 +165,39 @@ impl<'a> PropertySets<'a> { }) } } + +pub(crate) fn reference_streams<'a>(c: &mut Cursor<'a>) -> Result<[Cursor<'a>; 3], Error> { + let mut streams = std::array::from_fn::<_, 3, _>(|_| Cursor { + bytes: &[], + offset: 0, + }); + let mut extended = false; + for (index, stream) in streams.iter_mut().enumerate() { + let header = u32::from_le_bytes(c.read()?); + let count = usize::try_from(header & 0xffffff).unwrap(); + if (index > 0 && header & 0x80000000 != 0) + || (index == 0 && header & 0xc0000000 == 0xc0000000) + || (index == 1 && (header & 0x40000000 != 0) != extended) + || (index == 2 && header & 0x40000000 != 0) + { + return Err(Error { + offset: c.offset - 4, + message: "Inconsistent property reference-stream flags", + }); + } + let offset = c.offset; + *stream = Cursor { + bytes: c.take(count * 4)?, + offset, + }; + if index == 0 { + extended = header & 0x40000000 != 0; + if header & 0x80000000 != 0 { + break; + } + } else if index == 1 && !extended { + break; + } + } + Ok(streams) +} diff --git a/crates/onestore/src/protected/crypto.rs b/crates/onestore/src/protected/crypto.rs new file mode 100644 index 0000000000000000000000000000000000000000..6018240d23e9249c8c4204d75748152ab8db3a4a --- /dev/null +++ b/crates/onestore/src/protected/crypto.rs @@ -0,0 +1,349 @@ +use super::{Error, Result, invalid}; +use aes::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use sha1::{Digest, Sha1}; +use subtle::ConstantTimeEq; +use zeroize::{Zeroize, Zeroizing}; + +const NS: &str = "http://schemas.microsoft.com/office/2006/encryption"; +const PASSWORD_NS: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password"; + +pub(super) struct Key { + value: Zeroizing<[u8; 16]>, + file_iv: [u8; 16], +} + +fn child<'a, 'input>( + node: roxmltree::Node<'a, 'input>, + name: &str, + ns: &str, +) -> Result> { + let mut matches = node.children().filter(|n| n.has_tag_name((ns, name))); + let value = matches + .next() + .ok_or_else(|| invalid("Missing encryption metadata element"))?; + if matches.next().is_some() { + return Err(invalid("Repeated encryption metadata element")); + } + Ok(value) +} + +fn decoded(node: roxmltree::Node<'_, '_>, name: &str) -> Result<[u8; N]> { + let value = node + .attribute(name) + .ok_or_else(|| invalid("Missing encryption metadata attribute"))?; + let bytes = STANDARD + .decode(value.split_ascii_whitespace().collect::()) + .map_err(|_| invalid("Invalid encryption metadata base64"))?; + bytes + .try_into() + .map_err(|_| invalid("Invalid encryption metadata byte length")) +} + +fn profile(node: roxmltree::Node<'_, '_>) -> Result<()> { + for (attribute, value) in [ + ("saltSize", 16), + ("blockSize", 16), + ("keyBits", 128), + ("hashSize", 20), + ] { + if number(node, attribute)? != value { + return Err(Error::Unsupported); + } + } + for (attribute, value) in [ + ("cipherAlgorithm", "AES"), + ("cipherChaining", "ChainingModeCBC"), + ("hashAlgorithm", "SHA1"), + ] { + let actual = node + .attribute(attribute) + .ok_or_else(|| invalid("Missing encryption algorithm attribute"))?; + if actual != value { + return Err(Error::Unsupported); + } + } + Ok(()) +} + +fn number(node: roxmltree::Node<'_, '_>, name: &str) -> Result { + node.attribute(name) + .ok_or_else(|| invalid("Missing encryption numeric attribute"))? + .trim() + .parse() + .map_err(|_| invalid("Invalid encryption numeric attribute")) +} + +fn decrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) -> Result<()> { + cbc::Decryptor::::new(key.into(), iv.into()) + .decrypt_padded::(bytes) + .map_err(|_| invalid("Encrypted payload is not block aligned"))?; + Ok(()) +} + +impl Key { + pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result { + if data.len() > 65536 || password.len() > 65536 { + return Err(Error::Limit); + } + let mut c = crate::bytes::Cursor { + bytes: data, + offset: 0, + }; + if u32::from_le_bytes(c.read()?) != 3 { + return Err(Error::Unsupported); + } + let length = u32::from_le_bytes(c.read()?) as usize; + let offset = u32::from_le_bytes(c.read()?) as usize; + let inner = u32::from_le_bytes(c.read()?) as usize; + if length != data.len() || offset != 16 || inner != data.len() - 16 { + return Err(invalid("Inconsistent encryption metadata framing")); + } + if c.read::<8>()? != [4, 0, 4, 0, 64, 0, 0, 0] { + return Err(Error::Unsupported); + } + let text = std::str::from_utf8(c.bytes) + .map_err(|_| invalid("Encryption metadata is not UTF-8"))?; + let xml = roxmltree::Document::parse_with_options( + text, + roxmltree::ParsingOptions { + nodes_limit: 64, + ..Default::default() + }, + ) + .map_err(|_| invalid("Invalid encryption metadata XML"))?; + let root = xml.root_element(); + if !root.has_tag_name((NS, "encryption")) { + return Err(Error::Unsupported); + } + let data_key = child(root, "keyData", NS)?; + let encryptors = child(root, "keyEncryptors", NS)?; + if root.children().filter(|n| n.is_element()).count() != 2 + || encryptors.children().filter(|n| n.is_element()).count() != 1 + { + return Err(Error::Unsupported); + } + let encryptor = child(encryptors, "keyEncryptor", NS)?; + if encryptor.attribute("uri") != Some(PASSWORD_NS) + || encryptor.children().filter(|n| n.is_element()).count() != 1 + { + return Err(Error::Unsupported); + } + let wrapped = child(encryptor, "encryptedKey", PASSWORD_NS)?; + profile(data_key)?; + profile(wrapped)?; + let count = number(wrapped, "spinCount")?; + *rounds = rounds.checked_sub(u64::from(count)).ok_or(Error::Limit)?; + let salt = decoded::<16>(wrapped, "saltValue")?; + let mut verifier = Zeroizing::new(decoded::<16>(wrapped, "encryptedVerifierHashInput")?); + let mut expected = Zeroizing::new(decoded::<32>(wrapped, "encryptedVerifierHashValue")?); + let mut value = Zeroizing::new(decoded::<16>(wrapped, "encryptedKeyValue")?); + let mut hash = Sha1::new(); + hash.update(salt); + for word in password.encode_utf16() { + hash.update(word.to_le_bytes()); + } + let mut seed = Zeroizing::new(<[u8; 20]>::from(hash.finalize())); + for index in 0..count { + let mut hash = Sha1::new(); + hash.update(index.to_le_bytes()); + hash.update(seed.as_ref()); + *seed = hash.finalize().into(); + } + for (label, bytes) in [ + ( + [0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79], + verifier.as_mut_slice(), + ), + ( + [0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e], + expected.as_mut_slice(), + ), + ( + [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6], + value.as_mut_slice(), + ), + ] { + let mut hash = Sha1::new(); + hash.update(seed.as_ref()); + hash.update(label); + let derived = Zeroizing::new(<[u8; 20]>::from(hash.finalize())); + decrypt(derived[..16].try_into().unwrap(), &salt, bytes)?; + } + let actual = Zeroizing::new(<[u8; 20]>::from(Sha1::digest(verifier.as_slice()))); + if !bool::from(actual.as_slice().ct_eq(&expected[..20])) { + return Err(Error::PasswordMismatch); + } + let mut hash = Sha1::new(); + hash.update(decoded::<16>(data_key, "saltValue")?); + hash.update(0_u32.to_le_bytes()); + let file_iv = hash.finalize()[..16].try_into().unwrap(); + Ok(Self { value, file_iv }) + } + + pub(super) fn property(&self, input: &[u8]) -> Result>> { + let mut c = crate::bytes::Cursor { + bytes: input, + offset: 0, + }; + crate::properties::reference_streams(&mut c)?; + let prefix = c.offset; + let length = u32::from_le_bytes(c.read()?) as usize; + let encrypted = c.take(length)?; + if c.bytes.len() > 7 || c.bytes.iter().any(|b| *b != 0) { + return Err(invalid("Invalid encrypted property alignment")); + } + let (iv, body) = encrypted + .split_first_chunk::<16>() + .ok_or_else(|| invalid("Missing encrypted property IV"))?; + let mut clear = Zeroizing::new(body.to_vec()); + decrypt(&self.value, iv, &mut clear)?; + let padding = clear + .first_chunk::<2>() + .map(|b| usize::from(u16::from_le_bytes(*b))) + .ok_or_else(|| invalid("Missing encrypted property padding count"))?; + if padding >= 16 || clear.len() < 2 + padding { + return Err(invalid("Invalid encrypted property padding count")); + } + let mut output = Zeroizing::new(Vec::with_capacity(prefix + clear.len() - 2 - padding)); + output.extend_from_slice(&input[..prefix]); + output.extend_from_slice(&clear[2..clear.len() - padding]); + crate::PropertySets::parse(&output)?; + Ok(output) + } + + pub(super) fn file(&self, input: &[u8]) -> Result>> { + if input.is_empty() { + return Ok(Zeroizing::new(Vec::new())); + } + let mut clear = Zeroizing::new(input.to_vec()); + decrypt(&self.value, &self.file_iv, &mut clear)?; + let length = clear + .first_chunk::<8>() + .map(|b| u64::from_le_bytes(*b)) + .ok_or_else(|| invalid("Missing encrypted file length"))?; + let length = usize::try_from(length) + .map_err(|_| invalid("Encrypted file length exceeds address space"))?; + if length > clear.len() - 8 || clear.len() - 8 - length >= 16 { + return Err(invalid("Invalid encrypted file length")); + } + clear.copy_within(8..8 + length, 0); + clear[length..].zeroize(); + clear.truncate(length); + Ok(clear) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ObjectData, Reference, RevisionIndex, Store}; + + #[test] + fn native_frames_and_bounded_malformed_inputs() { + let root = std::path::Path::new("../../corpus/native-encrypted"); + let bytes = std::fs::read(root.join("encrypted-01/notebook/synthetic.one")).unwrap(); + let manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap(); + let password = manifest["password"].as_str().unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let node = index + .spaces + .values() + .next() + .unwrap() + .revisions + .values() + .next() + .unwrap() + .nodes + .first() + .unwrap(); + let Some(Reference::Data(chunk)) = node.reference else { + panic!("Missing native key") + }; + let metadata = store.encryption_key(chunk).unwrap(); + for length in 0..metadata.len() { + assert!(Key::open(&metadata[..length], password, &mut 0).is_err()); + } + let key = Key::open(metadata, password, &mut 100000).unwrap(); + let xml = std::str::from_utf8(&metadata[24..]).unwrap(); + let frame = |xml: &str| { + let mut value = metadata[..24].to_vec(); + value.extend_from_slice(xml.as_bytes()); + let length = u32::try_from(value.len()).unwrap(); + value[4..8].copy_from_slice(&length.to_le_bytes()); + value[12..16].copy_from_slice(&(length - 16).to_le_bytes()); + value + }; + assert!(matches!( + Key::open( + &frame(&xml.replace("keyBits=\"128\"", "keyBits=\"256\"")), + password, + &mut 100000 + ), + Err(Error::Unsupported) + )); + assert!(matches!( + Key::open( + &frame(&xml.replace("keyBits=\"128\"", "")), + password, + &mut 100000 + ), + Err(Error::Invalid(_)) + )); + let tree = roxmltree::Document::parse(xml).unwrap(); + let salt = child(tree.root_element(), "keyData", NS) + .unwrap() + .attribute("saltValue") + .unwrap(); + let spaced = xml + .replace("keyBits=\"128\"", "keyBits=\" +0128 \"") + .replace("spinCount=\"100000\"", "spinCount=\" 0100000 \"") + .replace(salt, &format!(" {}\n{} ", &salt[..8], &salt[8..])); + let spaced = Key::open(&frame(&spaced), password, &mut 100000).unwrap(); + for (sid, space) in &index.spaces { + for rid in space.labels.values() { + let revision = index.resolve(*sid, *rid).unwrap(); + for object in revision.objects.values() { + if let ObjectData::Encrypted(bytes) = object.data { + let mut cursor = crate::bytes::Cursor { bytes, offset: 0 }; + crate::properties::reference_streams(&mut cursor).unwrap(); + let length = u32::from_le_bytes(cursor.read().unwrap()) as usize; + let end = cursor.offset + length; + assert!(key.property(bytes).is_ok()); + assert_eq!( + *key.property(bytes).unwrap(), + *spaced.property(bytes).unwrap() + ); + for cut in 0..end { + assert!(key.property(&bytes[..cut]).is_err()); + } + let mut changed = bytes.to_vec(); + changed.push(1); + assert!(key.property(&changed).is_err()); + } + } + } + } + let mut state = 0x4851_e529_b30d_620f_u64; + for length in 0..1024 { + let mut bytes = vec![0; length]; + for byte in &mut bytes { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + *byte = state.to_le_bytes()[0]; + } + let _ = key.property(&bytes); + let _ = key.file(&bytes); + assert!(Key::open(&bytes, password, &mut 0).is_err()); + } + for index in 0..metadata.len() { + let mut changed = metadata.to_vec(); + changed[index] ^= 0x80; + assert!(Key::open(&changed, password, &mut 0).is_err()); + } + } +} diff --git a/crates/onestore/src/protected/mod.rs b/crates/onestore/src/protected/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..ee0a7141683095af3076256e5c1004c51b4a325a --- /dev/null +++ b/crates/onestore/src/protected/mod.rs @@ -0,0 +1,275 @@ +//! Explicit, in-memory opening of native OneNote 2010 protected sections. +//! +//! AES-128/CBC and SHA-1 Agile password wrappers are supported. Unknown wrappers +//! remain opaque through the ordinary storage/document APIs. CBC provides no +//! general ciphertext authentication; native read-only hashes and model checks +//! detect structural inconsistencies, not arbitrary changes to all content. + +mod crypto; + +use crate::{ExGuid, FileDataReference, ObjectData, Reference, RevisionIndex, document::Document}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, +}; +use zeroize::Zeroizing; + +type Result = std::result::Result; + +#[derive(Debug)] +pub enum Error { + PasswordMismatch, + Unsupported, + Limit, + Invalid(crate::Error), +} + +impl From for Error { + fn from(value: crate::Error) -> Self { + Self::Invalid(value) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PasswordMismatch => { + f.write_str("The password did not match the section verifier") + } + Self::Unsupported => f.write_str("This protection format is not supported"), + Self::Limit => f.write_str("Opening the protected section exceeded its work limit"), + Self::Invalid(error) => error.fmt(f), + } + } +} +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Invalid(error) => Some(error), + _ => None, + } + } +} + +fn invalid(message: &'static str) -> Error { + Error::Invalid(crate::Error { offset: 0, message }) +} + +#[derive(Debug, Clone, Copy)] +pub struct Limits { + /// Total password iterations across distinct encryption metadata containers. + pub kdf_rounds: u64, + /// Total ciphertext/reference bytes materialized; temporary copies can double this. + pub decoded_bytes: usize, + /// Sum of object counts in distinct labeled revisions. + pub object_visits: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + kdf_rounds: 1_000_000, + decoded_bytes: 256 * 1024 * 1024, + object_visits: 1_000_000, + } + } +} + +/// Owns decoded buffers until dropped; returned views cannot outlive this owner. +/// +/// Passwords and derived keys are not retained. Dropping this owner clears its +/// decoded buffers. Owned strings or exports made from a `Document` are separate +/// caller-owned copies and must be disposed of by the caller when locking. +/// Opening never rewrites the source image or changes its protection state. +/// +/// ```compile_fail +/// # use onestore::{RevisionIndex, protected::{Limits, UnlockedSection}}; +/// fn outlive<'a>(index: &'a RevisionIndex<'a>, password: &str) { +/// let unlocked = UnlockedSection::open(index, password, Limits::default()).unwrap(); +/// let document = unlocked.document().unwrap(); +/// drop(unlocked); +/// document.pages().unwrap(); +/// } +/// ``` +pub struct UnlockedSection<'a> { + index: &'a RevisionIndex<'a>, + objects: BTreeMap<(ExGuid, usize), Zeroizing>>, + files: BTreeMap<[u8; 16], Zeroizing>>, +} + +impl<'a> UnlockedSection<'a> { + /// Verifies the password and every labeled revision before exposing a view. + /// Metadata and password input are each bounded to 64 KiB. + pub fn open(index: &'a RevisionIndex<'a>, password: &str, mut limits: Limits) -> Result { + if index.store.header.file_type != crate::FileType::Section { + return Err(Error::Unsupported); + } + if !index.store.checksum_mismatches.is_empty() { + return Err(invalid("Protected document transaction checksum mismatch")); + } + let mut result = Self { + index, + objects: BTreeMap::new(), + files: BTreeMap::new(), + }; + let mut keys = BTreeMap::new(); + let mut file_keys = BTreeMap::new(); + let mut hashes = BTreeMap::new(); + for node in index.store.lists.values().flat_map(|list| &list.nodes) { + if !matches!(node.id, 0xc4 | 0xc5) { + continue; + } + let Some(Reference::Data(chunk)) = node.reference else { + return Err(invalid("Read-only object has no data reference")); + }; + let bytes = index.store.chunk_data(chunk)?; + let expected: [u8; 16] = node + .payload + .last_chunk::<16>() + .copied() + .ok_or_else(|| invalid("Missing read-only object hash"))?; + if hashes + .insert(bytes.as_ptr().addr(), expected) + .is_some_and(|old| old != expected) + { + return Err(invalid("Inconsistent read-only hashes for one payload")); + } + } + for (space_id, space) in &index.spaces { + let mut metadata = None; + for revision in space.revisions.values() { + if !revision.encrypted { + return Err(Error::Unsupported); + } + let node = revision + .nodes + .first() + .ok_or_else(|| invalid("Missing encryption key node"))?; + let Some(Reference::Data(chunk)) = node.reference else { + return Err(invalid("Missing encryption key reference")); + }; + let data = index.store.encryption_key(chunk)?; + if metadata.replace(data).is_some_and(|old| old != data) { + return Err(invalid("Object space changes its encryption metadata")); + } + } + let metadata = + metadata.ok_or_else(|| invalid("Protected object space has no revision"))?; + if !keys.contains_key(metadata) { + keys.insert( + metadata, + crypto::Key::open(metadata, password, &mut limits.kdf_rounds)?, + ); + } + let key = &keys[metadata]; + for rid in space.labels.values().copied().collect::>() { + let revision = index.resolve(*space_id, rid)?; + limits.object_visits = limits + .object_visits + .checked_sub(revision.objects.len()) + .ok_or(Error::Limit)?; + for object in revision.objects.values() { + match object.data { + ObjectData::Encrypted(bytes) => { + let identity = (*space_id, bytes.as_ptr().addr()); + let expected = hashes.get(&identity.1); + if object.jcid & 0x100000 != 0 && expected.is_none() { + return Err(invalid("Read-only encrypted object has no hash")); + } + if result.objects.contains_key(&identity) { + continue; + } + limits.decoded_bytes = limits + .decoded_bytes + .checked_sub(bytes.len()) + .ok_or(Error::Limit)?; + let decoded = key.property(bytes)?; + if let Some(expected) = expected { + let mut hash = md5::Context::new(); + hash.consume(&decoded); + hash.consume(&[0; 7][..(8 - decoded.len() % 8) % 8]); + if hash.finalize().0 != *expected { + return Err(invalid( + "Decrypted read-only object hash mismatch", + )); + } + } + result.objects.insert(identity, decoded); + } + ObjectData::File { .. } => { + if let Some(FileDataReference::Internal(guid)) = + object.file_reference()? + { + if file_keys + .insert(guid, metadata) + .is_some_and(|old| old != metadata) + { + return Err(invalid( + "File payload uses inconsistent encryption metadata", + )); + } + if result.files.contains_key(&guid) { + continue; + } + let bytes = index.store.file_data(guid)?; + limits.decoded_bytes = limits + .decoded_bytes + .checked_sub(bytes.len()) + .ok_or(Error::Limit)?; + result.files.insert(guid, key.file(bytes)?); + } + } + ObjectData::Properties(_) => { + return Err(invalid("Protected revision contains clear properties")); + } + } + } + } + } + drop(keys); + for (space, info) in &index.spaces { + for rid in info.labels.values().copied().collect::>() { + result.resolve(*space, rid)?.reachable()?; + } + } + result.document()?.pages()?; + Ok(result) + } + + fn resolve( + &self, + space: ExGuid, + rid: ExGuid, + ) -> std::result::Result, crate::Error> { + let mut revision = self.index.resolve(space, rid)?; + for object in revision.objects.values_mut() { + if let ObjectData::Encrypted(bytes) = object.data { + let decoded = + self.objects + .get(&(space, bytes.as_ptr().addr())) + .ok_or(crate::Error { + offset: 0, + message: "Protected object was not decoded", + })?; + object.data = ObjectData::Properties(decoded); + } + } + Ok(revision) + } + + pub fn document(&self) -> std::result::Result, crate::Error> { + Document::parse_with( + self.index, + |space, rid| self.resolve(space, rid), + |id| { + self.files + .get(&id) + .map(|bytes| bytes.as_slice()) + .ok_or(crate::Error { + offset: 0, + message: "Protected file payload was not decoded", + }) + }, + ) + } +} diff --git a/crates/onestore/tests/protected.rs b/crates/onestore/tests/protected.rs new file mode 100644 index 0000000000000000000000000000000000000000..9edd8e4ab4f3ce0e1a69238740f157b04e7d324b --- /dev/null +++ b/crates/onestore/tests/protected.rs @@ -0,0 +1,110 @@ +#![cfg(feature = "protected")] + +use onestore::{ + RevisionIndex, Store, + document::{Document, Kind}, + protected::{Error, Limits, UnlockedSection}, +}; +use std::{fs, path::Path}; + +#[test] +fn known_passwords_open_independent_native_fixtures() { + for (root, notebook, pages) in [ + ("native-encrypted", "encrypted-01/notebook/synthetic.one", 1), + ("native-protected-boundaries", "notebook/synthetic.one", 11), + ] { + let root = Path::new("../../corpus").join(root); + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); + let password = manifest["password"].as_str().unwrap(); + let bytes = fs::read(root.join(notebook)).unwrap(); + let before = bytes.clone(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); + let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap(); + let document = unlocked.document().unwrap(); + assert_eq!(document.pages().unwrap().len(), pages); + let (space, _) = document.pages().unwrap()[0]; + let current = &document.spaces[&space]; + let revision = ¤t.revisions[¤t.contexts[&onestore::ExGuid::default()]]; + let (object, _) = revision + .nodes + .iter() + .find(|(_, node)| matches!(node.kind, Kind::RichText { .. })) + .unwrap(); + assert!( + onestore::PreparedEdit::text(&bytes, space, *object, 0..0, "Must remain protected") + .is_err() + ); + assert!( + document + .spaces + .values() + .flat_map(|s| s.revisions.values()) + .flat_map(|r| r.nodes.values()) + .all(|n| !matches!(n.kind, Kind::Encrypted { .. })) + ); + assert!(matches!( + UnlockedSection::open( + &index, + "deliberately incorrect fixture password", + Limits::default() + ), + Err(Error::PasswordMismatch) + )); + assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty()); + assert_eq!(bytes, before); + } +} + +#[test] +fn limits_and_password_bytes_are_explicit() { + let root = Path::new("../../corpus/native-protected-boundaries"); + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap(); + let password = manifest["password"].as_str().unwrap(); + let bytes = fs::read(root.join("notebook/synthetic.one")).unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + for limits in [ + Limits { + kdf_rounds: 0, + ..Limits::default() + }, + Limits { + decoded_bytes: 0, + ..Limits::default() + }, + Limits { + object_visits: 0, + ..Limits::default() + }, + ] { + assert!(matches!( + UnlockedSection::open(&index, password, limits), + Err(Error::Limit) + )); + } + for changed in [ + password.replace("e\u{301}", "é"), + format!("{password}\n"), + password.to_uppercase(), + ] { + assert!(matches!( + UnlockedSection::open(&index, &changed, Limits::default()), + Err(Error::PasswordMismatch) + )); + } + assert!(matches!( + UnlockedSection::open(&index, &"x".repeat(65537), Limits::default()), + Err(Error::Limit) + )); + let ordinary = onestore::create_section("ordinary.one", "Fictitious", "Author").unwrap(); + let store = Store::parse(&ordinary).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + assert!(matches!( + UnlockedSection::open(&index, password, Limits::default()), + Err(Error::Unsupported) + )); +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 60579236e039128517842e559a9dcba0f72033dd..a2e4232b1ca6977f055ff4674e78bbf2830dfce9 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -2,18 +2,64 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", + "zeroize", +] + [[package]] name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.5" @@ -38,6 +84,60 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "zeroize", +] + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -55,6 +155,32 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", + "zeroize", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jobserver" version = "0.1.35" @@ -87,6 +213,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "nix" version = "0.31.3" @@ -103,10 +235,17 @@ dependencies = [ name = "onestore" version = "0.1.0" dependencies = [ + "aes", + "base64", + "cbc", "getrandom", "md5", "nix", + "roxmltree", "serde", + "sha1", + "subtle", + "zeroize", ] [[package]] @@ -116,6 +255,7 @@ dependencies = [ "libfuzzer-sys", "md5", "onestore", + "serde_json", ] [[package]] @@ -142,6 +282,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "serde" version = "1.0.229" @@ -172,12 +321,42 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "3.0.5" @@ -189,8 +368,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 3b3035b2e50c2dfd6b073c530800d942835d38ed..f898a806d40f6d2e347328f85e1cc012fd9f309c 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -9,8 +9,16 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" -onestore = { path = "../crates/onestore" } +onestore = { path = "../crates/onestore", features = ["protected"] } md5 = "0.8.1" +serde_json = "1" + +[[bin]] +name = "protected" +path = "fuzz_targets/protected.rs" +test = false +doc = false +bench = false [[bin]] name = "store" diff --git a/fuzz/fuzz_targets/protected.rs b/fuzz/fuzz_targets/protected.rs new file mode 100644 index 0000000000000000000000000000000000000000..fff52027bcff14c719eed425140062297b66f8e6 --- /dev/null +++ b/fuzz/fuzz_targets/protected.rs @@ -0,0 +1,78 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use onestore::{ + Reference, RevisionIndex, Store, + protected::{Limits, UnlockedSection}, +}; +use std::{ops::Range, sync::LazyLock}; + +static SOURCES: LazyLock, String, Vec>)>> = LazyLock::new(|| { + [ + (&include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one")[..], include_str!("../../corpus/native-encrypted/manifest.json")), + (&include_bytes!("../../corpus/native-protected-boundaries/notebook/synthetic.one")[..], include_str!("../../corpus/native-protected-boundaries/manifest.json")), + ].into_iter().map(|(bytes, manifest)| { + let manifest: serde_json::Value = serde_json::from_str(manifest).unwrap(); + let store = Store::parse(bytes).unwrap(); + let mut ranges: Vec<_> = store.lists.values().flat_map(|list| &list.nodes).filter_map(|node| { + let Reference::Data(chunk) = node.reference? else { return None; }; + let start = usize::try_from(chunk.offset).unwrap(); + let end = start + usize::try_from(chunk.length).unwrap(); + (start < end).then_some(start..end) + }).collect(); + ranges.sort_by_key(|range| (range.start, range.end)); + ranges.dedup(); + (bytes.to_vec(), manifest["password"].as_str().unwrap().to_owned(), ranges) + }).collect() +}); + +fuzz_target!(|data: &[u8]| { + if data.len() < 8 { + return; + } + let (source, password, ranges) = &SOURCES[usize::from(data[0]) % SOURCES.len()]; + let mut bytes = source.clone(); + let mut password = password.clone(); + let mode = data[1] % 3; + if mode == 2 { + password.push_str(&String::from_utf8_lossy(&data[8..])); + } else { + let range = if mode == 0 { + 0..bytes.len() + } else { + ranges[usize::from(u16::from_le_bytes([data[2], data[3]])) % ranges.len()].clone() + }; + let offset = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize % range.len(); + let count = (range.len() - offset).min(data.len() - 8); + bytes[range.start + offset..range.start + offset + count] + .copy_from_slice(&data[8..8 + count]); + } + let Ok(store) = Store::parse(&bytes) else { + return; + }; + let Ok(index) = RevisionIndex::parse(&store) else { + return; + }; + let result = UnlockedSection::open( + &index, + &password, + Limits { + kdf_rounds: 200000, + decoded_bytes: 4 * 1024 * 1024, + object_visits: 20000, + }, + ); + if mode == 2 && data.len() > 8 { + assert!(result.is_err()); + } + if let Ok(unlocked) = result { + let document = unlocked.document().unwrap(); + let _ = document.pages(); + for revision in document.spaces.values().flat_map(|s| s.revisions.values()) { + for (id, node) in &revision.nodes { + if matches!(node.kind, onestore::document::Kind::RichText { .. }) { + let _ = revision.text_runs(*id); + } + } + } + } +}); diff --git a/tools/test_document_oracle.py b/tools/test_document_oracle.py index 8a6badddb447581f5fa691b2485ff98465ba88d6..4b0d2804a7822c346c3286c36472dedc46a243b1 100644 --- a/tools/test_document_oracle.py +++ b/tools/test_document_oracle.py @@ -12,6 +12,21 @@ compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] class DocumentOracleTest(unittest.TestCase): + def test_locked_table_width_requires_the_native_value(self): + fixture = ROOT / 'corpus/m6/native-structure-01' + with TemporaryDirectory() as temporary: + native = Path(temporary) / 'read' + shutil.copytree(fixture / 'read', native) + compare(fixture / 'notebook', native) + path = native / 'page-001.xml' + xml = ET.parse(path) + column = next(n for n in xml.getroot().iter() + if n.tag.endswith('}Column') and n.get('isLocked') == 'true') + column.set('width', str(float(column.get('width')) + 1)) + xml.write(path, encoding='utf-8') + with self.assertRaisesRegex(AssertionError, 'Locked table column width differs'): + compare(fixture / 'notebook', native) + def test_native_2010_does_not_render_the_documented_cell_shading_control(self): import pdfplumber fixture = ROOT / 'corpus/m6/cell-shading-control-01/read/page-001' diff --git a/tools/test_protected.py b/tools/test_protected.py new file mode 100644 index 0000000000000000000000000000000000000000..92cfc270a39a8f2966f8eb3dbfc7f4f1e89e6efd --- /dev/null +++ b/tools/test_protected.py @@ -0,0 +1,77 @@ +import json +import os +from pathlib import Path +import runpy +import shutil +import stat +import subprocess +from tempfile import TemporaryDirectory +import unittest + +from document_model import EXPORTER + +ROOT = Path(__file__).resolve().parent.parent +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +class ProtectedDocumentTest(unittest.TestCase): + def test_native_autofit_is_distinct_from_a_fixed_column_width(self): + import xml.etree.ElementTree as ET + fixture = ROOT / 'corpus/native-encrypted' + manifest = json.loads((fixture / 'manifest.json').read_text()) + with TemporaryDirectory() as temporary: + root = Path(temporary) + password = root / 'password' + password.write_text(manifest['password']) + native = root / 'read' + shutil.copytree(fixture / 'cold-2010-4763/read', native) + compare(fixture / 'encrypted-01/notebook', native, password_file=password) + differences = json.loads((root / 'table-autofit.json').read_text()) + self.assertEqual(len(differences), 1) + self.assertAlmostEqual(differences[0]['stored'], 38.61, places=4) + self.assertAlmostEqual(differences[0]['native'], 39.146141, places=4) + xml_path = native / 'page-000.xml' + xml = ET.parse(xml_path) + column = next(n for n in xml.getroot().iter() if n.tag.endswith('}Column')) + column.set('isLocked', 'true') + xml.write(xml_path, encoding='utf-8') + with self.assertRaisesRegex(AssertionError, 'Table column lock state differs'): + compare(fixture / 'encrypted-01/notebook', native, password_file=password) + column.attrib.pop('isLocked') + column.set('width', 'NaN') + xml.write(xml_path, encoding='utf-8') + with self.assertRaisesRegex(AssertionError, 'Invalid native table column width'): + compare(fixture / 'encrypted-01/notebook', native, password_file=password) + + def test_native_page_formatting_and_all_attachment_boundaries(self): + fixture = ROOT / 'corpus/native-protected-boundaries' + manifest = json.loads((fixture / 'manifest.json').read_text()) + with TemporaryDirectory() as temporary: + root = Path(temporary) + password = root / 'password' + password.write_text(manifest['password']) + native = root / 'read' + shutil.copytree(fixture / 'read', native) + compare(fixture / 'notebook', native, password_file=password) + output = root / 'export' + subprocess.run([EXPORTER, fixture / 'notebook/synthetic.one', output, + '--password-file', password], check=True, capture_output=True) + if os.name == 'posix': + self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o700) + password.write_text(manifest['password'] + '\n') + failed = root / 'failed' + result = subprocess.run([EXPORTER, fixture / 'notebook/synthetic.one', failed, + '--password-file', password], capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, b'') + self.assertFalse(failed.exists()) + self.assertNotIn(manifest['password'].encode(), result.stderr) + password.write_text('x' * 65537) + result = subprocess.run([EXPORTER, fixture / 'notebook/synthetic.one', + '--password-file', password], capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(result.stdout, b'') + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/verify-document.py b/tools/verify-document.py index 5454f732aa32d96803e0f3f28915ec2e09bc5fc9..6bbc6b59b8b990ff5c389275f1fdd08f7b1acf8f 100644 --- a/tools/verify-document.py +++ b/tools/verify-document.py @@ -8,6 +8,7 @@ from tempfile import TemporaryDirectory from PIL import Image from datetime import datetime, timezone import json +import math from pathlib import Path, PureWindowsPath from zoneinfo import ZoneInfo import subprocess @@ -82,7 +83,7 @@ def visible_text(node, space): return "" if text == "\u00a0" else project_text(text.removesuffix('\r')) -def compare_objects(space, roots, page, native_roots, assets, native_payloads): +def compare_objects(space, roots, page, native_roots, assets, native_payloads, autofit): types = {'T': 'RichText', 'Image': 'Image', 'InsertedFile': 'Attachment', 'MediaFile': 'Attachment', 'Table': 'Table'} actual = [n for root in roots for _, n in walk(space, root) if n['kind']['type'] in types.values() and not n['kind'].get('boilerplate')] @@ -132,9 +133,15 @@ def compare_objects(space, roots, page, native_roots, assets, native_payloads): rows = native.findall('one:Row', ns) columns = native.findall('one:Columns/one:Column', ns) assert len(rows) == kind['rows'] and len(columns) == kind['columns'], 'Table dimensions differ' - for column, width in zip(columns, kind['widths'], strict=True): - assert abs(float(column.get('width')) - width) < .002, 'Table column width differs' assert (kind['locked'] or [False] * len(columns)) == [c.get('isLocked') == 'true' for c in columns], 'Table column lock state differs' + for column, width in zip(columns, kind['widths'], strict=True): + measured = float(column.get('width')) + assert math.isfinite(measured) and measured >= 0, 'Invalid native table column width' + if abs(measured - width) >= .002: + if column.get('isLocked') == 'true': + raise AssertionError('Locked table column width differs') + autofit.append({'page': page.get('ID'), 'table': parents[native].get('objectID'), + 'column': column.get('index'), 'stored': width, 'native': measured}) assert len(node['children']) == len(rows), 'Table row count differs' for oid, row in zip(node['children'], rows, strict=True): assert len(space['nodes'][oid]['children']) == len(row.findall('one:Cell', ns)), 'Table cell count differs' @@ -186,7 +193,7 @@ def compare_objects(space, roots, page, native_roots, assets, native_payloads): return count -def compare(notebook, native, versions=None): +def compare(notebook, native, versions=None, password_file=None): notebook = notebook.resolve(strict=True) native = native.resolve(strict=True) sections = sorted(notebook.rglob('*.one')) @@ -199,13 +206,17 @@ def compare(notebook, native, versions=None): discrepancies = [] pdf_checks = [] geometry = [] + autofit = [] for path in sections: relative = path.relative_to(notebook) if versions is not None and relative.as_posix() != versions['section']: continue with TemporaryDirectory() as temporary: exported = Path(temporary) / 'document' - subprocess.run([EXPORTER, path, exported], check=True) + command = [EXPORTER, path, exported] + if password_file is not None: + command.extend(['--password-file', password_file]) + subprocess.run(command, check=True) document = json.loads((exported / 'document.json').read_text()) resolved_text = json.loads((exported / 'text.json').read_text()) assets = {json.dumps(a['reference'], sort_keys=True): (exported / a['path']).read_bytes() @@ -308,7 +319,7 @@ def compare(notebook, native, versions=None): raise AssertionError(f'{relative}: page {ordinal}: ordered text differs; inspect {destination}') native_roots = [n for n in native_children if n.tag == '{' + ns['one'] + '}Title'] + content try: - tags += compare_objects(space, roots, page, native_roots, assets, native_payloads) + tags += compare_objects(space, roots, page, native_roots, assets, native_payloads, autofit) native_runs = native_characters(page, native_roots) text_ids = [oid for root in roots for oid, n in walk(space, root) if n['kind']['type'] == 'RichText' and not n['kind']['boilerplate']] @@ -339,6 +350,7 @@ def compare(notebook, native, versions=None): if versions is not None: assert compared == len(versions['pages']) > 0, 'No historical source section was compared' (native.parent / 'geometry-differences.json').write_text(json.dumps(geometry, indent=2)) + (native.parent / 'table-autofit.json').write_text(json.dumps(autofit, indent=2)) (native.parent / 'pdf-format-checks.json').write_text(json.dumps(pdf_checks, indent=2)) destination = native.parent / 'format-differences.json' destination.write_text(json.dumps(discrepancies, indent=2)) @@ -358,5 +370,6 @@ if __name__ == '__main__': parser.add_argument('notebook', type=Path) parser.add_argument('native', type=Path) parser.add_argument('--versions', type=Path, help='Native history UI date and copied-page associations.') + parser.add_argument('--password-file', type=Path, help='Exact UTF-8 password bytes; requires the protected exporter feature.') args = parser.parse_args() - compare(args.notebook.resolve(), args.native.resolve(), json.loads(args.versions.read_text()) if args.versions else None) + compare(args.notebook.resolve(), args.native.resolve(), json.loads(args.versions.read_text()) if args.versions else None, args.password_file) -- 2.54.0