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...@@ -347,8 +347,11 @@ when the page itself was moved to another section. Other URLs and unknown
347targets are `None`.347targets are `None`.
348348
349With the optional `protected` feature, `Notebook::unlock(path, password)`349With the optional `protected` feature, `Notebook::unlock(path, password)`
350reads the pages of a `Locked` section for display; nothing is cached or350reads the pages of a `Locked` section, and `Notebook::save_unlocked(path,
351written, and a wrong password is `Error::Protected(PasswordMismatch)`.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
353`Section::import_page(page, author)` copies a page, usually read from another356`Section::import_page(page, author)` copies a page, usually read from another
354section, to the end of this one as a page creation and a save queued like the357section, 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 {...@@ -643,6 +643,24 @@ impl Notebook {
643 .collect()643 .collect()
644 }644 }
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
646 /// Opens a section of a mounted notebook by its catalog path.664 /// Opens a section of a mounted notebook by its catalog path.
647 pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> {665 pub fn section(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section> {
648 let path = self.section_path(path)?.path.clone();666 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() {...@@ -27,3 +27,90 @@ fn a_locked_section_unlocks_for_reading() {
27 ))27 ))
28 ));28 ));
29}29}
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...@@ -82,7 +82,9 @@ sections retain their encrypted structure and payloads. With the optional
82`protected` feature, `protected::UnlockedSection` opens native OneNote 201082`protected` feature, `protected::UnlockedSection` opens native OneNote 2010
83AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect83AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect
84passwords, unsupported protection profiles and work-limit failures remain distinct.84passwords, 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.
86`PageCreation::new` appends, or inserts before the first page space of an existing88`PageCreation::new` appends, or inserts before the first page space of an existing
87series. `Some("")` creates an empty title field; `None` omits the title node.89series. `Some("")` creates an empty title field; `None` omits the title node.
88The page has no body outlines, generated date/time text or applied template.90The page has no body outlines, generated date/time text or applied template.
...@@ -337,8 +339,8 @@ drop(unlocked);...@@ -337,8 +339,8 @@ drop(unlocked);
337# }339# }
338```340```
339341
340This example requires `features = ["protected"]`. The owner retains no password or342This example requires `features = ["protected"]`. The owner retains no password;
341key after opening. Its source-buffer views cannot outlive it; copies of parsed343its derived key is cleared on drop. Its source-buffer views cannot outlive it; copies of parsed
342strings, serialized models and exports have their own lifetimes. These copies are344strings, serialized models and exports have their own lifetimes. These copies are
343plaintext, and dropping the unlock owner does not clear them. CBC has no general345plaintext, and dropping the unlock owner does not clear them. CBC has no general
344ciphertext-authentication guarantee; native read-only hashes and model validation346ciphertext-authentication guarantee; native read-only hashes and model validation
crates/onestore/src/commit.rs+16
...@@ -261,6 +261,22 @@ impl<'a> PreparedEdit<'a> {...@@ -261,6 +261,22 @@ impl<'a> PreparedEdit<'a> {
261 })261 })
262 }262 }
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
264 /// Creates a page and its section entry in one transaction, retaining the intent's identities.280 /// Creates a page and its section entry in one transaction, retaining the intent's identities.
265 pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result<Self, crate::Error> {281 pub fn create_page(source: &'a [u8], page: &crate::PageCreation) -> Result<Self, crate::Error> {
266 Ok(Self {282 Ok(Self {
crates/onestore/src/page/write.rs+19-6
...@@ -361,7 +361,7 @@ pub(crate) fn write_page(...@@ -361,7 +361,7 @@ pub(crate) fn write_page(
361 if lowering.image == source {361 if lowering.image == source {
362 return Ok(lowering.image);362 return Ok(lowering.image);
363 }363 }
364 squash(source, &lowering.image, &lowering.alias)364 squash(source, &lowering.image, &lowering.alias, None)
365}365}
366366
367/// Direct children of every container, in model order, plus lookups by identity.367/// 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...@@ -2909,11 +2909,13 @@ fn attributes(current: &Format, target: &Format, fresh: bool) -> Result<Vec<Text
2909}2909}
29102910
2911/// Rewrites every revision the typed writers appended as one transaction on `source`,2911/// Rewrites every revision the typed writers appended as one transaction on `source`,
2912/// renaming writer-allocated identities to the model's.2912/// renaming writer-allocated identities to the model's. A protected `source` takes the
2913fn squash(2913/// revisions its plaintext twin gained.
2914pub(crate) fn squash(
2914 source: &[u8],2915 source: &[u8],
2915 applied: &[u8],2916 applied: &[u8],
2916 alias: &BTreeMap<ExGuid, ExGuid>,2917 alias: &BTreeMap<ExGuid, ExGuid>,
2918 protection: Option<&dyn crate::write::Protection>,
2917) -> Result<Vec<u8>, Error> {2919) -> Result<Vec<u8>, Error> {
2918 let rename: BTreeMap<ExGuid, ExGuid> = alias2920 let rename: BTreeMap<ExGuid, ExGuid> = alias
2919 .iter()2921 .iter()
...@@ -2939,17 +2941,24 @@ fn squash(...@@ -2939,17 +2941,24 @@ fn squash(
2939 payloads.push((guid, applied_store.file_data(guid)?));2941 payloads.push((guid, applied_store.file_data(guid)?));
2940 }2942 }
2941 }2943 }
2942 crate::write::write_revisions_with_payloads(source, &payloads, |index| {2944 let edit = |index: &RevisionIndex<'_>| {
2943 let mut changes = BTreeMap::new();2945 let mut changes = BTreeMap::new();
2944 for sid in applied_index.spaces.keys() {2946 for sid in applied_index.spaces.keys() {
2945 let Some(space) = index.spaces.get(sid) else {2947 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 }
2946 return Err(invalid("Page edits cannot create object spaces"));2952 return Err(invalid("Page edits cannot create object spaces"));
2947 };2953 };
2948 let after_rid = applied_index.active(*sid)?;2954 let after_rid = applied_index.active(*sid)?;
2949 if space.labels.get(&(ExGuid::default(), 1)) == Some(&after_rid) {2955 if space.labels.get(&(ExGuid::default(), 1)) == Some(&after_rid) {
2950 continue;2956 continue;
2951 }2957 }
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 };
2953 let after = applied_index.resolve(*sid, after_rid)?;2962 let after = applied_index.resolve(*sid, after_rid)?;
2954 if before.roots != after.roots {2963 if before.roots != after.roots {
2955 return Err(invalid("Page edits cannot change revision roots"));2964 return Err(invalid("Page edits cannot change revision roots"));
...@@ -3008,7 +3017,11 @@ fn squash(...@@ -3008,7 +3017,11 @@ fn squash(
3008 changes.insert(*sid, RevisionEdit::Update(changed));3017 changes.insert(*sid, RevisionEdit::Update(changed));
3009 }3018 }
3010 Ok(changes)3019 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 }
3012}3025}
30133026
3014fn remap(object: &mut PropertyObject, rename: &BTreeMap<ExGuid, ExGuid>) -> Result<(), Error> {3027fn remap(object: &mut PropertyObject, rename: &BTreeMap<ExGuid, ExGuid>) -> Result<(), Error> {
crates/onestore/src/protected/crypto.rs+55-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1use super::{Error, Result, invalid};1use super::{Error, Result, invalid};
2use aes::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding};2use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::NoPadding};
3use base64::{Engine, engine::general_purpose::STANDARD};3use base64::{Engine, engine::general_purpose::STANDARD};
4use sha1::{Digest, Sha1};4use sha1::{Digest, Sha1};
5use subtle::ConstantTimeEq;5use subtle::ConstantTimeEq;
...@@ -81,6 +81,13 @@ fn decrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) -> Result<()> {...@@ -81,6 +81,13 @@ fn decrypt(key: &[u8; 16], iv: &[u8; 16], bytes: &mut [u8]) -> Result<()> {
81 Ok(())81 Ok(())
82}82}
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
84impl Key {91impl Key {
85 pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result<Self> {92 pub(super) fn open(data: &[u8], password: &str, rounds: &mut u64) -> Result<Self> {
86 if data.len() > 65536 || password.len() > 65536 {93 if data.len() > 65536 || password.len() > 65536 {
...@@ -212,6 +219,42 @@ impl Key {...@@ -212,6 +219,42 @@ impl Key {
212 Ok(output)219 Ok(output)
213 }220 }
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
215 pub(super) fn file(&self, input: &[u8]) -> Result<Zeroizing<Vec<u8>>> {258 pub(super) fn file(&self, input: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
216 if input.is_empty() {259 if input.is_empty() {
217 return Ok(Zeroizing::new(Vec::new()));260 return Ok(Zeroizing::new(Vec::new()));
...@@ -312,7 +355,17 @@ mod tests {...@@ -312,7 +355,17 @@ mod tests {
312 crate::properties::reference_streams(&mut cursor).unwrap();355 crate::properties::reference_streams(&mut cursor).unwrap();
313 let length = u32::from_le_bytes(cursor.read().unwrap()) as usize;356 let length = u32::from_le_bytes(cursor.read().unwrap()) as usize;
314 let end = cursor.offset + length;357 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 );
316 assert_eq!(369 assert_eq!(
317 *key.property(bytes).unwrap(),370 *key.property(bytes).unwrap(),
318 *spaced.property(bytes).unwrap()371 *spaced.property(bytes).unwrap()
crates/onestore/src/protected/mod.rs+176-9
...@@ -75,9 +75,14 @@ impl Default for Limits {...@@ -75,9 +75,14 @@ impl Default for Limits {
75 }75 }
76}76}
7777
78struct Decoded<'a> {
79 stored: &'a [u8],
80 clear: Zeroizing<Vec<u8>>,
81}
82
78/// Owns decoded buffers until dropped; returned views cannot outlive this owner.83/// Owns decoded buffers until dropped; returned views cannot outlive this owner.
79///84///
80/// Passwords and derived keys are not retained. Dropping this owner clears its85/// Passwords are not retained. Dropping this owner clears its derived key and
81/// decoded buffers. Owned strings or exports made from a `Document` are separate86/// decoded buffers. Owned strings or exports made from a `Document` are separate
82/// caller-owned copies and must be disposed of by the caller when locking.87/// caller-owned copies and must be disposed of by the caller when locking.
83/// Opening never rewrites the source image or changes its protection state.88/// Opening never rewrites the source image or changes its protection state.
...@@ -93,8 +98,10 @@ impl Default for Limits {...@@ -93,8 +98,10 @@ impl Default for Limits {
93/// ```98/// ```
94pub struct UnlockedSection<'a> {99pub struct UnlockedSection<'a> {
95 index: &'a RevisionIndex<'a>,100 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>>,
97 files: BTreeMap<[u8; 16], Zeroizing<Vec<u8>>>,103 files: BTreeMap<[u8; 16], Zeroizing<Vec<u8>>>,
104 keys: BTreeMap<&'a [u8], crypto::Key>,
98}105}
99106
100impl<'a> UnlockedSection<'a> {107impl<'a> UnlockedSection<'a> {
...@@ -111,6 +118,7 @@ impl<'a> UnlockedSection<'a> {...@@ -111,6 +118,7 @@ impl<'a> UnlockedSection<'a> {
111 index,118 index,
112 objects: BTreeMap::new(),119 objects: BTreeMap::new(),
113 files: BTreeMap::new(),120 files: BTreeMap::new(),
121 keys: BTreeMap::new(),
114 };122 };
115 let mut keys = BTreeMap::new();123 let mut keys = BTreeMap::new();
116 let mut file_keys = BTreeMap::new();124 let mut file_keys = BTreeMap::new();
...@@ -141,10 +149,9 @@ impl<'a> UnlockedSection<'a> {...@@ -141,10 +149,9 @@ impl<'a> UnlockedSection<'a> {
141 if !revision.encrypted {149 if !revision.encrypted {
142 return Err(Error::Unsupported);150 return Err(Error::Unsupported);
143 }151 }
144 let node = revision152 let Some(node) = revision.nodes.first().filter(|node| node.id == 0x7c) else {
145 .nodes153 continue;
146 .first()154 };
147 .ok_or_else(|| invalid("Missing encryption key node"))?;
148 let Some(Reference::Data(chunk)) = node.reference else {155 let Some(Reference::Data(chunk)) = node.reference else {
149 return Err(invalid("Missing encryption key reference"));156 return Err(invalid("Missing encryption key reference"));
150 };157 };
...@@ -194,7 +201,13 @@ impl<'a> UnlockedSection<'a> {...@@ -194,7 +201,13 @@ impl<'a> UnlockedSection<'a> {
194 ));201 ));
195 }202 }
196 }203 }
197 result.objects.insert(identity, decoded);204 result.objects.insert(
205 identity,
206 Decoded {
207 stored: bytes,
208 clear: decoded,
209 },
210 );
198 }211 }
199 ObjectData::File { .. } => {212 ObjectData::File { .. } => {
200 if let Some(FileDataReference::Internal(guid)) =213 if let Some(FileDataReference::Internal(guid)) =
...@@ -226,7 +239,7 @@ impl<'a> UnlockedSection<'a> {...@@ -226,7 +239,7 @@ impl<'a> UnlockedSection<'a> {
226 }239 }
227 }240 }
228 }241 }
229 drop(keys);242 result.keys = keys;
230 for (space, info) in &index.spaces {243 for (space, info) in &index.spaces {
231 for rid in info.labels.values().copied().collect::<BTreeSet<_>>() {244 for rid in info.labels.values().copied().collect::<BTreeSet<_>>() {
232 result.resolve(*space, rid)?.reachable()?;245 result.resolve(*space, rid)?.reachable()?;
...@@ -251,7 +264,7 @@ impl<'a> UnlockedSection<'a> {...@@ -251,7 +264,7 @@ impl<'a> UnlockedSection<'a> {
251 offset: 0,264 offset: 0,
252 message: "Protected object was not decoded",265 message: "Protected object was not decoded",
253 })?;266 })?;
254 object.data = ObjectData::Properties(decoded);267 object.data = ObjectData::Properties(&decoded.clear);
255 }268 }
256 }269 }
257 Ok(revision)270 Ok(revision)
...@@ -273,3 +286,157 @@ impl<'a> UnlockedSection<'a> {...@@ -273,3 +286,157 @@ impl<'a> UnlockedSection<'a> {
273 )286 )
274 }287 }
275}288}
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> {...@@ -286,8 +286,11 @@ impl<'a> RevisionIndex<'a> {
286 });286 });
287 }287 }
288 }288 }
289 if (encoding == 2)289 // A protected revision names its key unless it inherits the
290 != body.first().is_some_and(|node| node.id == 0x7c)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()
291 {294 {
292 return Err(Error {295 return Err(Error {
293 offset: node.offset,296 offset: node.offset,
...@@ -304,7 +307,7 @@ impl<'a> RevisionIndex<'a> {...@@ -304,7 +307,7 @@ impl<'a> RevisionIndex<'a> {
304 message: "Object space mixes encrypted and unencrypted revisions",307 message: "Object space mixes encrypted and unencrypted revisions",
305 });308 });
306 }309 }
307 if encoding == 2 {310 if keyed {
308 let key = &body[0];311 let key = &body[0];
309 let Some(Reference::Data(chunk)) = key.reference else {312 let Some(Reference::Data(chunk)) = key.reference else {
310 return Err(Error {313 return Err(Error {
crates/onestore/src/write.rs+125-15
...@@ -374,6 +374,14 @@ pub(crate) enum RevisionEdit {...@@ -374,6 +374,14 @@ pub(crate) enum RevisionEdit {
374 roots: BTreeMap<u32, ExGuid>,374 roots: BTreeMap<u32, ExGuid>,
375 objects: BTreeMap<ExGuid, PropertyObject>,375 objects: BTreeMap<ExGuid, PropertyObject>,
376 },376 },
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 },
377}385}
378386
379impl PropertyObject {387impl PropertyObject {
...@@ -698,6 +706,16 @@ impl PropertyObject {...@@ -698,6 +706,16 @@ impl PropertyObject {
698 }706 }
699}707}
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
701pub(crate) fn write_revision(719pub(crate) fn write_revision(
702 source: &[u8],720 source: &[u8],
703 space: ExGuid,721 space: ExGuid,
...@@ -769,6 +787,22 @@ pub(crate) fn write_revisions_with_payloads(...@@ -769,6 +787,22 @@ pub(crate) fn write_revisions_with_payloads(
769 source: &[u8],787 source: &[u8],
770 payloads: &[([u8; 16], &[u8])],788 payloads: &[([u8; 16], &[u8])],
771 edit: impl FnOnce(&RevisionIndex<'_>) -> Result<BTreeMap<ExGuid, RevisionEdit>>,789 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>>,
772) -> Result<Vec<u8>> {806) -> Result<Vec<u8>> {
773 let store = Store::parse(source)?;807 let store = Store::parse(source)?;
774 let is_section = store.header.file_type == FileType::Section;808 let is_section = store.header.file_type == FileType::Section;
...@@ -779,7 +813,10 @@ pub(crate) fn write_revisions_with_payloads(...@@ -779,7 +813,10 @@ pub(crate) fn write_revisions_with_payloads(
779 });813 });
780 }814 }
781 let index = RevisionIndex::parse(&store)?;815 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 };
783 let changes = edit(&index)?;820 let changes = edit(&index)?;
784 let mut output = source.to_vec();821 let mut output = source.to_vec();
785 // Native files reserve 1 KiB per transaction-log fragment; a fragment that ends the file822 // 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(...@@ -810,10 +847,12 @@ pub(crate) fn write_revisions_with_payloads(
810 let mut counts = Vec::new();847 let mut counts = Vec::new();
811 let mut root_nodes = Vec::new();848 let mut root_nodes = Vec::new();
812 for (space, change) in changes {849 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 {
814 RevisionEdit::Update(objects) => {853 RevisionEdit::Update(objects) => {
815 let rid = index.active(space)?;854 let rid = index.active(space)?;
816 (Some(rid), index.resolve(space, rid)?, objects)855 (Some(rid), current, resolve(space, rid)?, objects)
817 }856 }
818 RevisionEdit::Create { roots, objects } => {857 RevisionEdit::Create { roots, objects } => {
819 if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) {858 if !is_section || space.guid == [0; 16] || index.spaces.contains_key(&space) {
...@@ -830,6 +869,30 @@ pub(crate) fn write_revisions_with_payloads(...@@ -830,6 +869,30 @@ pub(crate) fn write_revisions_with_payloads(
830 }869 }
831 (870 (
832 None,871 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),
833 crate::ResolvedRevision {896 crate::ResolvedRevision {
834 roots,897 roots,
835 objects: BTreeMap::new(),898 objects: BTreeMap::new(),
...@@ -1062,16 +1125,45 @@ pub(crate) fn write_revisions_with_payloads(...@@ -1062,16 +1125,45 @@ pub(crate) fn write_revisions_with_payloads(
1062 if !is_section {1125 if !is_section {
1063 start.extend_from_slice(&0_u64.to_le_bytes());1126 start.extend_from_slice(&0_u64.to_le_bytes());
1064 }1127 }
1065 start.extend_from_slice(&1_u32.to_le_bytes());1128 start.extend_from_slice(&label.1.to_le_bytes());
1066 start.extend_from_slice(&0_u16.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 }
1067 let mut manifest = Vec::new();1134 let mut manifest = Vec::new();
1068 if rid.is_none() {1135 if new_space {
1069 let mut payload = Vec::new();1136 let mut payload = Vec::new();
1070 space.encode(&mut payload);1137 space.encode(&mut payload);
1071 payload.extend_from_slice(&0_u32.to_le_bytes());1138 payload.extend_from_slice(&0_u32.to_le_bytes());
1072 manifest.push(node(0x14, None, &payload)?);1139 manifest.push(node(0x14, None, &payload)?);
1073 }1140 }
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 }
1075 for (table, objects) in groups {1167 for (table, objects) in groups {
1076 let mut payload = Vec::new();1168 let mut payload = Vec::new();
1077 let mut group = if is_section {1169 let mut group = if is_section {
...@@ -1139,14 +1231,26 @@ pub(crate) fn write_revisions_with_payloads(...@@ -1139,14 +1231,26 @@ pub(crate) fn write_revisions_with_payloads(
1139 }1231 }
1140 append(&mut output, &mapped)?1232 append(&mut output, &mapped)?
1141 } else if replacements.contains_key(&id) {1233 } 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 }
1143 } else {1240 } 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 };
1144 Chunk {1248 Chunk {
1145 offset: u64::try_from(1249 offset: u64::try_from(
1146 bytes.as_ptr().addr() - source.as_ptr().addr(),1250 stored.as_ptr().addr() - source.as_ptr().addr(),
1147 )1251 )
1148 .unwrap(),1252 .unwrap(),
1149 length: u64::try_from(bytes.len()).unwrap(),1253 length: u64::try_from(stored.len()).unwrap(),
1150 }1254 }
1151 };1255 };
1152 // A table-of-contents object is declared when the revision is a1256 // A table-of-contents object is declared when the revision is a
...@@ -1164,7 +1268,13 @@ pub(crate) fn write_revisions_with_payloads(...@@ -1164,7 +1268,13 @@ pub(crate) fn write_revisions_with_payloads(
1164 declaration.extend_from_slice(&object.reference_count.to_le_bytes());1268 declaration.extend_from_slice(&object.reference_count.to_le_bytes());
1165 let readonly = object.jcid & 0x100000 != 0;1269 let readonly = object.jcid & 0x100000 != 0;
1166 if readonly {1270 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);
1168 }1278 }
1169 group.push(node(1279 group.push(node(
1170 if is_section {1280 if is_section {
...@@ -1219,7 +1329,7 @@ pub(crate) fn write_revisions_with_payloads(...@@ -1219,7 +1329,7 @@ pub(crate) fn write_revisions_with_payloads(
1219 }1329 }
1220 }1330 }
1221 manifest.push(node(0x1c, None, &[])?);1331 manifest.push(node(0x1c, None, &[])?);
1222 if rid.is_none() {1332 if new_space {
1223 let list_id = allocate_list()?;1333 let list_id = allocate_list()?;
1224 let chunk = append_list(&mut output, list_id, &manifest)?;1334 let chunk = append_list(&mut output, list_id, &manifest)?;
1225 counts.push((list_id, manifest.len()));1335 counts.push((list_id, manifest.len()));
...@@ -1269,6 +1379,8 @@ pub(crate) fn write_revisions_with_payloads(...@@ -1269,6 +1379,8 @@ pub(crate) fn write_revisions_with_payloads(
1269 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a,1379 0xe7, 0x16, 0xe3, 0xbd, 0x65, 0x26, 0x11, 0x45, 0xa4, 0xc4, 0x8d, 0x4d, 0x0b, 0x7a,
1270 0x9e, 0xac,1380 0x9e, 0xac,
1271 ];1381 ];
1382 let sealed = protection.map(|protection| protection.seal_file(payload));
1383 let payload = sealed.as_deref().unwrap_or(*payload);
1272 blob.extend_from_slice(&(payload.len() as u64).to_le_bytes());1384 blob.extend_from_slice(&(payload.len() as u64).to_le_bytes());
1273 blob.extend_from_slice(&[0; 12]);1385 blob.extend_from_slice(&[0; 12]);
1274 blob.extend_from_slice(payload);1386 blob.extend_from_slice(payload);
...@@ -1410,8 +1522,6 @@ pub(crate) fn write_revisions_with_payloads(...@@ -1410,8 +1522,6 @@ pub(crate) fn write_revisions_with_payloads(
1410 message: "File generation counter is exhausted",1522 message: "File generation counter is exhausted",
1411 })?;1523 })?;
1412 output[228..236].copy_from_slice(&generation.to_le_bytes());1524 output[228..236].copy_from_slice(&generation.to_le_bytes());
1413 let written = Store::parse(&output)?;1525 RevisionIndex::parse(&Store::parse(&output)?)?;
1414 let written_index = RevisionIndex::parse(&written)?;
1415 written_index.validate_current()?;
1416 Ok(output)1526 Ok(output)
1417}1527}
crates/onestore/tests/protected.rs+129
...@@ -108,3 +108,132 @@ fn limits_and_password_bytes_are_explicit() {...@@ -108,3 +108,132 @@ fn limits_and_password_bytes_are_explicit() {
108 Err(Error::Unsupported)108 Err(Error::Unsupported)
109 ));109 ));
110}110}
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"...@@ -136,3 +136,10 @@ path = "fuzz_targets/page_model.rs"
136test = false136test = false
137doc = false137doc = false
138bench = false138bench = 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()