| author | |
| committer | |
| log | 860f9341c754f84c8ce5d06a103d2d1fd39fefdb |
| tree | 5540be3b4f8492375eeca8e632a3a5ed7cc8ca52 |
| parent | 2393166f77111e9fcba3a433294bddc0c4df5ec9 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.123 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 | ||
| 5 | paragraph, 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 | ||
| 7 | fresh clone with an empty cache after unlocking the section in its UI with the | ||
| 8 | fixture 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 | ||
| 12 | that depend on an earlier revision carry no key node (`0x7c`); only revisions | ||
| 13 | without 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 @@ | ||
| 1 | Fictitious 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 | ||
| 3 | style='font-weight:bold;font-family:Calibri' lang=en-US>Fictitious: café, </span><span | ||
| 4 | style='font-weight:bold;font-family:SimSun' lang=en-US>東京</span><span | ||
| 5 | style='font-weight:bold;font-family:Calibri' lang=en-US>, </span><span | ||
| 6 | style='font-weight:bold;font-family:Arial;direction:rtl;unicode-bidi:embed' | ||
| 7 | lang=ar-SA>مرحبا</span><span style='font-weight:bold;font-family:Calibri' | ||
| 8 | lang=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 | ||
| 9 | AAAASUVORK5CYII=</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 |
| 347 | targets are `None`. | 347 | targets are `None`. |
| 348 | 348 | ||
| 349 | With the optional `protected` feature, `Notebook::unlock(path, password)` | 349 | With the optional `protected` feature, `Notebook::unlock(path, password)` |
| 350 | reads the pages of a `Locked` section for display; nothing is cached or | 350 | reads the pages of a `Locked` section, and `Notebook::save_unlocked(path, |
| 351 | written, and a wrong password is `Error::Protected(PasswordMismatch)`. | 351 | password, space, page, author)` saves an edited one under the section's key, |
| 352 | straight to the file: nothing of a protected section is cached or queued, a | ||
| 353 | section changed since the read fails the save, and a wrong password is | ||
| 354 | `Error::Protected(PasswordMismatch)`. | ||
| 352 | 355 | ||
| 353 | `Section::import_page(page, author)` copies a page, usually read from another | 356 | `Section::import_page(page, author)` copies a page, usually read from another |
| 354 | section, to the end of this one as a page creation and a save queued like the | 357 | section, 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 | } |
| 645 | 645 | ||
| 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] | ||
| 35 | fn 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 2010 | 82 | `protected` feature, `protected::UnlockedSection` opens native OneNote 2010 |
| 83 | AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect | 83 | AES-128/CBC, SHA-1 password wrappers into a borrowed document view. Incorrect |
| 84 | passwords, unsupported protection profiles and work-limit failures remain distinct. | 84 | passwords, unsupported protection profiles and work-limit failures remain distinct. |
| 85 | The source stays encrypted; protected writes are rejected. | 85 | The source stays encrypted. `PreparedEdit::page_protected` publishes a page-model |
| 86 | edit stored under the section's key (fresh IV per object, payloads under the file | ||
| 87 | IV); the other writers reject protected sections. | ||
| 86 | `PageCreation::new` appends, or inserts before the first page space of an existing | 88 | `PageCreation::new` appends, or inserts before the first page space of an existing |
| 87 | series. `Some("")` creates an empty title field; `None` omits the title node. | 89 | series. `Some("")` creates an empty title field; `None` omits the title node. |
| 88 | The page has no body outlines, generated date/time text or applied template. | 90 | The 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 | ``` |
| 339 | 341 | ||
| 340 | This example requires `features = ["protected"]`. The owner retains no password or | 342 | This example requires `features = ["protected"]`. The owner retains no password; |
| 341 | key after opening. Its source-buffer views cannot outlive it; copies of parsed | 343 | its derived key is cleared on drop. Its source-buffer views cannot outlive it; copies of parsed |
| 342 | strings, serialized models and exports have their own lifetimes. These copies are | 344 | strings, serialized models and exports have their own lifetimes. These copies are |
| 343 | plaintext, and dropping the unlock owner does not clear them. CBC has no general | 345 | plaintext, and dropping the unlock owner does not clear them. CBC has no general |
| 344 | ciphertext-authentication guarantee; native read-only hashes and model validation | 346 | ciphertext-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 | } |
| 263 | 263 | ||
| 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 | } |
| 366 | 366 | ||
| 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 | } |
| 2910 | 2910 | ||
| 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 |
| 2913 | fn squash( | 2913 | /// revisions its plaintext twin gained. |
| 2914 | pub(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> = alias | 2920 | 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 | } |
| 3013 | 3026 | ||
| 3014 | fn remap(object: &mut PropertyObject, rename: &BTreeMap<ExGuid, ExGuid>) -> Result<(), Error> { | 3027 | fn 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 @@ |
| 1 | use super::{Error, Result, invalid}; | 1 | use super::{Error, Result, invalid}; |
| 2 | use aes::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::NoPadding}; | 2 | use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::NoPadding}; |
| 3 | use base64::{Engine, engine::general_purpose::STANDARD}; | 3 | use base64::{Engine, engine::general_purpose::STANDARD}; |
| 4 | use sha1::{Digest, Sha1}; | 4 | use sha1::{Digest, Sha1}; |
| 5 | use subtle::ConstantTimeEq; | 5 | use 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 | } |
| 83 | 83 | ||
| 84 | fn 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 | |||
| 84 | impl Key { | 91 | impl 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 | } |
| 214 | 221 | ||
| 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 | } |
| 77 | 77 | ||
| 78 | struct 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 its | 85 | /// Passwords are not retained. Dropping this owner clears its derived key and |
| 81 | /// decoded buffers. Owned strings or exports made from a `Document` are separate | 86 | /// 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 | /// ``` |
| 94 | pub struct UnlockedSection<'a> { | 99 | pub 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 | } |
| 99 | 106 | ||
| 100 | impl<'a> UnlockedSection<'a> { | 107 | impl<'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 = revision | 152 | let Some(node) = revision.nodes.first().filter(|node| node.id == 0x7c) else { |
| 145 | .nodes | 153 | 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 | |||
| 290 | impl 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`]. | ||
| 391 | pub(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 | |||
| 409 | impl 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 | } |
| 378 | 386 | ||
| 379 | impl PropertyObject { | 387 | impl PropertyObject { |
| ... | @@ -698,6 +706,16 @@ impl PropertyObject { | ... | @@ -698,6 +706,16 @@ impl PropertyObject { |
| 698 | } | 706 | } |
| 699 | } | 707 | } |
| 700 | 708 | ||
| 709 | /// A password-protected section's unlocked view, through which its revisions are | ||
| 710 | /// read as plaintext and written back in their stored form. | ||
| 711 | pub(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 | |||
| 701 | pub(crate) fn write_revision( | 719 | pub(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`. | ||
| 801 | pub(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 file | 822 | // 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 a | 1256 | // 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] | ||
| 115 | fn 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] | ||
| 217 | fn 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" |
| 136 | test = false | 136 | test = false |
| 137 | doc = false | 137 | doc = false |
| 138 | bench = false | 138 | bench = false |
| 139 | |||
| 140 | [[bin]] | ||
| 141 | name = "protected_write" | ||
| 142 | path = "fuzz_targets/protected_write.rs" | ||
| 143 | test = false | ||
| 144 | doc = false | ||
| 145 | bench = 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. | ||
| 4 | use libfuzzer_sys::fuzz_target; | ||
| 5 | use onestore::{ | ||
| 6 | ExGuid, PreparedEdit, RevisionIndex, Store, | ||
| 7 | page::{Page, PageObject, Paragraph, text::Edit}, | ||
| 8 | protected::{Limits, UnlockedSection}, | ||
| 9 | }; | ||
| 10 | |||
| 11 | const SOURCE: &[u8] = | ||
| 12 | include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one"); | ||
| 13 | const PASSWORD: &str = "fictitious-only"; | ||
| 14 | |||
| 15 | fn 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 | |||
| 24 | fuzz_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 @@ | ||
| 1 | import json | ||
| 2 | from pathlib import Path | ||
| 3 | import runpy | ||
| 4 | import shutil | ||
| 5 | import subprocess | ||
| 6 | from tempfile import TemporaryDirectory | ||
| 7 | import unittest | ||
| 8 | |||
| 9 | from document_model import EXPORTER | ||
| 10 | |||
| 11 | ROOT = Path(__file__).resolve().parent.parent | ||
| 12 | FIXTURE = ROOT / 'corpus/protected-edit' | ||
| 13 | compare = runpy.run_path(str(ROOT / 'tools/verify-document.py'))['compare'] | ||
| 14 | |||
| 15 | |||
| 16 | class 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 | |||
| 44 | if __name__ == '__main__': | ||
| 45 | unittest.main() | ||