| 1 | //! Edit rows. An edit is stored as its serialized ops with picture and attachment bytes |
| 2 | //! moved to the `payloads` table, named by SHA-256 in the order `slots` visits them. |
| 3 | //! |
| 4 | //! A password-protected section's queue keeps none of it in the clear: each edit and |
| 5 | //! payload is sealed with AES-256-GCM (a random nonce, the row's kind as associated data) |
| 6 | //! and payloads are named by HMAC-SHA256, both under keys HMAC-SHA256 derives from the |
| 7 | //! section's own key. Only the author's name and the payload names stay readable. |
| 8 | |
| 9 | use crate::{PendingEdit, Result, signed, unsigned}; |
| 10 | use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; |
| 11 | use hmac::{Hmac, Mac}; |
| 12 | use onestore::{ |
| 13 | op::{Edit, Op, PageOp, SectionOp, TableEdit}, |
| 14 | page::{PageObject, PageParagraph, ParagraphContent, TableCell}, |
| 15 | protected::Key, |
| 16 | }; |
| 17 | use rusqlite::{Connection, OptionalExtension, params}; |
| 18 | use sha2::{Digest, Sha256}; |
| 19 | use std::{io, sync::Arc}; |
| 20 | use zeroize::Zeroizing; |
| 21 | |
| 22 | type Payload = Option<Arc<[u8]>>; |
| 23 | |
| 24 | fn paragraphs(list: &mut [PageParagraph], visit: &mut impl FnMut(&mut Payload)) { |
| 25 | for paragraph in list { |
| 26 | match &mut paragraph.content { |
| 27 | ParagraphContent::Image(image) => visit(&mut image.bytes), |
| 28 | ParagraphContent::Attachment(attachment) => { |
| 29 | visit(&mut attachment.bytes); |
| 30 | visit(&mut attachment.preview); |
| 31 | } |
| 32 | ParagraphContent::Table(table) => { |
| 33 | for row in &mut table.rows { |
| 34 | cells(&mut row.cells, visit); |
| 35 | } |
| 36 | } |
| 37 | ParagraphContent::Text(_) |
| 38 | | ParagraphContent::Ink(_) |
| 39 | | ParagraphContent::Unsupported(_) => {} |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | fn cells(list: &mut [TableCell], visit: &mut impl FnMut(&mut Payload)) { |
| 45 | for cell in list { |
| 46 | paragraphs(&mut cell.paragraphs, visit); |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | fn object(object: &mut PageObject, visit: &mut impl FnMut(&mut Payload)) { |
| 51 | match object { |
| 52 | PageObject::Outline(outline) => paragraphs(&mut outline.paragraphs, visit), |
| 53 | PageObject::Title(title) => { |
| 54 | for outline in &mut title.outlines { |
| 55 | paragraphs(&mut outline.paragraphs, visit); |
| 56 | } |
| 57 | } |
| 58 | PageObject::Image(image) => visit(&mut image.bytes), |
| 59 | PageObject::Attachment(attachment) => { |
| 60 | visit(&mut attachment.bytes); |
| 61 | visit(&mut attachment.preview); |
| 62 | } |
| 63 | PageObject::Ink(_) | PageObject::Unsupported(_) => {} |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /// Visits every payload an edit carries, in a fixed order. |
| 68 | fn slots(edit: &mut Edit, mut visit: impl FnMut(&mut Payload)) { |
| 69 | for op in &mut edit.ops { |
| 70 | match op { |
| 71 | Op::Page { op, .. } => match op { |
| 72 | PageOp::Insert { |
| 73 | paragraphs: list, .. |
| 74 | } => paragraphs(list, &mut visit), |
| 75 | PageOp::Add { object: added, .. } => object(added, &mut visit), |
| 76 | PageOp::Table { |
| 77 | edit: TableEdit::Rows { rows, .. }, |
| 78 | .. |
| 79 | } => { |
| 80 | for row in rows { |
| 81 | cells(&mut row.cells, &mut visit); |
| 82 | } |
| 83 | } |
| 84 | PageOp::Table { |
| 85 | edit: TableEdit::Column { cells: list, .. }, |
| 86 | .. |
| 87 | } => cells(list, &mut visit), |
| 88 | _ => {} |
| 89 | }, |
| 90 | Op::Section(SectionOp::Import { page, .. } | SectionOp::Conflict { page, .. }) => { |
| 91 | for added in &mut page.objects { |
| 92 | object(added, &mut visit); |
| 93 | } |
| 94 | } |
| 95 | Op::Section(_) => {} |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | fn damaged(message: &'static str) -> crate::Error { |
| 101 | io::Error::new(io::ErrorKind::InvalidData, message).into() |
| 102 | } |
| 103 | |
| 104 | /// A key `key` derives for `purpose`. |
| 105 | fn derived(key: &Key, purpose: &[u8]) -> Zeroizing<[u8; 32]> { |
| 106 | let mut mac = |
| 107 | <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(key.secret()).expect("any key length"); |
| 108 | mac.update(purpose); |
| 109 | Zeroizing::new(mac.finalize().into_bytes().into()) |
| 110 | } |
| 111 | |
| 112 | fn cipher(key: &Key) -> Aes256Gcm { |
| 113 | Aes256Gcm::new_from_slice(&*derived(key, b"Snowbound queue v1")).expect("a 32-byte key") |
| 114 | } |
| 115 | |
| 116 | /// A payload's name: its SHA-256, or in a protected section's queue an HMAC. |
| 117 | fn name(key: Option<&Key>, bytes: &[u8]) -> String { |
| 118 | match key { |
| 119 | Some(key) => { |
| 120 | let mut mac = <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(&*derived( |
| 121 | key, |
| 122 | b"Snowbound payload names v1", |
| 123 | )) |
| 124 | .expect("any key length"); |
| 125 | mac.update(bytes); |
| 126 | hex(&mac.finalize().into_bytes()) |
| 127 | } |
| 128 | None => hex(&Sha256::digest(bytes)), |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /// `clear` sealed under `key` as `kind`: a random nonce, then the ciphertext and tag. |
| 133 | fn seal(key: &Key, kind: &[u8], clear: &[u8]) -> Result<Vec<u8>> { |
| 134 | let mut nonce = [0; 12]; |
| 135 | getrandom::fill(&mut nonce).map_err(|_| io::Error::other("System random source failed"))?; |
| 136 | let sealed = cipher(key) |
| 137 | .encrypt( |
| 138 | &nonce.into(), |
| 139 | aes_gcm::aead::Payload { |
| 140 | msg: clear, |
| 141 | aad: kind, |
| 142 | }, |
| 143 | ) |
| 144 | .map_err(|_| io::Error::other("A queued edit could not be sealed"))?; |
| 145 | Ok([&nonce[..], &sealed].concat()) |
| 146 | } |
| 147 | |
| 148 | /// The inverse of `seal`. |
| 149 | fn open(key: &Key, kind: &[u8], sealed: &[u8]) -> Result<Zeroizing<Vec<u8>>> { |
| 150 | let (nonce, sealed) = sealed |
| 151 | .split_first_chunk::<12>() |
| 152 | .ok_or_else(|| damaged("A sealed queue row is truncated"))?; |
| 153 | Ok(Zeroizing::new( |
| 154 | cipher(key) |
| 155 | .decrypt( |
| 156 | &(*nonce).into(), |
| 157 | aes_gcm::aead::Payload { |
| 158 | msg: sealed, |
| 159 | aad: kind, |
| 160 | }, |
| 161 | ) |
| 162 | .map_err(|_| damaged("A sealed queue row does not open under the section's key"))?, |
| 163 | )) |
| 164 | } |
| 165 | |
| 166 | /// Stores `edit` in `batch` under `id`, or the next id, its payloads once each by name; |
| 167 | /// returns the edit's id. |
| 168 | pub(crate) fn insert( |
| 169 | connection: &Connection, |
| 170 | key: Option<&Key>, |
| 171 | id: Option<u64>, |
| 172 | batch: i64, |
| 173 | author: &str, |
| 174 | edit: &Edit, |
| 175 | ) -> Result<u64> { |
| 176 | let (text, names) = encode(connection, key, edit)?; |
| 177 | connection.execute( |
| 178 | "INSERT INTO edits(id, batch, author, edit, payloads) VALUES (?1, ?2, ?3, ?4, ?5)", |
| 179 | params![id.map(signed).transpose()?, batch, author, text, names], |
| 180 | )?; |
| 181 | unsigned(connection.last_insert_rowid()) |
| 182 | } |
| 183 | |
| 184 | fn hex(digest: &[u8]) -> String { |
| 185 | digest.iter().map(|byte| format!("{byte:02x}")).collect() |
| 186 | } |
| 187 | |
| 188 | /// Replaces a stored edit's ops. |
| 189 | pub(crate) fn rewrite( |
| 190 | connection: &Connection, |
| 191 | key: Option<&Key>, |
| 192 | id: u64, |
| 193 | edit: &Edit, |
| 194 | ) -> Result<()> { |
| 195 | let (text, names) = encode(connection, key, edit)?; |
| 196 | connection.execute( |
| 197 | "UPDATE edits SET edit=?1, payloads=?2 WHERE id=?3", |
| 198 | params![text, names, signed(id)?], |
| 199 | )?; |
| 200 | Ok(()) |
| 201 | } |
| 202 | |
| 203 | /// The serialized edit without payload bytes and the payload names, storing the payloads; |
| 204 | /// sealed under `key` for a protected section. |
| 205 | fn encode( |
| 206 | connection: &Connection, |
| 207 | key: Option<&Key>, |
| 208 | edit: &Edit, |
| 209 | ) -> Result<(String, Option<String>)> { |
| 210 | let mut edit = edit.clone(); |
| 211 | let mut names: Vec<Option<String>> = Vec::new(); |
| 212 | let mut failure = None; |
| 213 | slots(&mut edit, |payload| { |
| 214 | let name = payload.take().map(|bytes| { |
| 215 | let name = name(key, &bytes); |
| 216 | let stored = match key { |
| 217 | Some(key) => seal(key, b"payload", &bytes), |
| 218 | None => Ok(bytes.to_vec()), |
| 219 | }; |
| 220 | let stored = stored.and_then(|stored| { |
| 221 | Ok(connection.execute( |
| 222 | "INSERT INTO payloads(sha256, bytes) VALUES (unhex(?1), ?2) ON CONFLICT DO NOTHING", |
| 223 | params![&name, &stored[..]], |
| 224 | )?) |
| 225 | }); |
| 226 | if let Err(error) = stored { |
| 227 | failure.get_or_insert(error); |
| 228 | } |
| 229 | name |
| 230 | }); |
| 231 | names.push(name); |
| 232 | }); |
| 233 | if let Some(error) = failure { |
| 234 | return Err(error); |
| 235 | } |
| 236 | let names = names |
| 237 | .iter() |
| 238 | .any(Option::is_some) |
| 239 | .then(|| serde_json::to_string(&names)) |
| 240 | .transpose() |
| 241 | .map_err(io::Error::other)?; |
| 242 | let text = Zeroizing::new(serde_json::to_string(&edit).map_err(io::Error::other)?); |
| 243 | let text = match key { |
| 244 | Some(key) => { |
| 245 | use base64::Engine; |
| 246 | base64::engine::general_purpose::STANDARD.encode(seal(key, b"edit", text.as_bytes())?) |
| 247 | } |
| 248 | None => text.to_string(), |
| 249 | }; |
| 250 | Ok((text, names)) |
| 251 | } |
| 252 | |
| 253 | /// A stored edit's ops, without its payloads. |
| 254 | pub(crate) fn parse(key: Option<&Key>, text: &str) -> Result<Edit> { |
| 255 | Ok(match key { |
| 256 | Some(key) => { |
| 257 | use base64::Engine; |
| 258 | let sealed = base64::engine::general_purpose::STANDARD |
| 259 | .decode(text) |
| 260 | .map_err(|_| damaged("A sealed queued edit is damaged"))?; |
| 261 | serde_json::from_slice(&open(key, b"edit", &sealed)?) |
| 262 | } |
| 263 | None => serde_json::from_str(text), |
| 264 | } |
| 265 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?) |
| 266 | } |
| 267 | |
| 268 | fn decode( |
| 269 | connection: &Connection, |
| 270 | key: Option<&Key>, |
| 271 | text: &str, |
| 272 | names: Option<String>, |
| 273 | ) -> Result<Edit> { |
| 274 | let mut edit = parse(key, text)?; |
| 275 | let Some(names) = names else { |
| 276 | return Ok(edit); |
| 277 | }; |
| 278 | let names: Vec<Option<String>> = serde_json::from_str(&names) |
| 279 | .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; |
| 280 | let mut names = names.into_iter(); |
| 281 | let mut failure: Option<crate::Error> = None; |
| 282 | let mut query = |
| 283 | connection.prepare_cached("SELECT bytes FROM payloads WHERE sha256=unhex(?1)")?; |
| 284 | slots(&mut edit, |payload| { |
| 285 | let Some(name) = names.next() else { |
| 286 | failure.get_or_insert(damaged("A queued edit names fewer payloads than it holds")); |
| 287 | return; |
| 288 | }; |
| 289 | let Some(name) = name else { |
| 290 | return; |
| 291 | }; |
| 292 | let bytes = query |
| 293 | .query_row([&name], |row| row.get::<_, Vec<u8>>(0)) |
| 294 | .optional(); |
| 295 | let bytes = match (key, bytes) { |
| 296 | (Some(key), Ok(Some(sealed))) => { |
| 297 | open(key, b"payload", &sealed).map(|bytes| Some(bytes.to_vec())) |
| 298 | } |
| 299 | (_, bytes) => bytes.map_err(Into::into), |
| 300 | }; |
| 301 | match bytes { |
| 302 | Ok(Some(bytes)) if self::name(key, &bytes) == name => { |
| 303 | *payload = Some(Arc::from(bytes)); |
| 304 | } |
| 305 | Ok(_) => { |
| 306 | failure.get_or_insert(damaged("A queued payload is missing or damaged")); |
| 307 | } |
| 308 | Err(error) => { |
| 309 | failure.get_or_insert(error); |
| 310 | } |
| 311 | } |
| 312 | }); |
| 313 | if names.next().is_some() { |
| 314 | failure.get_or_insert(damaged("A queued edit names more payloads than it holds")); |
| 315 | } |
| 316 | match failure { |
| 317 | Some(error) => Err(error), |
| 318 | None => Ok(edit), |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | /// Queued edits, oldest first; of one batch when `batch` is given. |
| 323 | pub(crate) fn load( |
| 324 | connection: &Connection, |
| 325 | key: Option<&Key>, |
| 326 | batch: Option<i64>, |
| 327 | ) -> Result<Vec<PendingEdit>> { |
| 328 | let mut query = connection.prepare_cached( |
| 329 | "SELECT id, author, edit, payloads FROM edits WHERE ?1 IS NULL OR batch=?1 ORDER BY id", |
| 330 | )?; |
| 331 | let mut rows = query.query([batch])?; |
| 332 | let mut edits = Vec::new(); |
| 333 | while let Some(row) = rows.next()? { |
| 334 | edits.push(PendingEdit { |
| 335 | id: unsigned(row.get(0)?)?, |
| 336 | author: row.get(1)?, |
| 337 | edit: decode(connection, key, &row.get::<_, String>(2)?, row.get(3)?)?, |
| 338 | }); |
| 339 | } |
| 340 | Ok(edits) |
| 341 | } |
| 342 | |
| 343 | /// Drops payloads no queued edit names. |
| 344 | pub(crate) fn collect(connection: &Connection) -> Result<()> { |
| 345 | connection.execute( |
| 346 | "DELETE FROM payloads WHERE NOT EXISTS (SELECT 1 FROM edits, json_each(edits.payloads) AS name |
| 347 | WHERE edits.payloads IS NOT NULL AND name.value=lower(hex(payloads.sha256)))", |
| 348 | [], |
| 349 | )?; |
| 350 | Ok(()) |
| 351 | } |
| 352 | |
| 353 | /// The object spaces an edit changes; section ops change the root space as well. |
| 354 | pub(crate) fn spaces(edit: &Edit, root: onestore::ExGuid) -> Vec<onestore::ExGuid> { |
| 355 | let mut spaces = Vec::new(); |
| 356 | for op in &edit.ops { |
| 357 | match op { |
| 358 | Op::Page { space, .. } => spaces.push(*space), |
| 359 | Op::Section(op) => { |
| 360 | spaces.push(root); |
| 361 | match op { |
| 362 | SectionOp::Create(creation) | SectionOp::Import { creation, .. } => { |
| 363 | spaces.push(creation.space()); |
| 364 | } |
| 365 | SectionOp::Conflict { of, creation, .. } => { |
| 366 | spaces.extend([*of, creation.space()]); |
| 367 | } |
| 368 | SectionOp::Pages(edits) => spaces.extend(edits.iter().map(|edit| edit.space())), |
| 369 | SectionOp::Delete(pages) => spaces.extend(pages), |
| 370 | SectionOp::RestoreVersion { page, .. } |
| 371 | | SectionOp::DeleteVersions { page, .. } => spaces.push(*page), |
| 372 | SectionOp::Color(_) => {} |
| 373 | } |
| 374 | } |
| 375 | } |
| 376 | } |
| 377 | spaces.sort(); |
| 378 | spaces.dedup(); |
| 379 | spaces |
| 380 | } |
| 381 | |
| 382 | #[cfg(test)] |
| 383 | mod tests { |
| 384 | use super::*; |
| 385 | use onestore::{ExGuid, page::Image}; |
| 386 | |
| 387 | #[test] |
| 388 | fn payloads_are_stored_once_by_hash_and_restored_in_place() { |
| 389 | let connection = Connection::open_in_memory().unwrap(); |
| 390 | connection.execute_batch(crate::schema::QUEUE).unwrap(); |
| 391 | connection |
| 392 | .execute("INSERT INTO batches DEFAULT VALUES", []) |
| 393 | .unwrap(); |
| 394 | let bytes: Arc<[u8]> = Arc::from(vec![7_u8; 100_000]); |
| 395 | let image = |id: u32| { |
| 396 | PageObject::Image(Image { |
| 397 | id: ExGuid { |
| 398 | guid: [1; 16], |
| 399 | n: id, |
| 400 | }, |
| 401 | layout: Default::default(), |
| 402 | size: None, |
| 403 | bytes: Some(Arc::clone(&bytes)), |
| 404 | display: None, |
| 405 | alt: None, |
| 406 | background: false, |
| 407 | printout: None, |
| 408 | tags: Vec::new(), |
| 409 | link: None, |
| 410 | text: None, |
| 411 | }) |
| 412 | }; |
| 413 | let space = ExGuid { |
| 414 | guid: [2; 16], |
| 415 | n: 1, |
| 416 | }; |
| 417 | let edit = Edit { |
| 418 | at: 1, |
| 419 | ops: [image(1), image(2)] |
| 420 | .into_iter() |
| 421 | .map(|object| Op::Page { |
| 422 | space, |
| 423 | op: PageOp::Add { |
| 424 | object, |
| 425 | before: None, |
| 426 | }, |
| 427 | }) |
| 428 | .collect(), |
| 429 | }; |
| 430 | let id = insert(&connection, None, None, 1, "Author", &edit).unwrap(); |
| 431 | let stored: String = connection |
| 432 | .query_row("SELECT edit FROM edits WHERE id=?1", [id as i64], |row| { |
| 433 | row.get(0) |
| 434 | }) |
| 435 | .unwrap(); |
| 436 | assert!(stored.len() < 1000, "{}", stored.len()); |
| 437 | let payloads: i64 = connection |
| 438 | .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0)) |
| 439 | .unwrap(); |
| 440 | assert_eq!(payloads, 1); |
| 441 | let loaded = load(&connection, None, None).unwrap(); |
| 442 | assert_eq!(loaded[0].edit, edit); |
| 443 | let Op::Page { |
| 444 | op: |
| 445 | PageOp::Add { |
| 446 | object: PageObject::Image(image), |
| 447 | .. |
| 448 | }, |
| 449 | .. |
| 450 | } = &loaded[0].edit.ops[1] |
| 451 | else { |
| 452 | panic!() |
| 453 | }; |
| 454 | assert_eq!(image.bytes.as_deref(), Some(&bytes[..])); |
| 455 | connection.execute("DELETE FROM edits", []).unwrap(); |
| 456 | collect(&connection).unwrap(); |
| 457 | let payloads: i64 = connection |
| 458 | .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0)) |
| 459 | .unwrap(); |
| 460 | assert_eq!(payloads, 0); |
| 461 | } |
| 462 | } |