diff --git a/corpus/protected-edit/README.md b/corpus/protected-edit/README.md new file mode 100644 index 0000000000000000000000000000000000000000..21d294a63600c317ebb157826b4e0d042aeecc67 --- /dev/null +++ b/corpus/protected-edit/README.md @@ -0,0 +1,13 @@ +# Protected page edit + +`candidate/notebook` is `native-encrypted/encrypted-01` after +`Notebook::save_unlocked` appended text to the first paragraph and added a +paragraph, exported by `an_unlocked_page_is_saved_under_the_section_key` +(`ONESTORE_PROTECTED_EXPORT`). `candidate/read` is OneNote 2010's COM read from a +fresh clone with an empty cache after unlocking the section in its UI with the +fixture password (`../native-encrypted/manifest.json`). + +`native-after/synthetic.one` is the same section after OneNote then typed +" Native edit after Rust." into the positioned outline and saved. Its revisions +that depend on an earlier revision carry no key node (`0x7c`); only revisions +without a dependency name the key. diff --git a/corpus/protected-edit/candidate/notebook/Open Notebook.onetoc2 b/corpus/protected-edit/candidate/notebook/Open Notebook.onetoc2 new file mode 100644 index 0000000000000000000000000000000000000000..3757c08bbbcd0eadbc8f0548068c44684f9b21dd Binary files /dev/null and b/corpus/protected-edit/candidate/notebook/Open Notebook.onetoc2 differ diff --git a/corpus/protected-edit/candidate/notebook/synthetic.one b/corpus/protected-edit/candidate/notebook/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..dc901c224a10ac21d40ce5ac3143483201c4d5f8 Binary files /dev/null and b/corpus/protected-edit/candidate/notebook/synthetic.one differ diff --git a/corpus/protected-edit/candidate/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment b/corpus/protected-edit/candidate/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment new file mode 100644 index 0000000000000000000000000000000000000000..66b6151e91f90df6a61a19ef2f4447da278e07b2 --- /dev/null +++ b/corpus/protected-edit/candidate/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment @@ -0,0 +1 @@ +Fictitious attachment for native corpus. \ No newline at end of file diff --git a/corpus/protected-edit/candidate/read/environment.json b/corpus/protected-edit/candidate/read/environment.json new file mode 100644 index 0000000000000000000000000000000000000000..72d528e800c26fa9676aa56590f514f284b54f3d --- /dev/null +++ b/corpus/protected-edit/candidate/read/environment.json @@ -0,0 +1,7 @@ +{ + "powershell": "5.1.14409.1005", + "schema": "xs2010", + "hostname": "ONE-F02GATE", + "cold": false, + "onenote": "14.0.4763.1000" +} diff --git a/corpus/protected-edit/candidate/read/hierarchy.xml b/corpus/protected-edit/candidate/read/hierarchy.xml new file mode 100644 index 0000000000000000000000000000000000000000..956fb8c3c6befceb98ff9743ba2abd12bade9088 --- /dev/null +++ b/corpus/protected-edit/candidate/read/hierarchy.xml @@ -0,0 +1,2 @@ + + diff --git a/corpus/protected-edit/candidate/read/page-000.xml b/corpus/protected-edit/candidate/read/page-000.xml new file mode 100644 index 0000000000000000000000000000000000000000..23c95a03d81023e6f6cc16107559ec409c999d72 --- /dev/null +++ b/corpus/protected-edit/candidate/read/page-000.xml @@ -0,0 +1,9 @@ + +Fictitious: café, 東京, مرحبا and edited under its key]]>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA +AAAASUVORK5CYII= diff --git a/corpus/protected-edit/candidate/read/payloads.json b/corpus/protected-edit/candidate/read/payloads.json new file mode 100644 index 0000000000000000000000000000000000000000..627d31bc43c3370a0ffc5de4b3ee6007cb9e0762 --- /dev/null +++ b/corpus/protected-edit/candidate/read/payloads.json @@ -0,0 +1,10 @@ +[ + { + "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7", + "name": "fictitious-attachment.txt", + "object": "{6899E045-AC46-0B2E-1C63-C98811ADBC94}{32}{B0}", + "kind": "InsertedFile", + "page": "{2A0ED059-C349-09FD-0D81-ED36E47D6F2A}{14}{B0}", + "bytes": 43 + } +] \ No newline at end of file diff --git a/corpus/protected-edit/native-after/synthetic.one b/corpus/protected-edit/native-after/synthetic.one new file mode 100644 index 0000000000000000000000000000000000000000..0caa86dd598140aa762d8251b80aae0a936c32b4 Binary files /dev/null and b/corpus/protected-edit/native-after/synthetic.one differ diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 7e97726caf7588ffed76fc134b87a6915de9d8ad..0371f3ecdb671ae0ad48befe308b0b7d50ac5882 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -347,8 +347,11 @@ when the page itself was moved to another section. Other URLs and unknown targets are `None`. With the optional `protected` feature, `Notebook::unlock(path, password)` -reads the pages of a `Locked` section for display; nothing is cached or -written, and a wrong password is `Error::Protected(PasswordMismatch)`. +reads the pages of a `Locked` section, and `Notebook::save_unlocked(path, +password, space, page, author)` saves an edited one under the section's key, +straight to the file: nothing of a protected section is cached or queued, a +section changed since the read fails the save, and a wrong password is +`Error::Protected(PasswordMismatch)`. `Section::import_page(page, author)` copies a page, usually read from another section, to the end of this one as a page creation and a save queued like the diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index f926999b243f7e07192aebc2e3ed78d84cc9c8a5..4f36018af75e14d1ffe398d096b92b463ecb2dd0 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -643,6 +643,24 @@ impl Notebook { .collect() } + /// Saves a page read through `unlock`, stored under the section's key. The save goes + /// straight to the file and fails if the section changed since `unlock` read it; + /// nothing of a protected section is cached or queued. + #[cfg(feature = "protected")] + pub fn save_unlocked( + &self, + path: &str, + password: &str, + space: ExGuid, + page: &Page, + author: &str, + ) -> Result<()> { + let path = self.section_path(path)?.path.clone(); + let bytes = self.storage.read(&path)?; + let edit = onestore::PreparedEdit::page_protected(&bytes, password, space, page, author)?; + self.storage.commit(&path, &edit) + } + /// Opens a section of a mounted notebook by its catalog path. pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result
{ let path = self.section_path(path)?.path.clone(); diff --git a/crates/notebook/tests/protected.rs b/crates/notebook/tests/protected.rs index 9fe75e76a21ce67bce41efb8e85eb0d7eeaf9c9d..15d8864e358903324cb14de813d5972c3bbe601e 100644 --- a/crates/notebook/tests/protected.rs +++ b/crates/notebook/tests/protected.rs @@ -27,3 +27,90 @@ fn a_locked_section_unlocks_for_reading() { )) )); } + +/// An unlocked page is edited and saved under the section's key, then reads back with the +/// same password. `ONESTORE_PROTECTED_EXPORT` names a directory receiving the notebook for +/// a cold reopen in OneNote. +#[test] +fn an_unlocked_page_is_saved_under_the_section_key() { + use onestore::page::{PageObject, Paragraph, text::new_id}; + let root = Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../corpus/native-encrypted" + )); + 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 temporary = tempfile::tempdir().unwrap(); + let copy = temporary.path().join("notebook"); + std::fs::create_dir(©).unwrap(); + for entry in std::fs::read_dir(root.join("encrypted-01/notebook")).unwrap() { + let entry = entry.unwrap(); + std::fs::copy(entry.path(), copy.join(entry.file_name())).unwrap(); + } + let notebook = Notebook::open(©, temporary.path().join("cache")).unwrap(); + let section = notebook + .catalog() + .sections + .iter() + .find(|section| matches!(section.state, SectionState::Locked)) + .unwrap() + .path + .clone(); + let (space, mut page) = notebook.unlock(§ion, password).unwrap().remove(0); + let paragraphs = page + .objects + .iter_mut() + .find_map(|object| match object { + PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs), + _ => None, + }) + .unwrap(); + let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); + let mut added = paragraphs[at].clone(); + added.id = new_id().unwrap(); + added.style = None; + added.lists.clear(); + added.tags.clear(); + let text = added.text_mut().unwrap(); + text.id = new_id().unwrap(); + let format = text.text.format_at(0).unwrap().clone(); + text.text = Paragraph::new( + "Written while protected 🦀".to_owned(), + onestore::document::Format { + font: Some("Calibri".into()), + font_size: Some(11.0), + language: Some(1033), + ..Default::default() + }, + ); + paragraphs.insert(at + 1, added); + paragraphs[at] + .text_mut() + .unwrap() + .text + .append(Paragraph::new( + " and edited under its key".to_owned(), + format, + )) + .unwrap(); + assert!(matches!( + notebook.save_unlocked(§ion, "wrong", space, &page, "Rust"), + Err(notebook::Error::Protected( + onestore::protected::Error::PasswordMismatch + )) + )); + notebook + .save_unlocked(§ion, password, space, &page, "Rust") + .unwrap(); + let (_, stored) = notebook.unlock(§ion, password).unwrap().remove(0); + assert!(stored.objects == page.objects); + if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") { + let export = Path::new(&export); + std::fs::create_dir_all(export).unwrap(); + for entry in std::fs::read_dir(©).unwrap() { + let entry = entry.unwrap(); + std::fs::copy(entry.path(), export.join(entry.file_name())).unwrap(); + } + } +} diff --git a/crates/onestore/README.md b/crates/onestore/README.md index 249ded674288292f417d54906bac30ebd5a23b0c..88c1eee232e4759bebe8df67b31a5785ff3d8106 100644 --- a/crates/onestore/README.md +++ b/crates/onestore/README.md @@ -82,7 +82,9 @@ 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. +The source stays encrypted. `PreparedEdit::page_protected` publishes a page-model +edit stored under the section's key (fresh IV per object, payloads under the file +IV); the other writers reject protected sections. `PageCreation::new` appends, or inserts before the first page space of an existing series. `Some("")` creates an empty title field; `None` omits the title node. The page has no body outlines, generated date/time text or applied template. @@ -337,8 +339,8 @@ drop(unlocked); # } ``` -This example requires `features = ["protected"]`. The owner retains no password or -key after opening. Its source-buffer views cannot outlive it; copies of parsed +This example requires `features = ["protected"]`. The owner retains no password; +its derived key is cleared on drop. 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 diff --git a/crates/onestore/src/commit.rs b/crates/onestore/src/commit.rs index d78b98d3d3bd80940cb23420fb8e3c7cbb0908a2..2a325fcc92d319016dffdb7d17ebe89a362a0468 100644 --- a/crates/onestore/src/commit.rs +++ b/crates/onestore/src/commit.rs @@ -261,6 +261,22 @@ impl<'a> PreparedEdit<'a> { }) } + /// `page` for a password-protected section: the revision is stored under the section's + /// key. The model must come from this snapshot unlocked with `password`. + #[cfg(feature = "protected")] + pub fn page_protected( + source: &'a [u8], + password: &str, + space: ExGuid, + page: &crate::page::Page, + author: &str, + ) -> Result { + Ok(Self { + source, + written: crate::protected::write_page(source, password, space, page, author)?, + }) + } + /// Creates a page and its section entry in one transaction, retaining the intent's identities. pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result { Ok(Self { diff --git a/crates/onestore/src/page/write.rs b/crates/onestore/src/page/write.rs index 7fa8eecf5328521938fff47b9ff021d274dafff9..9b9a829421cd23890116a4c139a2c2de7d6e3b61 100644 --- a/crates/onestore/src/page/write.rs +++ b/crates/onestore/src/page/write.rs @@ -361,7 +361,7 @@ pub(crate) fn write_page( if lowering.image == source { return Ok(lowering.image); } - squash(source, &lowering.image, &lowering.alias) + squash(source, &lowering.image, &lowering.alias, None) } /// Direct children of every container, in model order, plus lookups by identity. @@ -2909,11 +2909,13 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result, + protection: Option<&dyn crate::write::Protection>, ) -> Result, Error> { let rename: BTreeMap = alias .iter() @@ -2939,17 +2941,24 @@ fn squash( payloads.push((guid, applied_store.file_data(guid)?)); } } - crate::write::write_revisions_with_payloads(source, &payloads, |index| { + let edit = |index: &RevisionIndex<'_>| { let mut changes = BTreeMap::new(); for sid in applied_index.spaces.keys() { let Some(space) = index.spaces.get(sid) else { + // The twin's scaffold spaces are not the section's. + if protection.is_some() { + continue; + } return Err(invalid("Page edits cannot create object spaces")); }; let after_rid = applied_index.active(*sid)?; if space.labels.get(&(ExGuid::default(), 1)) == Some(&after_rid) { continue; } - let before = index.resolve_active(*sid)?; + let before = match protection { + Some(protection) => protection.resolve(*sid, index.active(*sid)?)?, + None => index.resolve_active(*sid)?, + }; let after = applied_index.resolve(*sid, after_rid)?; if before.roots != after.roots { return Err(invalid("Page edits cannot change revision roots")); @@ -3008,7 +3017,11 @@ fn squash( changes.insert(*sid, RevisionEdit::Update(changed)); } Ok(changes) - }) + }; + match protection { + Some(_) => crate::write::append_revisions(source, &payloads, protection, edit), + None => crate::write::write_revisions_with_payloads(source, &payloads, edit), + } } fn remap(object: &mut PropertyObject, rename: &BTreeMap) -> Result<(), Error> { diff --git a/crates/onestore/src/protected/crypto.rs b/crates/onestore/src/protected/crypto.rs index 6018240d23e9249c8c4204d75748152ab8db3a4a..b25e027b7aa2c47e986cca4ff33553705bdef534 100644 --- a/crates/onestore/src/protected/crypto.rs +++ b/crates/onestore/src/protected/crypto.rs @@ -1,5 +1,5 @@ use super::{Error, Result, invalid}; -use aes::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding}; +use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::NoPadding}; use base64::{Engine, engine::general_purpose::STANDARD}; use sha1::{Digest, Sha1}; use subtle::ConstantTimeEq; @@ -81,6 +81,13 @@ fn decrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) -> Result<()> { Ok(()) } +fn encrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) { + let length = bytes.len(); + cbc::Encryptor::::new(key.into(), iv.into()) + .encrypt_padded::(bytes, length) + .expect("block aligned"); +} + impl Key { pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result { if data.len() > 65536 || password.len() > 65536 { @@ -212,6 +219,42 @@ impl Key { Ok(output) } + /// The stored form of a plaintext property object, the inverse of `property`. + pub(super) fn seal_property(&self, clear: &[u8], iv: [u8; 16]) -> Result> { + let mut c = crate::bytes::Cursor { + bytes: clear, + offset: 0, + }; + crate::properties::reference_streams(&mut c)?; + let prefix = c.offset; + let padding = (16 - (2 + clear.len() - prefix) % 16) % 16; + let mut body = Zeroizing::new((padding as u16).to_le_bytes().to_vec()); + body.extend_from_slice(&clear[prefix..]); + let length = body.len() + padding; + body.resize(length, 0); + getrandom::fill(&mut body[length - padding..]) + .map_err(|_| invalid("System random source failed"))?; + encrypt(&self.value, &iv, &mut body); + let mut output = clear[..prefix].to_vec(); + output.extend_from_slice(&((16 + body.len()) as u32).to_le_bytes()); + output.extend_from_slice(&iv); + output.extend_from_slice(&body); + output.resize(output.len().next_multiple_of(8), 0); + Ok(output) + } + + /// The stored form of a file payload, the inverse of `file`. + pub(super) fn seal_file(&self, clear: &[u8]) -> Vec { + if clear.is_empty() { + return Vec::new(); + } + let mut output = (clear.len() as u64).to_le_bytes().to_vec(); + output.extend_from_slice(clear); + output.resize(output.len().next_multiple_of(16), 0); + encrypt(&self.value, &self.file_iv, &mut output); + output + } + pub(super) fn file(&self, input: &[u8]) -> Result>> { if input.is_empty() { return Ok(Zeroizing::new(Vec::new())); @@ -312,7 +355,17 @@ mod tests { 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()); + let iv = bytes[cursor.offset..cursor.offset + 16].try_into().unwrap(); + // Native padding is arbitrary; it only reaches the last block. + let sealed = key + .seal_property(&key.property(bytes).unwrap(), iv) + .unwrap(); + assert_eq!(sealed.len(), bytes.len()); + assert_eq!(sealed[..end - 16], bytes[..end - 16]); + assert_eq!( + *key.property(&sealed).unwrap(), + *key.property(bytes).unwrap() + ); assert_eq!( *key.property(bytes).unwrap(), *spaced.property(bytes).unwrap() diff --git a/crates/onestore/src/protected/mod.rs b/crates/onestore/src/protected/mod.rs index ee0a7141683095af3076256e5c1004c51b4a325a..9e8d38cb931f44897dec0ec1e7779fc784c95140 100644 --- a/crates/onestore/src/protected/mod.rs +++ b/crates/onestore/src/protected/mod.rs @@ -75,9 +75,14 @@ impl Default for Limits { } } +struct Decoded<'a> { + stored: &'a [u8], + clear: Zeroizing>, +} + /// Owns decoded buffers until dropped; returned views cannot outlive this owner. /// -/// Passwords and derived keys are not retained. Dropping this owner clears its +/// Passwords are not retained. Dropping this owner clears its derived key and /// 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. @@ -93,8 +98,10 @@ impl Default for Limits { /// ``` pub struct UnlockedSection<'a> { index: &'a RevisionIndex<'a>, - objects: BTreeMap<(ExGuid, usize), Zeroizing>>, + /// By object space and stored address. + objects: BTreeMap<(ExGuid, usize), Decoded<'a>>, files: BTreeMap<[u8; 16], Zeroizing>>, + keys: BTreeMap<&'a [u8], crypto::Key>, } impl<'a> UnlockedSection<'a> { @@ -111,6 +118,7 @@ impl<'a> UnlockedSection<'a> { index, objects: BTreeMap::new(), files: BTreeMap::new(), + keys: BTreeMap::new(), }; let mut keys = BTreeMap::new(); let mut file_keys = BTreeMap::new(); @@ -141,10 +149,9 @@ impl<'a> UnlockedSection<'a> { if !revision.encrypted { return Err(Error::Unsupported); } - let node = revision - .nodes - .first() - .ok_or_else(|| invalid("Missing encryption key node"))?; + let Some(node) = revision.nodes.first().filter(|node| node.id == 0x7c) else { + continue; + }; let Some(Reference::Data(chunk)) = node.reference else { return Err(invalid("Missing encryption key reference")); }; @@ -194,7 +201,13 @@ impl<'a> UnlockedSection<'a> { )); } } - result.objects.insert(identity, decoded); + result.objects.insert( + identity, + Decoded { + stored: bytes, + clear: decoded, + }, + ); } ObjectData::File { .. } => { if let Some(FileDataReference::Internal(guid)) = @@ -226,7 +239,7 @@ impl<'a> UnlockedSection<'a> { } } } - drop(keys); + result.keys = keys; for (space, info) in &index.spaces { for rid in info.labels.values().copied().collect::>() { result.resolve(*space, rid)?.reachable()?; @@ -251,7 +264,7 @@ impl<'a> UnlockedSection<'a> { offset: 0, message: "Protected object was not decoded", })?; - object.data = ObjectData::Properties(decoded); + object.data = ObjectData::Properties(&decoded.clear); } } Ok(revision) @@ -273,3 +286,157 @@ impl<'a> UnlockedSection<'a> { ) } } + +impl UnlockedSection<'_> { + /// A plaintext section holding every object space's current revision and payloads + /// under their own identities, for writers that read ordinary sections. + fn twin(&self) -> std::result::Result>, crate::Error> { + use crate::write::{PropertyObject, RevisionEdit, append_revisions}; + let copy = |space: ExGuid, revision: ExGuid| { + let revision = self.resolve(space, revision)?; + let mut objects = BTreeMap::new(); + for (id, object) in &revision.objects { + let copy = match object.data { + ObjectData::File { + reference, + extension, + } => { + let text = |bytes: &[u8]| { + String::from_utf16_lossy( + &bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(), + ) + }; + let mut copy = + PropertyObject::file(*id, &text(reference), &text(extension))?; + copy.jcid = object.jcid; + copy.global_ids = std::sync::Arc::clone(&object.global_ids); + copy + } + _ => PropertyObject::from_object(object)?, + }; + objects.insert(*id, copy); + } + Ok::<_, crate::Error>((revision.roots, objects)) + }; + let payloads: Vec<_> = self + .files + .iter() + .map(|(id, bytes)| (*id, bytes.as_slice())) + .collect(); + let current = (ExGuid::default(), 1); + // The scaffold's root space takes the section's identity, then its content. + let mut scaffold = crate::create_section("twin.one", "", "")?; + let identity = |id: ExGuid| { + let mut bytes = Vec::new(); + id.encode(&mut bytes); + bytes + }; + let store = crate::Store::parse(&scaffold)?; + let placeholder = identity(RevisionIndex::parse(&store)?.root); + for at in 0..scaffold.len() - 20 { + if scaffold[at..at + 20] == placeholder { + scaffold[at..at + 20].copy_from_slice(&identity(self.index.root)); + } + } + let mut twin = Zeroizing::new(append_revisions(&scaffold, &payloads, None, |_| { + let mut spaces = BTreeMap::new(); + for id in self.index.spaces.keys() { + let (roots, objects) = copy(*id, self.index.active(*id)?)?; + let edit = if *id == self.index.root { + RevisionEdit::Label { + context: current.0, + role: current.1, + roots, + objects, + } + } else { + RevisionEdit::Create { roots, objects } + }; + spaces.insert(*id, edit); + } + Ok(spaces) + })?); + // One revision per call and space: the remaining labels follow in rounds. + for round in 0.. { + let mut spaces = BTreeMap::new(); + for (id, space) in &self.index.spaces { + let label = space.labels.iter().filter(|(label, _)| **label != current); + if let Some(((context, role), revision)) = label.clone().nth(round) { + let (roots, objects) = copy(*id, *revision)?; + spaces.insert( + *id, + RevisionEdit::Label { + context: *context, + role: *role, + roots, + objects, + }, + ); + } + } + if spaces.is_empty() { + break; + } + twin = Zeroizing::new(append_revisions(&twin, &[], None, |_| Ok(spaces))?); + } + Ok(twin) + } +} + +/// An edited page of a protected section as one stored revision per changed space, the +/// protected counterpart of [`crate::PreparedEdit::page`]. +pub(crate) fn write_page( + source: &[u8], + password: &str, + space: ExGuid, + page: &crate::page::Page, + author: &str, +) -> Result> { + let store = crate::Store::parse(source)?; + let index = RevisionIndex::parse(&store)?; + let unlocked = UnlockedSection::open(&index, password, Limits::default())?; + let twin = unlocked.twin()?; + let applied = Zeroizing::new(crate::page::write::write_page(&twin, space, page, author)?); + let written = crate::page::write::squash(source, &applied, &BTreeMap::new(), Some(&unlocked))?; + let store = crate::Store::parse(&written)?; + UnlockedSection::open(&RevisionIndex::parse(&store)?, password, Limits::default())?; + Ok(written) +} + +impl crate::write::Protection for UnlockedSection<'_> { + fn resolve( + &self, + space: ExGuid, + revision: ExGuid, + ) -> std::result::Result, crate::Error> { + self.resolve(space, revision) + } + + fn stored(&self, clear: &[u8]) -> Option<&[u8]> { + self.objects + .values() + .find(|decoded| std::ptr::eq(decoded.clear.as_ptr(), clear.as_ptr())) + .map(|decoded| decoded.stored) + } + + fn seal_property(&self, clear: &[u8]) -> std::result::Result, crate::Error> { + let [key] = self.keys.values().collect::>()[..] else { + return Err(crate::Error { + offset: 0, + message: "Protected writing needs one section key", + }); + }; + let failed = |message| crate::Error { offset: 0, message }; + let mut iv = [0; 16]; + getrandom::fill(&mut iv).map_err(|_| failed("System random source failed"))?; + key.seal_property(clear, iv) + .map_err(|_| failed("Malformed property object")) + } + + fn seal_file(&self, clear: &[u8]) -> Vec { + self.keys.values().next().unwrap().seal_file(clear) + } +} diff --git a/crates/onestore/src/revisions.rs b/crates/onestore/src/revisions.rs index 3ffbcee3e6aff1e89ceba5436280aa9e8cfc2fe2..7e9b7e49b4767245409e2b1034f85cca441aa302 100644 --- a/crates/onestore/src/revisions.rs +++ b/crates/onestore/src/revisions.rs @@ -286,8 +286,11 @@ impl<'a> RevisionIndex<'a> { }); } } - if (encoding == 2) - != body.first().is_some_and(|node| node.id == 0x7c) + // A protected revision names its key unless it inherits the + // key of the revision it depends on. + let keyed = body.first().is_some_and(|node| node.id == 0x7c); + if keyed && encoding != 2 + || !keyed && encoding == 2 && dependency.is_none() { return Err(Error { offset: node.offset, @@ -304,7 +307,7 @@ impl<'a> RevisionIndex<'a> { message: "Object space mixes encrypted and unencrypted revisions", }); } - if encoding == 2 { + if keyed { let key = &body[0]; let Some(Reference::Data(chunk)) = key.reference else { return Err(Error { diff --git a/crates/onestore/src/write.rs b/crates/onestore/src/write.rs index e9266a1735575b317ae1e04a743f6a30bc30add8..06f8621b819c07207ba254e024cbdc2de916a261 100644 --- a/crates/onestore/src/write.rs +++ b/crates/onestore/src/write.rs @@ -374,6 +374,14 @@ pub(crate) enum RevisionEdit { roots: BTreeMap, objects: BTreeMap, }, + /// A complete revision of an existing space under a context and role, without history. + #[cfg(feature = "protected")] + Label { + context: ExGuid, + role: u32, + roots: BTreeMap, + objects: BTreeMap, + }, } impl PropertyObject { @@ -698,6 +706,16 @@ impl PropertyObject { } } +/// A password-protected section's unlocked view, through which its revisions are +/// read as plaintext and written back in their stored form. +pub(crate) trait Protection { + fn resolve(&self, space: ExGuid, revision: ExGuid) -> Result>; + /// The stored bytes a resolved object's plaintext was decoded from. + fn stored(&self, clear: &[u8]) -> Option<&[u8]>; + fn seal_property(&self, clear: &[u8]) -> Result>; + fn seal_file(&self, clear: &[u8]) -> Vec; +} + pub(crate) fn write_revision( source: &[u8], space: ExGuid, @@ -769,6 +787,22 @@ pub(crate) fn write_revisions_with_payloads( source: &[u8], payloads: &[([u8; 16], &[u8])], edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, +) -> Result> { + let store = Store::parse(source)?; + RevisionIndex::parse(&store)?.validate_current()?; + let output = append_revisions(source, payloads, None, edit)?; + let store = Store::parse(&output)?; + RevisionIndex::parse(&store)?.validate_current()?; + Ok(output) +} + +/// `write_revisions_with_payloads` without validating that current revisions are +/// complete: a protected section is validated by unlocking it, through `protection`. +pub(crate) fn append_revisions( + source: &[u8], + payloads: &[([u8; 16], &[u8])], + protection: Option<&dyn Protection>, + edit: impl FnOnce(&RevisionIndex<'_>) -> Result>, ) -> Result> { let store = Store::parse(source)?; let is_section = store.header.file_type == FileType::Section; @@ -779,7 +813,10 @@ pub(crate) fn write_revisions_with_payloads( }); } let index = RevisionIndex::parse(&store)?; - index.validate_current()?; + let resolve = |space, rid| match protection { + Some(protection) => protection.resolve(space, rid), + None => index.resolve(space, rid), + }; let changes = edit(&index)?; let mut output = source.to_vec(); // Native files reserve 1 KiB per transaction-log fragment; a fragment that ends the file @@ -810,10 +847,12 @@ pub(crate) fn write_revisions_with_payloads( let mut counts = Vec::new(); let mut root_nodes = Vec::new(); for (space, change) in changes { - let (rid, mut revision, mut replacements) = match change { + let new_space = matches!(change, RevisionEdit::Create { .. }); + let current = (ExGuid::default(), 1_u32); + let (rid, label, mut revision, mut replacements) = match change { RevisionEdit::Update(objects) => { let rid = index.active(space)?; - (Some(rid), index.resolve(space, rid)?, objects) + (Some(rid), current, resolve(space, rid)?, objects) } RevisionEdit::Create { roots, objects } => { if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) { @@ -830,6 +869,30 @@ pub(crate) fn write_revisions_with_payloads( } ( None, + current, + crate::ResolvedRevision { + roots, + objects: BTreeMap::new(), + }, + objects, + ) + } + #[cfg(feature = "protected")] + RevisionEdit::Label { + context, + role, + roots, + objects, + } => { + if !is_section || role > 0xffff || !index.spaces.contains_key(&space) { + return Err(Error { + offset: 0, + message: "Choose a label in a section's object space", + }); + } + ( + None, + (context, role), crate::ResolvedRevision { roots, objects: BTreeMap::new(), @@ -1062,16 +1125,45 @@ pub(crate) fn write_revisions_with_payloads( if !is_section { start.extend_from_slice(&0_u64.to_le_bytes()); } - start.extend_from_slice(&1_u32.to_le_bytes()); - start.extend_from_slice(&0_u16.to_le_bytes()); + start.extend_from_slice(&label.1.to_le_bytes()); + start.extend_from_slice(&(if protection.is_some() { 2_u16 } else { 0 }).to_le_bytes()); + let contextual = label.0 != ExGuid::default(); + if contextual { + label.0.encode(&mut start); + } let mut manifest = Vec::new(); - if rid.is_none() { + if new_space { let mut payload = Vec::new(); space.encode(&mut payload); payload.extend_from_slice(&0_u32.to_le_bytes()); manifest.push(node(0x14, None, &payload)?); } - manifest.push(node(if is_section { 0x1e } else { 0x1b }, None, &start)?); + manifest.push(node( + match (is_section, contextual) { + (true, true) => 0x1f, + (true, false) => 0x1e, + (false, _) => 0x1b, + }, + None, + &start, + )?); + // A dependent revision inherits its key, as OneNote writes it. + if protection.is_some() && checkpoint { + let key = index + .spaces + .get(&space) + .and_then(|space| { + space + .revisions + .values() + .find_map(|revision| revision.nodes.first().filter(|node| node.id == 0x7c)) + }) + .ok_or(Error { + offset: 0, + message: "Protected revisions continue a protected object space", + })?; + manifest.push(node(0x7c, key.reference, key.payload)?); + } for (table, objects) in groups { let mut payload = Vec::new(); let mut group = if is_section { @@ -1139,14 +1231,26 @@ pub(crate) fn write_revisions_with_payloads( } append(&mut output, &mapped)? } else if replacements.contains_key(&id) { - append(&mut output, bytes)? + match protection { + Some(protection) => { + append(&mut output, &protection.seal_property(bytes)?)? + } + None => append(&mut output, bytes)?, + } } else { + let stored = match protection { + Some(protection) => protection.stored(bytes).ok_or(Error { + offset: 0, + message: "Protected object has no stored form", + })?, + None => bytes, + }; Chunk { offset: u64::try_from( - bytes.as_ptr().addr() - source.as_ptr().addr(), + stored.as_ptr().addr() - source.as_ptr().addr(), ) .unwrap(), - length: u64::try_from(bytes.len()).unwrap(), + length: u64::try_from(stored.len()).unwrap(), } }; // A table-of-contents object is declared when the revision is a @@ -1164,7 +1268,13 @@ pub(crate) fn write_revisions_with_payloads( declaration.extend_from_slice(&object.reference_count.to_le_bytes()); let readonly = object.jcid & 0x100000 != 0; if readonly { - declaration.extend_from_slice(&md5::compute(bytes).0); + // A protected declaration hashes the plaintext as aligned. + let mut hash = md5::Context::new(); + hash.consume(bytes); + if protection.is_some() { + hash.consume(&[0; 7][..(8 - bytes.len() % 8) % 8]); + } + declaration.extend_from_slice(&hash.finalize().0); } group.push(node( if is_section { @@ -1219,7 +1329,7 @@ pub(crate) fn write_revisions_with_payloads( } } manifest.push(node(0x1c, None, &[])?); - if rid.is_none() { + if new_space { let list_id = allocate_list()?; let chunk = append_list(&mut output, list_id, &manifest)?; counts.push((list_id, manifest.len())); @@ -1269,6 +1379,8 @@ pub(crate) fn write_revisions_with_payloads( 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a, 0x9e, 0xac, ]; + let sealed = protection.map(|protection| protection.seal_file(payload)); + let payload = sealed.as_deref().unwrap_or(*payload); blob.extend_from_slice(&(payload.len() as u64).to_le_bytes()); blob.extend_from_slice(&[0; 12]); blob.extend_from_slice(payload); @@ -1410,8 +1522,6 @@ pub(crate) fn write_revisions_with_payloads( message: "File generation counter is exhausted", })?; output[228..236].copy_from_slice(&generation.to_le_bytes()); - let written = Store::parse(&output)?; - let written_index = RevisionIndex::parse(&written)?; - written_index.validate_current()?; + RevisionIndex::parse(&Store::parse(&output)?)?; Ok(output) } diff --git a/crates/onestore/tests/protected.rs b/crates/onestore/tests/protected.rs index 9edd8e4ab4f3ce0e1a69238740f157b04e7d324b..5f676566a198e5ea31a33d50a6c003d331c5ba09 100644 --- a/crates/onestore/tests/protected.rs +++ b/crates/onestore/tests/protected.rs @@ -108,3 +108,132 @@ fn limits_and_password_bytes_are_explicit() { Err(Error::Unsupported) )); } + +/// The first text paragraph of every page gains text; the written file stays protected, +/// opens with the same password and reads back as the edited model. +#[test] +fn a_protected_page_edit_is_stored_under_the_section_key() { + use onestore::page::{Page, PageObject, Paragraph}; + for (root, notebook) in [ + ("native-encrypted", "encrypted-01/notebook/synthetic.one"), + ("native-protected-boundaries", "notebook/synthetic.one"), + ] { + 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 mut bytes = fs::read(root.join(notebook)).unwrap(); + let pages = |bytes: &[u8]| -> Vec<(onestore::ExGuid, Page)> { + 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(); + document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| (space, Page::from_space(&document, space).unwrap())) + .collect() + }; + let mut expected = pages(&bytes); + let mut edited = 0; + for (space, page) in &mut expected { + let paragraphs = page.objects.iter_mut().find_map(|object| match object { + PageObject::Outline(outline) + if outline.paragraphs.iter().any(|p| p.text().is_some()) => + { + Some(&mut outline.paragraphs) + } + _ => None, + }); + let Some(paragraphs) = paragraphs else { + continue; + }; + edited += 1; + let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap(); + let mut file = paragraphs[at].clone(); + file.id = onestore::page::text::new_id().unwrap(); + file.lists.clear(); + file.tags.clear(); + file.style = None; + file.format = Default::default(); + file.content = + onestore::page::ParagraphContent::Attachment(onestore::page::Attachment { + id: onestore::page::text::new_id().unwrap(), + filename: "sealed.txt".into(), + source_path: None, + size: Some([24.0, 24.0]), + bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])), + preview: None, + recording: None, + }); + paragraphs.insert(at + 1, file); + let text = paragraphs[at].text_mut().unwrap(); + let format = text.text.format_at(0).unwrap().clone(); + text.text + .append(Paragraph::new(" still protected".to_owned(), format)) + .unwrap(); + let edit = + onestore::PreparedEdit::page_protected(&bytes, password, *space, page, "Rust") + .unwrap(); + assert!(matches!( + onestore::PreparedEdit::page_protected(&bytes, "wrong", *space, page, "Rust"), + Err(Error::PasswordMismatch) + )); + let written = edit.as_bytes().to_vec(); + for clear in [&b" still protected"[..], b"stays sealed"] { + assert!(!written.windows(clear.len()).any(|w| w == clear)); + } + let revisions = |bytes: &[u8]| { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + index + .spaces + .iter() + .map(|(id, space)| (*id, space.revisions.len())) + .collect::>() + }; + let grown: Vec<_> = revisions(&bytes) + .into_iter() + .zip(revisions(&written)) + .filter(|(before, after)| before != after) + .map(|(before, _)| before.0) + .collect(); + assert_eq!(grown, [*space]); + bytes = written; + } + assert!(edited > 0); + let stored = pages(&bytes); + assert_eq!(stored.len(), expected.len()); + for ((_, stored), (_, expected)) in stored.iter().zip(&expected) { + assert_eq!(stored.objects, expected.objects); + } + } +} + +/// OneNote's revisions that depend on an earlier one carry no key node of their own. +#[test] +fn a_native_revision_inherits_the_key_of_its_dependency() { + let bytes = fs::read("../../corpus/protected-edit/native-after/synthetic.one").unwrap(); + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read("../../corpus/native-encrypted/manifest.json").unwrap()) + .unwrap(); + let store = Store::parse(&bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + assert!(index.spaces.values().any(|space| { + space.revisions.values().any(|revision| { + revision.encrypted && revision.nodes.first().is_none_or(|node| node.id != 0x7c) + }) + })); + let unlocked = UnlockedSection::open( + &index, + manifest["password"].as_str().unwrap(), + Limits::default(), + ) + .unwrap(); + let document = unlocked.document().unwrap(); + let (space, _) = document.pages().unwrap()[0]; + let page = onestore::page::Page::from_space(&document, space).unwrap(); + assert!(format!("{:?}", page.objects).contains("Native edit after Rust.")); +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 643170fddd8efa442a2289a43102a41b808724a8..e1057c999b35a12c9fcb5c69eb8e0ba8bd108ed0 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -136,3 +136,10 @@ path = "fuzz_targets/page_model.rs" test = false doc = false bench = false + +[[bin]] +name = "protected_write" +path = "fuzz_targets/protected_write.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/protected_write.rs b/fuzz/fuzz_targets/protected_write.rs new file mode 100644 index 0000000000000000000000000000000000000000..d53ab85164e265629325de058942dbee7f27555b --- /dev/null +++ b/fuzz/fuzz_targets/protected_write.rs @@ -0,0 +1,69 @@ +#![no_main] +//! Chains page edits on a protected section: every written image unlocks with the same +//! password, reads back as the edited model and stores none of the new text in clear. +use libfuzzer_sys::fuzz_target; +use onestore::{ + ExGuid, PreparedEdit, RevisionIndex, Store, + page::{Page, PageObject, Paragraph, text::Edit}, + protected::{Limits, UnlockedSection}, +}; + +const SOURCE: &[u8] = + include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one"); +const PASSWORD: &str = "fictitious-only"; + +fn page(bytes: &[u8]) -> (ExGuid, Page) { + let store = Store::parse(bytes).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap(); + let document = unlocked.document().unwrap(); + let (space, _) = document.pages().unwrap()[0]; + (space, Page::from_space(&document, space).unwrap()) +} + +fuzz_target!(|data: &[u8]| { + let mut bytes = SOURCE.to_vec(); + for step in data.chunks(12).take(4) { + if step.len() < 4 { + return; + } + let (space, mut after) = page(&bytes); + let mut texts: Vec<_> = after + .objects + .iter_mut() + .filter_map(|object| match object { + PageObject::Outline(outline) => Some(&mut outline.paragraphs), + _ => None, + }) + .flatten() + .filter_map(|paragraph| paragraph.text_mut()) + .collect(); + let count = texts.len(); + let text = &mut texts[usize::from(step[0]) % count].text; + let end = text.utf16_offset(text.text().len()).unwrap(); + let start = u32::from(step[1]) % (end + 1); + let stop = (start + u32::from(step[2]) % 8).min(end); + // Marked so its absence from the stored bytes is checkable. + let inserted = format!("\u{1f512}sealed\u{1f512}{}", String::from_utf8_lossy(&step[3..]).replace('\0', "")); + let format = text.format_at(start.min(end.saturating_sub(1))).unwrap().clone(); + let edit = Edit { + range: start..stop, + replacement: Paragraph::new(inserted.clone(), format), + }; + let (Ok(_), Ok(_)) = (text.byte_offset(start), text.byte_offset(stop)) else { + return; + }; + if text.apply(edit).is_err() { + return; + } + let Ok(edit) = PreparedEdit::page_protected(&bytes, PASSWORD, space, &after, "Fuzz") else { + return; + }; + let written = edit.as_bytes().to_vec(); + let clear: Vec = inserted.encode_utf16().flat_map(u16::to_le_bytes).collect(); + assert!(!written[bytes.len()..].windows(clear.len()).any(|w| w == clear)); + let (_, stored) = page(&written); + assert!(stored.objects == after.objects); + bytes = written; + } +}); diff --git a/tools/test_protected_edit.py b/tools/test_protected_edit.py new file mode 100644 index 0000000000000000000000000000000000000000..afa2994f982c85a4753c90df62e63bb9f1d78107 --- /dev/null +++ b/tools/test_protected_edit.py @@ -0,0 +1,45 @@ +import json +from pathlib import Path +import runpy +import shutil +import subprocess +from tempfile import TemporaryDirectory +import unittest + +from document_model import EXPORTER + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / 'corpus/protected-edit' +compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] + + +class ProtectedEditTest(unittest.TestCase): + def setUp(self): + self.temporary = TemporaryDirectory() + self.root = Path(self.temporary.name) + self.password = self.root / 'password' + manifest = json.loads((ROOT / 'corpus/native-encrypted/manifest.json').read_text()) + self.password.write_text(manifest['password']) + + def tearDown(self): + self.temporary.cleanup() + + def test_onenote_unlocks_and_reads_the_page_saved_under_the_section_key(self): + native = self.root / 'read' + shutil.copytree(FIXTURE / 'candidate/read', native) + compare(FIXTURE / 'candidate/notebook', native, password_file=self.password) + page = (native / 'page-000.xml').read_text(encoding='utf-8-sig') + self.assertIn('and edited under its key', page) + self.assertIn('Written while protected', page) + + def test_the_native_edit_that_followed_unlocks_with_both_edits(self): + output = self.root / 'export' + subprocess.run([EXPORTER, FIXTURE / 'native-after/synthetic.one', output, + '--password-file', self.password], check=True, capture_output=True) + text = (output / 'text.json').read_text() + for expected in ['Native edit after Rust.', 'Written while protected', 'and edited under its key']: + self.assertIn(expected, text) + + +if __name__ == '__main__': + unittest.main()