authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-19 18:55:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-25 20:26:20-07:00
log860f9341c754f84c8ce5d06a103d2d1fd39fefdb
tree5540be3b4f8492375eeca8e632a3a5ed7cc8ca52
parent2393166f77111e9fcba3a433294bddc0c4df5ec9
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: save page edits to password-protected sections

PreparedEdit::page_protected and Notebook::save_unlocked publish a page-model edit under the section key. The page writer runs unchanged on a plaintext twin of the unlocked section (every labelled revision and payload under its own identity); the revisions the twin gained are squashed onto the real file with objects sealed under fresh IVs and payloads under the file IV. OneNote 2010 unlocks and reads the result and keeps editing it (corpus/protected-edit). Its follow-up revisions showed that a revision with a dependency carries no key node; the reader accepts that and the writer matches. Assisted-by: claude-fable-5.1

23 files changed, 804 insertions(+), 40 deletions(-)

corpus/protected-edit/README.md created+13
......@@ -0,0 +1,13 @@
1# Protected page edit
2
3`candidate/notebook` is `native-encrypted/encrypted-01` after
4`Notebook::save_unlocked` appended text to the first paragraph and added a
5paragraph, exported by `an_unlocked_page_is_saved_under_the_section_key`
6(`ONESTORE_PROTECTED_EXPORT`). `candidate/read` is OneNote 2010's COM read from a
7fresh clone with an empty cache after unlocking the section in its UI with the
8fixture password (`../native-encrypted/manifest.json`).
9
10`native-after/synthetic.one` is the same section after OneNote then typed
11" Native edit after Rust." into the positioned outline and saved. Its revisions
12that depend on an earlier revision carry no key node (`0x7c`); only revisions
13without a dependency name the key.
corpus/protected-edit/candidate/notebook/Open Notebook.onetoc2 created
Binary files /dev/null and b/corpus/protected-edit/candidate/notebook/Open Notebook.onetoc2 differ
corpus/protected-edit/candidate/notebook/synthetic.one created
Binary files /dev/null and b/corpus/protected-edit/candidate/notebook/synthetic.one differ
corpus/protected-edit/candidate/read/af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7.attachment created+1
......@@ -0,0 +1 @@
1Fictitious attachment for native corpus.
\ No newline at end of file
corpus/protected-edit/candidate/read/environment.json created+7
......@@ -0,0 +1,7 @@
1{
2 "powershell": "5.1.14409.1005",
3 "schema": "xs2010",
4 "hostname": "ONE-F02GATE",
5 "cold": false,
6 "onenote": "14.0.4763.1000"
7}
corpus/protected-edit/candidate/read/hierarchy.xml created+2
......@@ -0,0 +1,2 @@
1<?xml version="1.0"?>
2<one:Notebook xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" name="notebook" nickname="notebook" ID="{AF67071F-BC0D-4B24-B776-154401563287}{1}{B0}" path="C:\one-tests\runs\capture2\notebook" lastModifiedTime="2026-09-20T01:25:56.000Z" color="#9595AA" isCurrentlyViewed="true"><one:Section name="synthetic" ID="{A2130C29-2A49-0477-0174-EA40D89E5334}{1}{B0}" path="C:\one-tests\runs\capture2\notebook\synthetic.one" lastModifiedTime="2026-09-20T01:25:56.000Z" color="#8AA8E4" encrypted="true" isCurrentlyViewed="true"><one:Page ID="{2A0ED059-C349-09FD-0D81-ED36E47D6F2A}{14}{B0}" name="Fictitious: café, 東京, مرحبا and edited under its key" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-20T01:25:56.000Z" pageLevel="1" isCurrentlyViewed="true"/></one:Section></one:Notebook>
corpus/protected-edit/candidate/read/page-000.xml created+9
......@@ -0,0 +1,9 @@
1<?xml version="1.0"?>
2<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2010/onenote" ID="{2A0ED059-C349-09FD-0D81-ED36E47D6F2A}{14}{B0}" name="Fictitious: café, 東京, مرحبا and edited under its key" dateTime="2026-09-05T05:06:54.000Z" lastModifiedTime="2026-09-20T01:26:12.000Z" pageLevel="1" isCurrentlyViewed="true" lang="en-US"><one:QuickStyleDef index="0" name="p" fontColor="automatic" highlightColor="automatic" font="Calibri" fontSize="11.0" spaceBefore="0.0" spaceAfter="0.0"/><one:PageSettings RTL="false" color="automatic"><one:PageSize><one:Automatic/></one:PageSize><one:RuleLines visible="false"/></one:PageSettings><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{20}{B0}"><one:Position x="36.0" y="14.40000057220459" z="0"/><one:Size width="127.5136947631836" height="41.15543365478516"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:55.000Z" lastModifiedTime="2026-09-20T01:25:21.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{19}{B0}" alignment="left" quickStyleIndex="0" style="font-size:11.0pt;color:#1F4E79"><one:T><![CDATA[<span
3style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span
4style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span
5style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span
6style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed'
7lang=ar-SA>مرحبا</span><span style='font-weight:bold;font-family:Calibri'
8lang=en-US> and edited under its key</span>]]></one:T></one:OE><one:OE author="Rust" lastModifiedBy="Rust" creationTime="2026-09-20T01:25:21.000Z" lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{9CC82D66-8ADA-0FEE-18AF-5F667C54984D}{1}{B0}" alignment="left" style="font-family:Calibri;font-size:11.0pt"><one:T><![CDATA[Written while protected &#129408;]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{25}{B0}"><one:Position x="144.0" y="96.0" z="1"/><one:Size width="167.0449523925781" height="13.42771339416504"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:57.000Z" lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{24}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-05T05:06:58.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{34}{B0}"><one:Position x="144.0" y="192.0" z="2"/><one:Size width="72.0" height="0.750005722045898"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:58.000Z" lastModifiedTime="2026-09-05T05:06:58.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{27}{B0}" alignment="left"><one:Image format="png" originalPageNumber="0"><one:Data>iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoA
9AAAASUVORK5CYII=</one:Data></one:Image></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-05T05:06:59.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{33}{B0}"><one:Position x="264.0" y="192.0" z="3"/><one:Size width="72.00001525878906" height="63.0"/><one:OEChildren><one:OE creationTime="2026-09-05T05:06:59.000Z" lastModifiedTime="2026-09-05T05:06:59.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{32}{B0}" alignment="left"><one:InsertedFile pathCache="C:\Users\clover\AppData\Local\Temp\OneNote\14.0\DNT\8722b1ff-e9f5-47ac-a873-6d5fcbde718b.txt" pathSource="C:\one-tests\runs\20260905-05\assets\fictitious-attachment.txt" preferredName="fictitious-attachment.txt"/></one:OE></one:OEChildren></one:Outline><one:Outline author="snow" authorInitials="S" lastModifiedBy="snow" lastModifiedByInitials="S" lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{44}{B0}"><one:Position x="144.0" y="312.0" z="4"/><one:Size width="92.42181396484374" height="21.94773101806641"/><one:OEChildren><one:OE creationTime="2026-09-05T05:07:00.000Z" lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{43}{B0}" alignment="left"><one:Table bordersVisible="true" lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{42}{B0}"><one:Columns><one:Column index="0" width="39.14614105224609"/><one:Column index="1" width="45.14567184448242"/></one:Columns><one:Row objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{41}{B0}" lastModifiedTime="2026-09-20T01:26:12.000Z"><one:Cell lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{40}{B0}" lastModifiedByInitials="S"><one:OEChildren><one:OE creationTime="2026-09-05T05:07:00.000Z" lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{39}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Left cell]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{37}{B0}" lastModifiedByInitials="S"><one:OEChildren><one:OE creationTime="2026-09-05T05:07:00.000Z" lastModifiedTime="2026-09-20T01:26:12.000Z" objectID="{6899E045-AC46-0B2E-1C63-C98811ADBC94}{36}{B0}" alignment="left" quickStyleIndex="0"><one:T><![CDATA[Right cell]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Outline></one:Page>
corpus/protected-edit/candidate/read/payloads.json created+10
......@@ -0,0 +1,10 @@
1[
2 {
3 "sha256": "af1af4b8987109ce1e335e538910fb3860b7c57eb3b6110a06aa2114e06d69a7",
4 "name": "fictitious-attachment.txt",
5 "object": "{6899E045-AC46-0B2E-1C63-C98811ADBC94}{32}{B0}",
6 "kind": "InsertedFile",
7 "page": "{2A0ED059-C349-09FD-0D81-ED36E47D6F2A}{14}{B0}",
8 "bytes": 43
9 }
10]
\ No newline at end of file
corpus/protected-edit/native-after/synthetic.one created
Binary files /dev/null and b/corpus/protected-edit/native-after/synthetic.one differ
crates/notebook/README.md+5-2
......@@ -347,8 +347,11 @@ when the page itself was moved to another section. Other URLs and unknown
347347targets are `None`.
348348
349349With the optional `protected` feature, `Notebook::unlock(path, password)`
350reads the pages of a `Locked` section for display; nothing is cached or
351written, and a wrong password is `Error::Protected(PasswordMismatch)`.
350reads the pages of a `Locked` section, and `Notebook::save_unlocked(path,
351password, space, page, author)` saves an edited one under the section's key,
352straight to the file: nothing of a protected section is cached or queued, a
353section changed since the read fails the save, and a wrong password is
354`Error::Protected(PasswordMismatch)`.
352355
353356`Section::import_page(page, author)` copies a page, usually read from another
354357section, to the end of this one as a page creation and a save queued like the
crates/notebook/src/session.rs+18
......@@ -643,6 +643,24 @@ impl Notebook {
643643 .collect()
644644 }
645645
646 /// Saves a page read through `unlock`, stored under the section's key. The save goes
647 /// straight to the file and fails if the section changed since `unlock` read it;
648 /// nothing of a protected section is cached or queued.
649 #[cfg(feature = "protected")]
650 pub fn save_unlocked(
651 &self,
652 path: &str,
653 password: &str,
654 space: ExGuid,
655 page: &Page,
656 author: &str,
657 ) -> Result<()> {
658 let path = self.section_path(path)?.path.clone();
659 let bytes = self.storage.read(&path)?;
660 let edit = onestore::PreparedEdit::page_protected(&bytes, password, space, page, author)?;
661 self.storage.commit(&path, &edit)
662 }
663
646664 /// Opens a section of a mounted notebook by its catalog path.
647665 pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> {
648666 let path = self.section_path(path)?.path.clone();
crates/notebook/tests/protected.rs+87
......@@ -27,3 +27,90 @@ fn a_locked_section_unlocks_for_reading() {
2727 ))
2828 ));
2929}
30
31/// An unlocked page is edited and saved under the section's key, then reads back with the
32/// same password. `ONESTORE_PROTECTED_EXPORT` names a directory receiving the notebook for
33/// a cold reopen in OneNote.
34#[test]
35fn an_unlocked_page_is_saved_under_the_section_key() {
36 use onestore::page::{PageObject, Paragraph, text::new_id};
37 let root = Path::new(concat!(
38 env!("CARGO_MANIFEST_DIR"),
39 "/../../corpus/native-encrypted"
40 ));
41 let manifest: serde_json::Value =
42 serde_json::from_slice(&std::fs::read(root.join("manifest.json")).unwrap()).unwrap();
43 let password = manifest["password"].as_str().unwrap();
44 let temporary = tempfile::tempdir().unwrap();
45 let copy = temporary.path().join("notebook");
46 std::fs::create_dir(&copy).unwrap();
47 for entry in std::fs::read_dir(root.join("encrypted-01/notebook")).unwrap() {
48 let entry = entry.unwrap();
49 std::fs::copy(entry.path(), copy.join(entry.file_name())).unwrap();
50 }
51 let notebook = Notebook::open(&copy, temporary.path().join("cache")).unwrap();
52 let section = notebook
53 .catalog()
54 .sections
55 .iter()
56 .find(|section| matches!(section.state, SectionState::Locked))
57 .unwrap()
58 .path
59 .clone();
60 let (space, mut page) = notebook.unlock(&section, password).unwrap().remove(0);
61 let paragraphs = page
62 .objects
63 .iter_mut()
64 .find_map(|object| match object {
65 PageObject::Outline(outline) if !outline.title => Some(&mut outline.paragraphs),
66 _ => None,
67 })
68 .unwrap();
69 let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap();
70 let mut added = paragraphs[at].clone();
71 added.id = new_id().unwrap();
72 added.style = None;
73 added.lists.clear();
74 added.tags.clear();
75 let text = added.text_mut().unwrap();
76 text.id = new_id().unwrap();
77 let format = text.text.format_at(0).unwrap().clone();
78 text.text = Paragraph::new(
79 "Written while protected 🦀".to_owned(),
80 onestore::document::Format {
81 font: Some("Calibri".into()),
82 font_size: Some(11.0),
83 language: Some(1033),
84 ..Default::default()
85 },
86 );
87 paragraphs.insert(at + 1, added);
88 paragraphs[at]
89 .text_mut()
90 .unwrap()
91 .text
92 .append(Paragraph::new(
93 " and edited under its key".to_owned(),
94 format,
95 ))
96 .unwrap();
97 assert!(matches!(
98 notebook.save_unlocked(&section, "wrong", space, &page, "Rust"),
99 Err(notebook::Error::Protected(
100 onestore::protected::Error::PasswordMismatch
101 ))
102 ));
103 notebook
104 .save_unlocked(&section, password, space, &page, "Rust")
105 .unwrap();
106 let (_, stored) = notebook.unlock(&section, password).unwrap().remove(0);
107 assert!(stored.objects == page.objects);
108 if let Some(export) = std::env::var_os("ONESTORE_PROTECTED_EXPORT") {
109 let export = Path::new(&export);
110 std::fs::create_dir_all(export).unwrap();
111 for entry in std::fs::read_dir(&copy).unwrap() {
112 let entry = entry.unwrap();
113 std::fs::copy(entry.path(), export.join(entry.file_name())).unwrap();
114 }
115 }
116}
crates/onestore/README.md+5-3
......@@ -82,7 +82,9 @@ sections retain their encrypted structure and payloads. With the optional
8282`protected` feature, `protected::UnlockedSection` opens native OneNote 2010
8383AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect
8484passwords, unsupported protection profiles and work-limit failures remain distinct.
85The source stays encrypted; protected writes are rejected.
85The source stays encrypted. `PreparedEdit::page_protected` publishes a page-model
86edit stored under the section's key (fresh IV per object, payloads under the file
87IV); the other writers reject protected sections.
8688`PageCreation::new` appends, or inserts before the first page space of an existing
8789series. `Some("")` creates an empty title field; `None` omits the title node.
8890The page has no body outlines, generated date/time text or applied template.
......@@ -337,8 +339,8 @@ drop(unlocked);
337339# }
338340```
339341
340This example requires `features = ["protected"]`. The owner retains no password or
341key after opening. Its source-buffer views cannot outlive it; copies of parsed
342This example requires `features = ["protected"]`. The owner retains no password;
343its derived key is cleared on drop. Its source-buffer views cannot outlive it; copies of parsed
342344strings, serialized models and exports have their own lifetimes. These copies are
343345plaintext, and dropping the unlock owner does not clear them. CBC has no general
344346ciphertext-authentication guarantee; native read-only hashes and model validation
crates/onestore/src/commit.rs+16
......@@ -261,6 +261,22 @@ impl<'a> PreparedEdit<'a> {
261261 })
262262 }
263263
264 /// `page` for a password-protected section: the revision is stored under the section's
265 /// key. The model must come from this snapshot unlocked with `password`.
266 #[cfg(feature = "protected")]
267 pub fn page_protected(
268 source: &'a [u8],
269 password: &str,
270 space: ExGuid,
271 page: &crate::page::Page,
272 author: &str,
273 ) -> Result<Self, crate::protected::Error> {
274 Ok(Self {
275 source,
276 written: crate::protected::write_page(source, password, space, page, author)?,
277 })
278 }
279
264280 /// Creates a page and its section entry in one transaction, retaining the intent's identities.
265281 pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result<Self, crate::Error> {
266282 Ok(Self {
crates/onestore/src/page/write.rs+19-6
......@@ -361,7 +361,7 @@ pub(crate) fn write_page(
361361 if lowering.image == source {
362362 return Ok(lowering.image);
363363 }
364 squash(source, &lowering.image, &lowering.alias)
364 squash(source, &lowering.image, &lowering.alias, None)
365365}
366366
367367/// 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<Vec<Text
29092909}
29102910
29112911/// Rewrites every revision the typed writers appended as one transaction on `source`,
2912/// renaming writer-allocated identities to the model's.
2913fn squash(
2912/// renaming writer-allocated identities to the model's. A protected `source` takes the
2913/// revisions its plaintext twin gained.
2914pub(crate) fn squash(
29142915 source: &[u8],
29152916 applied: &[u8],
29162917 alias: &BTreeMap<ExGuid, ExGuid>,
2918 protection: Option<&dyn crate::write::Protection>,
29172919) -> Result<Vec<u8>, Error> {
29182920 let rename: BTreeMap<ExGuid, ExGuid> = alias
29192921 .iter()
......@@ -2939,17 +2941,24 @@ fn squash(
29392941 payloads.push((guid, applied_store.file_data(guid)?));
29402942 }
29412943 }
2942 crate::write::write_revisions_with_payloads(source, &payloads, |index| {
2944 let edit = |index: &RevisionIndex<'_>| {
29432945 let mut changes = BTreeMap::new();
29442946 for sid in applied_index.spaces.keys() {
29452947 let Some(space) = index.spaces.get(sid) else {
2948 // The twin's scaffold spaces are not the section's.
2949 if protection.is_some() {
2950 continue;
2951 }
29462952 return Err(invalid("Page edits cannot create object spaces"));
29472953 };
29482954 let after_rid = applied_index.active(*sid)?;
29492955 if space.labels.get(&(ExGuid::default(), 1)) == Some(&after_rid) {
29502956 continue;
29512957 }
2952 let before = index.resolve_active(*sid)?;
2958 let before = match protection {
2959 Some(protection) => protection.resolve(*sid, index.active(*sid)?)?,
2960 None => index.resolve_active(*sid)?,
2961 };
29532962 let after = applied_index.resolve(*sid, after_rid)?;
29542963 if before.roots != after.roots {
29552964 return Err(invalid("Page edits cannot change revision roots"));
......@@ -3008,7 +3017,11 @@ fn squash(
30083017 changes.insert(*sid, RevisionEdit::Update(changed));
30093018 }
30103019 Ok(changes)
3011 })
3020 };
3021 match protection {
3022 Some(_) => crate::write::append_revisions(source, &payloads, protection, edit),
3023 None => crate::write::write_revisions_with_payloads(source, &payloads, edit),
3024 }
30123025}
30133026
30143027fn remap(object: &mut PropertyObject, rename: &BTreeMap<ExGuid, ExGuid>) -> Result<(), Error> {
crates/onestore/src/protected/crypto.rs+55-2
......@@ -1,5 +1,5 @@
11use super::{Error, Result, invalid};
2use aes::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding};
2use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::NoPadding};
33use base64::{Engine, engine::general_purpose::STANDARD};
44use sha1::{Digest, Sha1};
55use subtle::ConstantTimeEq;
......@@ -81,6 +81,13 @@ fn decrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) -> Result<()> {
8181 Ok(())
8282}
8383
84fn encrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) {
85 let length = bytes.len();
86 cbc::Encryptor::<aes::Aes128>::new(key.into(), iv.into())
87 .encrypt_padded::<NoPadding>(bytes, length)
88 .expect("block aligned");
89}
90
8491impl Key {
8592 pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result<Self> {
8693 if data.len() > 65536 || password.len() > 65536 {
......@@ -212,6 +219,42 @@ impl Key {
212219 Ok(output)
213220 }
214221
222 /// The stored form of a plaintext property object, the inverse of `property`.
223 pub(super) fn seal_property(&self, clear: &[u8], iv: [u8; 16]) -> Result<Vec<u8>> {
224 let mut c = crate::bytes::Cursor {
225 bytes: clear,
226 offset: 0,
227 };
228 crate::properties::reference_streams(&mut c)?;
229 let prefix = c.offset;
230 let padding = (16 - (2 + clear.len() - prefix) % 16) % 16;
231 let mut body = Zeroizing::new((padding as u16).to_le_bytes().to_vec());
232 body.extend_from_slice(&clear[prefix..]);
233 let length = body.len() + padding;
234 body.resize(length, 0);
235 getrandom::fill(&mut body[length - padding..])
236 .map_err(|_| invalid("System random source failed"))?;
237 encrypt(&self.value, &iv, &mut body);
238 let mut output = clear[..prefix].to_vec();
239 output.extend_from_slice(&((16 + body.len()) as u32).to_le_bytes());
240 output.extend_from_slice(&iv);
241 output.extend_from_slice(&body);
242 output.resize(output.len().next_multiple_of(8), 0);
243 Ok(output)
244 }
245
246 /// The stored form of a file payload, the inverse of `file`.
247 pub(super) fn seal_file(&self, clear: &[u8]) -> Vec<u8> {
248 if clear.is_empty() {
249 return Vec::new();
250 }
251 let mut output = (clear.len() as u64).to_le_bytes().to_vec();
252 output.extend_from_slice(clear);
253 output.resize(output.len().next_multiple_of(16), 0);
254 encrypt(&self.value, &self.file_iv, &mut output);
255 output
256 }
257
215258 pub(super) fn file(&self, input: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
216259 if input.is_empty() {
217260 return Ok(Zeroizing::new(Vec::new()));
......@@ -312,7 +355,17 @@ mod tests {
312355 crate::properties::reference_streams(&mut cursor).unwrap();
313356 let length = u32::from_le_bytes(cursor.read().unwrap()) as usize;
314357 let end = cursor.offset + length;
315 assert!(key.property(bytes).is_ok());
358 let iv = bytes[cursor.offset..cursor.offset + 16].try_into().unwrap();
359 // Native padding is arbitrary; it only reaches the last block.
360 let sealed = key
361 .seal_property(&key.property(bytes).unwrap(), iv)
362 .unwrap();
363 assert_eq!(sealed.len(), bytes.len());
364 assert_eq!(sealed[..end - 16], bytes[..end - 16]);
365 assert_eq!(
366 *key.property(&sealed).unwrap(),
367 *key.property(bytes).unwrap()
368 );
316369 assert_eq!(
317370 *key.property(bytes).unwrap(),
318371 *spaced.property(bytes).unwrap()
crates/onestore/src/protected/mod.rs+176-9
......@@ -75,9 +75,14 @@ impl Default for Limits {
7575 }
7676}
7777
78struct Decoded<'a> {
79 stored: &'a [u8],
80 clear: Zeroizing<Vec<u8>>,
81}
82
7883/// Owns decoded buffers until dropped; returned views cannot outlive this owner.
7984///
80/// Passwords and derived keys are not retained. Dropping this owner clears its
85/// Passwords are not retained. Dropping this owner clears its derived key and
8186/// decoded buffers. Owned strings or exports made from a `Document` are separate
8287/// caller-owned copies and must be disposed of by the caller when locking.
8388/// Opening never rewrites the source image or changes its protection state.
......@@ -93,8 +98,10 @@ impl Default for Limits {
9398/// ```
9499pub struct UnlockedSection<'a> {
95100 index: &'a RevisionIndex<'a>,
96 objects: BTreeMap<(ExGuid, usize), Zeroizing<Vec<u8>>>,
101 /// By object space and stored address.
102 objects: BTreeMap<(ExGuid, usize), Decoded<'a>>,
97103 files: BTreeMap<[u8; 16], Zeroizing<Vec<u8>>>,
104 keys: BTreeMap<&'a [u8], crypto::Key>,
98105}
99106
100107impl<'a> UnlockedSection<'a> {
......@@ -111,6 +118,7 @@ impl<'a> UnlockedSection<'a> {
111118 index,
112119 objects: BTreeMap::new(),
113120 files: BTreeMap::new(),
121 keys: BTreeMap::new(),
114122 };
115123 let mut keys = BTreeMap::new();
116124 let mut file_keys = BTreeMap::new();
......@@ -141,10 +149,9 @@ impl<'a> UnlockedSection<'a> {
141149 if !revision.encrypted {
142150 return Err(Error::Unsupported);
143151 }
144 let node = revision
145 .nodes
146 .first()
147 .ok_or_else(|| invalid("Missing encryption key node"))?;
152 let Some(node) = revision.nodes.first().filter(|node| node.id == 0x7c) else {
153 continue;
154 };
148155 let Some(Reference::Data(chunk)) = node.reference else {
149156 return Err(invalid("Missing encryption key reference"));
150157 };
......@@ -194,7 +201,13 @@ impl<'a> UnlockedSection<'a> {
194201 ));
195202 }
196203 }
197 result.objects.insert(identity, decoded);
204 result.objects.insert(
205 identity,
206 Decoded {
207 stored: bytes,
208 clear: decoded,
209 },
210 );
198211 }
199212 ObjectData::File { .. } => {
200213 if let Some(FileDataReference::Internal(guid)) =
......@@ -226,7 +239,7 @@ impl<'a> UnlockedSection<'a> {
226239 }
227240 }
228241 }
229 drop(keys);
242 result.keys = keys;
230243 for (space, info) in &index.spaces {
231244 for rid in info.labels.values().copied().collect::<BTreeSet<_>>() {
232245 result.resolve(*space, rid)?.reachable()?;
......@@ -251,7 +264,7 @@ impl<'a> UnlockedSection<'a> {
251264 offset: 0,
252265 message: "Protected object was not decoded",
253266 })?;
254 object.data = ObjectData::Properties(decoded);
267 object.data = ObjectData::Properties(&decoded.clear);
255268 }
256269 }
257270 Ok(revision)
......@@ -273,3 +286,157 @@ impl<'a> UnlockedSection<'a> {
273286 )
274287 }
275288}
289
290impl UnlockedSection<'_> {
291 /// A plaintext section holding every object space's current revision and payloads
292 /// under their own identities, for writers that read ordinary sections.
293 fn twin(&self) -> std::result::Result<Zeroizing<Vec<u8>>, crate::Error> {
294 use crate::write::{PropertyObject, RevisionEdit, append_revisions};
295 let copy = |space: ExGuid, revision: ExGuid| {
296 let revision = self.resolve(space, revision)?;
297 let mut objects = BTreeMap::new();
298 for (id, object) in &revision.objects {
299 let copy = match object.data {
300 ObjectData::File {
301 reference,
302 extension,
303 } => {
304 let text = |bytes: &[u8]| {
305 String::from_utf16_lossy(
306 &bytes
307 .chunks_exact(2)
308 .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
309 .collect::<Vec<_>>(),
310 )
311 };
312 let mut copy =
313 PropertyObject::file(*id, &text(reference), &text(extension))?;
314 copy.jcid = object.jcid;
315 copy.global_ids = std::sync::Arc::clone(&object.global_ids);
316 copy
317 }
318 _ => PropertyObject::from_object(object)?,
319 };
320 objects.insert(*id, copy);
321 }
322 Ok::<_, crate::Error>((revision.roots, objects))
323 };
324 let payloads: Vec<_> = self
325 .files
326 .iter()
327 .map(|(id, bytes)| (*id, bytes.as_slice()))
328 .collect();
329 let current = (ExGuid::default(), 1);
330 // The scaffold's root space takes the section's identity, then its content.
331 let mut scaffold = crate::create_section("twin.one", "", "")?;
332 let identity = |id: ExGuid| {
333 let mut bytes = Vec::new();
334 id.encode(&mut bytes);
335 bytes
336 };
337 let store = crate::Store::parse(&scaffold)?;
338 let placeholder = identity(RevisionIndex::parse(&store)?.root);
339 for at in 0..scaffold.len() - 20 {
340 if scaffold[at..at + 20] == placeholder {
341 scaffold[at..at + 20].copy_from_slice(&identity(self.index.root));
342 }
343 }
344 let mut twin = Zeroizing::new(append_revisions(&scaffold, &payloads, None, |_| {
345 let mut spaces = BTreeMap::new();
346 for id in self.index.spaces.keys() {
347 let (roots, objects) = copy(*id, self.index.active(*id)?)?;
348 let edit = if *id == self.index.root {
349 RevisionEdit::Label {
350 context: current.0,
351 role: current.1,
352 roots,
353 objects,
354 }
355 } else {
356 RevisionEdit::Create { roots, objects }
357 };
358 spaces.insert(*id, edit);
359 }
360 Ok(spaces)
361 })?);
362 // One revision per call and space: the remaining labels follow in rounds.
363 for round in 0.. {
364 let mut spaces = BTreeMap::new();
365 for (id, space) in &self.index.spaces {
366 let label = space.labels.iter().filter(|(label, _)| **label != current);
367 if let Some(((context, role), revision)) = label.clone().nth(round) {
368 let (roots, objects) = copy(*id, *revision)?;
369 spaces.insert(
370 *id,
371 RevisionEdit::Label {
372 context: *context,
373 role: *role,
374 roots,
375 objects,
376 },
377 );
378 }
379 }
380 if spaces.is_empty() {
381 break;
382 }
383 twin = Zeroizing::new(append_revisions(&twin, &[], None, |_| Ok(spaces))?);
384 }
385 Ok(twin)
386 }
387}
388
389/// An edited page of a protected section as one stored revision per changed space, the
390/// protected counterpart of [`crate::PreparedEdit::page`].
391pub(crate) fn write_page(
392 source: &[u8],
393 password: &str,
394 space: ExGuid,
395 page: &crate::page::Page,
396 author: &str,
397) -> Result<Vec<u8>> {
398 let store = crate::Store::parse(source)?;
399 let index = RevisionIndex::parse(&store)?;
400 let unlocked = UnlockedSection::open(&index, password, Limits::default())?;
401 let twin = unlocked.twin()?;
402 let applied = Zeroizing::new(crate::page::write::write_page(&twin, space, page, author)?);
403 let written = crate::page::write::squash(source, &applied, &BTreeMap::new(), Some(&unlocked))?;
404 let store = crate::Store::parse(&written)?;
405 UnlockedSection::open(&RevisionIndex::parse(&store)?, password, Limits::default())?;
406 Ok(written)
407}
408
409impl crate::write::Protection for UnlockedSection<'_> {
410 fn resolve(
411 &self,
412 space: ExGuid,
413 revision: ExGuid,
414 ) -> std::result::Result<crate::ResolvedRevision<'_>, crate::Error> {
415 self.resolve(space, revision)
416 }
417
418 fn stored(&self, clear: &[u8]) -> Option<&[u8]> {
419 self.objects
420 .values()
421 .find(|decoded| std::ptr::eq(decoded.clear.as_ptr(), clear.as_ptr()))
422 .map(|decoded| decoded.stored)
423 }
424
425 fn seal_property(&self, clear: &[u8]) -> std::result::Result<Vec<u8>, crate::Error> {
426 let [key] = self.keys.values().collect::<Vec<_>>()[..] else {
427 return Err(crate::Error {
428 offset: 0,
429 message: "Protected writing needs one section key",
430 });
431 };
432 let failed = |message| crate::Error { offset: 0, message };
433 let mut iv = [0; 16];
434 getrandom::fill(&mut iv).map_err(|_| failed("System random source failed"))?;
435 key.seal_property(clear, iv)
436 .map_err(|_| failed("Malformed property object"))
437 }
438
439 fn seal_file(&self, clear: &[u8]) -> Vec<u8> {
440 self.keys.values().next().unwrap().seal_file(clear)
441 }
442}
crates/onestore/src/revisions.rs+6-3
......@@ -286,8 +286,11 @@ impl<'a> RevisionIndex<'a> {
286286 });
287287 }
288288 }
289 if (encoding == 2)
290 != body.first().is_some_and(|node| node.id == 0x7c)
289 // A protected revision names its key unless it inherits the
290 // key of the revision it depends on.
291 let keyed = body.first().is_some_and(|node| node.id == 0x7c);
292 if keyed && encoding != 2
293 || !keyed && encoding == 2 && dependency.is_none()
291294 {
292295 return Err(Error {
293296 offset: node.offset,
......@@ -304,7 +307,7 @@ impl<'a> RevisionIndex<'a> {
304307 message: "Object space mixes encrypted and unencrypted revisions",
305308 });
306309 }
307 if encoding == 2 {
310 if keyed {
308311 let key = &body[0];
309312 let Some(Reference::Data(chunk)) = key.reference else {
310313 return Err(Error {
crates/onestore/src/write.rs+125-15
......@@ -374,6 +374,14 @@ pub(crate) enum RevisionEdit {
374374 roots: BTreeMap<u32, ExGuid>,
375375 objects: BTreeMap<ExGuid, PropertyObject>,
376376 },
377 /// A complete revision of an existing space under a context and role, without history.
378 #[cfg(feature = "protected")]
379 Label {
380 context: ExGuid,
381 role: u32,
382 roots: BTreeMap<u32, ExGuid>,
383 objects: BTreeMap<ExGuid, PropertyObject>,
384 },
377385}
378386
379387impl PropertyObject {
......@@ -698,6 +706,16 @@ impl PropertyObject {
698706 }
699707}
700708
709/// A password-protected section's unlocked view, through which its revisions are
710/// read as plaintext and written back in their stored form.
711pub(crate) trait Protection {
712 fn resolve(&self, space: ExGuid, revision: ExGuid) -> Result<crate::ResolvedRevision<'_>>;
713 /// The stored bytes a resolved object's plaintext was decoded from.
714 fn stored(&self, clear: &[u8]) -> Option<&[u8]>;
715 fn seal_property(&self, clear: &[u8]) -> Result<Vec<u8>>;
716 fn seal_file(&self, clear: &[u8]) -> Vec<u8>;
717}
718
701719pub(crate) fn write_revision(
702720 source: &[u8],
703721 space: ExGuid,
......@@ -769,6 +787,22 @@ pub(crate) fn write_revisions_with_payloads(
769787 source: &[u8],
770788 payloads: &[([u8; 16], &[u8])],
771789 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
790) -> Result<Vec<u8>> {
791 let store = Store::parse(source)?;
792 RevisionIndex::parse(&store)?.validate_current()?;
793 let output = append_revisions(source, payloads, None, edit)?;
794 let store = Store::parse(&output)?;
795 RevisionIndex::parse(&store)?.validate_current()?;
796 Ok(output)
797}
798
799/// `write_revisions_with_payloads` without validating that current revisions are
800/// complete: a protected section is validated by unlocking it, through `protection`.
801pub(crate) fn append_revisions(
802 source: &[u8],
803 payloads: &[([u8; 16], &[u8])],
804 protection: Option<&dyn Protection>,
805 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,
772806) -> Result<Vec<u8>> {
773807 let store = Store::parse(source)?;
774808 let is_section = store.header.file_type == FileType::Section;
......@@ -779,7 +813,10 @@ pub(crate) fn write_revisions_with_payloads(
779813 });
780814 }
781815 let index = RevisionIndex::parse(&store)?;
782 index.validate_current()?;
816 let resolve = |space, rid| match protection {
817 Some(protection) => protection.resolve(space, rid),
818 None => index.resolve(space, rid),
819 };
783820 let changes = edit(&index)?;
784821 let mut output = source.to_vec();
785822 // 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(
810847 let mut counts = Vec::new();
811848 let mut root_nodes = Vec::new();
812849 for (space, change) in changes {
813 let (rid, mut revision, mut replacements) = match change {
850 let new_space = matches!(change, RevisionEdit::Create { .. });
851 let current = (ExGuid::default(), 1_u32);
852 let (rid, label, mut revision, mut replacements) = match change {
814853 RevisionEdit::Update(objects) => {
815854 let rid = index.active(space)?;
816 (Some(rid), index.resolve(space, rid)?, objects)
855 (Some(rid), current, resolve(space, rid)?, objects)
817856 }
818857 RevisionEdit::Create { roots, objects } => {
819858 if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) {
......@@ -830,6 +869,30 @@ pub(crate) fn write_revisions_with_payloads(
830869 }
831870 (
832871 None,
872 current,
873 crate::ResolvedRevision {
874 roots,
875 objects: BTreeMap::new(),
876 },
877 objects,
878 )
879 }
880 #[cfg(feature = "protected")]
881 RevisionEdit::Label {
882 context,
883 role,
884 roots,
885 objects,
886 } => {
887 if !is_section || role > 0xffff || !index.spaces.contains_key(&space) {
888 return Err(Error {
889 offset: 0,
890 message: "Choose a label in a section's object space",
891 });
892 }
893 (
894 None,
895 (context, role),
833896 crate::ResolvedRevision {
834897 roots,
835898 objects: BTreeMap::new(),
......@@ -1062,16 +1125,45 @@ pub(crate) fn write_revisions_with_payloads(
10621125 if !is_section {
10631126 start.extend_from_slice(&0_u64.to_le_bytes());
10641127 }
1065 start.extend_from_slice(&1_u32.to_le_bytes());
1066 start.extend_from_slice(&0_u16.to_le_bytes());
1128 start.extend_from_slice(&label.1.to_le_bytes());
1129 start.extend_from_slice(&(if protection.is_some() { 2_u16 } else { 0 }).to_le_bytes());
1130 let contextual = label.0 != ExGuid::default();
1131 if contextual {
1132 label.0.encode(&mut start);
1133 }
10671134 let mut manifest = Vec::new();
1068 if rid.is_none() {
1135 if new_space {
10691136 let mut payload = Vec::new();
10701137 space.encode(&mut payload);
10711138 payload.extend_from_slice(&0_u32.to_le_bytes());
10721139 manifest.push(node(0x14, None, &payload)?);
10731140 }
1074 manifest.push(node(if is_section { 0x1e } else { 0x1b }, None, &start)?);
1141 manifest.push(node(
1142 match (is_section, contextual) {
1143 (true, true) => 0x1f,
1144 (true, false) => 0x1e,
1145 (false, _) => 0x1b,
1146 },
1147 None,
1148 &start,
1149 )?);
1150 // A dependent revision inherits its key, as OneNote writes it.
1151 if protection.is_some() && checkpoint {
1152 let key = index
1153 .spaces
1154 .get(&space)
1155 .and_then(|space| {
1156 space
1157 .revisions
1158 .values()
1159 .find_map(|revision| revision.nodes.first().filter(|node| node.id == 0x7c))
1160 })
1161 .ok_or(Error {
1162 offset: 0,
1163 message: "Protected revisions continue a protected object space",
1164 })?;
1165 manifest.push(node(0x7c, key.reference, key.payload)?);
1166 }
10751167 for (table, objects) in groups {
10761168 let mut payload = Vec::new();
10771169 let mut group = if is_section {
......@@ -1139,14 +1231,26 @@ pub(crate) fn write_revisions_with_payloads(
11391231 }
11401232 append(&mut output, &mapped)?
11411233 } else if replacements.contains_key(&id) {
1142 append(&mut output, bytes)?
1234 match protection {
1235 Some(protection) => {
1236 append(&mut output, &protection.seal_property(bytes)?)?
1237 }
1238 None => append(&mut output, bytes)?,
1239 }
11431240 } else {
1241 let stored = match protection {
1242 Some(protection) => protection.stored(bytes).ok_or(Error {
1243 offset: 0,
1244 message: "Protected object has no stored form",
1245 })?,
1246 None => bytes,
1247 };
11441248 Chunk {
11451249 offset: u64::try_from(
1146 bytes.as_ptr().addr() - source.as_ptr().addr(),
1250 stored.as_ptr().addr() - source.as_ptr().addr(),
11471251 )
11481252 .unwrap(),
1149 length: u64::try_from(bytes.len()).unwrap(),
1253 length: u64::try_from(stored.len()).unwrap(),
11501254 }
11511255 };
11521256 // A table-of-contents object is declared when the revision is a
......@@ -1164,7 +1268,13 @@ pub(crate) fn write_revisions_with_payloads(
11641268 declaration.extend_from_slice(&object.reference_count.to_le_bytes());
11651269 let readonly = object.jcid & 0x100000 != 0;
11661270 if readonly {
1167 declaration.extend_from_slice(&md5::compute(bytes).0);
1271 // A protected declaration hashes the plaintext as aligned.
1272 let mut hash = md5::Context::new();
1273 hash.consume(bytes);
1274 if protection.is_some() {
1275 hash.consume(&[0; 7][..(8 - bytes.len() % 8) % 8]);
1276 }
1277 declaration.extend_from_slice(&hash.finalize().0);
11681278 }
11691279 group.push(node(
11701280 if is_section {
......@@ -1219,7 +1329,7 @@ pub(crate) fn write_revisions_with_payloads(
12191329 }
12201330 }
12211331 manifest.push(node(0x1c, None, &[])?);
1222 if rid.is_none() {
1332 if new_space {
12231333 let list_id = allocate_list()?;
12241334 let chunk = append_list(&mut output, list_id, &manifest)?;
12251335 counts.push((list_id, manifest.len()));
......@@ -1269,6 +1379,8 @@ pub(crate) fn write_revisions_with_payloads(
12691379 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a,
12701380 0x9e, 0xac,
12711381 ];
1382 let sealed = protection.map(|protection| protection.seal_file(payload));
1383 let payload = sealed.as_deref().unwrap_or(*payload);
12721384 blob.extend_from_slice(&(payload.len() as u64).to_le_bytes());
12731385 blob.extend_from_slice(&[0; 12]);
12741386 blob.extend_from_slice(payload);
......@@ -1410,8 +1522,6 @@ pub(crate) fn write_revisions_with_payloads(
14101522 message: "File generation counter is exhausted",
14111523 })?;
14121524 output[228..236].copy_from_slice(&generation.to_le_bytes());
1413 let written = Store::parse(&output)?;
1414 let written_index = RevisionIndex::parse(&written)?;
1415 written_index.validate_current()?;
1525 RevisionIndex::parse(&Store::parse(&output)?)?;
14161526 Ok(output)
14171527}
crates/onestore/tests/protected.rs+129
......@@ -108,3 +108,132 @@ fn limits_and_password_bytes_are_explicit() {
108108 Err(Error::Unsupported)
109109 ));
110110}
111
112/// The first text paragraph of every page gains text; the written file stays protected,
113/// opens with the same password and reads back as the edited model.
114#[test]
115fn a_protected_page_edit_is_stored_under_the_section_key() {
116 use onestore::page::{Page, PageObject, Paragraph};
117 for (root, notebook) in [
118 ("native-encrypted", "encrypted-01/notebook/synthetic.one"),
119 ("native-protected-boundaries", "notebook/synthetic.one"),
120 ] {
121 let root = Path::new("../../corpus").join(root);
122 let manifest: serde_json::Value =
123 serde_json::from_slice(&fs::read(root.join("manifest.json")).unwrap()).unwrap();
124 let password = manifest["password"].as_str().unwrap();
125 let mut bytes = fs::read(root.join(notebook)).unwrap();
126 let pages = |bytes: &[u8]| -> Vec<(onestore::ExGuid, Page)> {
127 let store = Store::parse(bytes).unwrap();
128 let index = RevisionIndex::parse(&store).unwrap();
129 assert!(Document::parse(&index).unwrap().pages().unwrap().is_empty());
130 let unlocked = UnlockedSection::open(&index, password, Limits::default()).unwrap();
131 let document = unlocked.document().unwrap();
132 document
133 .pages()
134 .unwrap()
135 .into_iter()
136 .map(|(space, _)| (space, Page::from_space(&document, space).unwrap()))
137 .collect()
138 };
139 let mut expected = pages(&bytes);
140 let mut edited = 0;
141 for (space, page) in &mut expected {
142 let paragraphs = page.objects.iter_mut().find_map(|object| match object {
143 PageObject::Outline(outline)
144 if outline.paragraphs.iter().any(|p| p.text().is_some()) =>
145 {
146 Some(&mut outline.paragraphs)
147 }
148 _ => None,
149 });
150 let Some(paragraphs) = paragraphs else {
151 continue;
152 };
153 edited += 1;
154 let at = paragraphs.iter().position(|p| p.text().is_some()).unwrap();
155 let mut file = paragraphs[at].clone();
156 file.id = onestore::page::text::new_id().unwrap();
157 file.lists.clear();
158 file.tags.clear();
159 file.style = None;
160 file.format = Default::default();
161 file.content =
162 onestore::page::ParagraphContent::Attachment(onestore::page::Attachment {
163 id: onestore::page::text::new_id().unwrap(),
164 filename: "sealed.txt".into(),
165 source_path: None,
166 size: Some([24.0, 24.0]),
167 bytes: Some(std::sync::Arc::from(&b"A payload that stays sealed"[..])),
168 preview: None,
169 recording: None,
170 });
171 paragraphs.insert(at + 1, file);
172 let text = paragraphs[at].text_mut().unwrap();
173 let format = text.text.format_at(0).unwrap().clone();
174 text.text
175 .append(Paragraph::new(" still protected".to_owned(), format))
176 .unwrap();
177 let edit =
178 onestore::PreparedEdit::page_protected(&bytes, password, *space, page, "Rust")
179 .unwrap();
180 assert!(matches!(
181 onestore::PreparedEdit::page_protected(&bytes, "wrong", *space, page, "Rust"),
182 Err(Error::PasswordMismatch)
183 ));
184 let written = edit.as_bytes().to_vec();
185 for clear in [&b" still protected"[..], b"stays sealed"] {
186 assert!(!written.windows(clear.len()).any(|w| w == clear));
187 }
188 let revisions = |bytes: &[u8]| {
189 let store = Store::parse(bytes).unwrap();
190 let index = RevisionIndex::parse(&store).unwrap();
191 index
192 .spaces
193 .iter()
194 .map(|(id, space)| (*id, space.revisions.len()))
195 .collect::<Vec<_>>()
196 };
197 let grown: Vec<_> = revisions(&bytes)
198 .into_iter()
199 .zip(revisions(&written))
200 .filter(|(before, after)| before != after)
201 .map(|(before, _)| before.0)
202 .collect();
203 assert_eq!(grown, [*space]);
204 bytes = written;
205 }
206 assert!(edited > 0);
207 let stored = pages(&bytes);
208 assert_eq!(stored.len(), expected.len());
209 for ((_, stored), (_, expected)) in stored.iter().zip(&expected) {
210 assert_eq!(stored.objects, expected.objects);
211 }
212 }
213}
214
215/// OneNote's revisions that depend on an earlier one carry no key node of their own.
216#[test]
217fn a_native_revision_inherits_the_key_of_its_dependency() {
218 let bytes = fs::read("../../corpus/protected-edit/native-after/synthetic.one").unwrap();
219 let manifest: serde_json::Value =
220 serde_json::from_slice(&fs::read("../../corpus/native-encrypted/manifest.json").unwrap())
221 .unwrap();
222 let store = Store::parse(&bytes).unwrap();
223 let index = RevisionIndex::parse(&store).unwrap();
224 assert!(index.spaces.values().any(|space| {
225 space.revisions.values().any(|revision| {
226 revision.encrypted && revision.nodes.first().is_none_or(|node| node.id != 0x7c)
227 })
228 }));
229 let unlocked = UnlockedSection::open(
230 &index,
231 manifest["password"].as_str().unwrap(),
232 Limits::default(),
233 )
234 .unwrap();
235 let document = unlocked.document().unwrap();
236 let (space, _) = document.pages().unwrap()[0];
237 let page = onestore::page::Page::from_space(&document, space).unwrap();
238 assert!(format!("{:?}", page.objects).contains("Native edit after Rust."));
239}
fuzz/Cargo.toml+7
......@@ -136,3 +136,10 @@ path = "fuzz_targets/page_model.rs"
136136test = false
137137doc = false
138138bench = false
139
140[[bin]]
141name = "protected_write"
142path = "fuzz_targets/protected_write.rs"
143test = false
144doc = false
145bench = false
fuzz/fuzz_targets/protected_write.rs created+69
......@@ -0,0 +1,69 @@
1#![no_main]
2//! Chains page edits on a protected section: every written image unlocks with the same
3//! password, reads back as the edited model and stores none of the new text in clear.
4use libfuzzer_sys::fuzz_target;
5use onestore::{
6 ExGuid, PreparedEdit, RevisionIndex, Store,
7 page::{Page, PageObject, Paragraph, text::Edit},
8 protected::{Limits, UnlockedSection},
9};
10
11const SOURCE: &[u8] =
12 include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one");
13const PASSWORD: &str = "fictitious-only";
14
15fn page(bytes: &[u8]) -> (ExGuid, Page) {
16 let store = Store::parse(bytes).unwrap();
17 let index = RevisionIndex::parse(&store).unwrap();
18 let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap();
19 let document = unlocked.document().unwrap();
20 let (space, _) = document.pages().unwrap()[0];
21 (space, Page::from_space(&document, space).unwrap())
22}
23
24fuzz_target!(|data: &[u8]| {
25 let mut bytes = SOURCE.to_vec();
26 for step in data.chunks(12).take(4) {
27 if step.len() < 4 {
28 return;
29 }
30 let (space, mut after) = page(&bytes);
31 let mut texts: Vec<_> = after
32 .objects
33 .iter_mut()
34 .filter_map(|object| match object {
35 PageObject::Outline(outline) => Some(&mut outline.paragraphs),
36 _ => None,
37 })
38 .flatten()
39 .filter_map(|paragraph| paragraph.text_mut())
40 .collect();
41 let count = texts.len();
42 let text = &mut texts[usize::from(step[0]) % count].text;
43 let end = text.utf16_offset(text.text().len()).unwrap();
44 let start = u32::from(step[1]) % (end + 1);
45 let stop = (start + u32::from(step[2]) % 8).min(end);
46 // Marked so its absence from the stored bytes is checkable.
47 let inserted = format!("\u{1f512}sealed\u{1f512}{}", String::from_utf8_lossy(&step[3..]).replace('\0', ""));
48 let format = text.format_at(start.min(end.saturating_sub(1))).unwrap().clone();
49 let edit = Edit {
50 range: start..stop,
51 replacement: Paragraph::new(inserted.clone(), format),
52 };
53 let (Ok(_), Ok(_)) = (text.byte_offset(start), text.byte_offset(stop)) else {
54 return;
55 };
56 if text.apply(edit).is_err() {
57 return;
58 }
59 let Ok(edit) = PreparedEdit::page_protected(&bytes, PASSWORD, space, &after, "Fuzz") else {
60 return;
61 };
62 let written = edit.as_bytes().to_vec();
63 let clear: Vec<u8> = inserted.encode_utf16().flat_map(u16::to_le_bytes).collect();
64 assert!(!written[bytes.len()..].windows(clear.len()).any(|w| w == clear));
65 let (_, stored) = page(&written);
66 assert!(stored.objects == after.objects);
67 bytes = written;
68 }
69});
tools/test_protected_edit.py created+45
......@@ -0,0 +1,45 @@
1import json
2from pathlib import Path
3import runpy
4import shutil
5import subprocess
6from tempfile import TemporaryDirectory
7import unittest
8
9from document_model import EXPORTER
10
11ROOT = Path(__file__).resolve().parent.parent
12FIXTURE = ROOT / 'corpus/protected-edit'
13compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare']
14
15
16class ProtectedEditTest(unittest.TestCase):
17 def setUp(self):
18 self.temporary = TemporaryDirectory()
19 self.root = Path(self.temporary.name)
20 self.password = self.root / 'password'
21 manifest = json.loads((ROOT / 'corpus/native-encrypted/manifest.json').read_text())
22 self.password.write_text(manifest['password'])
23
24 def tearDown(self):
25 self.temporary.cleanup()
26
27 def test_onenote_unlocks_and_reads_the_page_saved_under_the_section_key(self):
28 native = self.root / 'read'
29 shutil.copytree(FIXTURE / 'candidate/read', native)
30 compare(FIXTURE / 'candidate/notebook', native, password_file=self.password)
31 page = (native / 'page-000.xml').read_text(encoding='utf-8-sig')
32 self.assertIn('and edited under its key', page)
33 self.assertIn('Written while protected', page)
34
35 def test_the_native_edit_that_followed_unlocks_with_both_edits(self):
36 output = self.root / 'export'
37 subprocess.run([EXPORTER, FIXTURE / 'native-after/synthetic.one', output,
38 '--password-file', self.password], check=True, capture_output=True)
39 text = (output / 'text.json').read_text()
40 for expected in ['Native edit after Rust.', 'Written while protected', 'and edited under its key']:
41 self.assertIn(expected, text)
42
43
44if __name__ == '__main__':
45 unittest.main()