From bbcd2f47e8c5f16c33bfdbd58df61a8828b6805e Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:45:21 +0200 Subject: [PATCH] Web restore: reject originals that don't match their content hash (#227) --- apps/lightcraft-web/src/backup.rs | 36 +++++++++++++++++++++++++++++-- apps/lightcraft-web/src/safety.rs | 5 ++++- docs/web.md | 7 +++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/lightcraft-web/src/backup.rs b/apps/lightcraft-web/src/backup.rs index 5161e69f..00ec12a4 100644 --- a/apps/lightcraft-web/src/backup.rs +++ b/apps/lightcraft-web/src/backup.rs @@ -11,8 +11,9 @@ //! //! Restoring never deletes anything: the library files go to a new library folder in browser //! storage, originals that aren't stored yet are added (they're named by content, so existing -//! ones are identical), and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to -//! the restored library. The library that was there before stays in storage. +//! ones are identical; a restored one must match its content hash, see [`verify_original`]), +//! and only then does a small pointer file ([`ACTIVE_LIBRARY`]) switch to the restored library. +//! The library that was there before stays in storage. //! //! No zip64: a backup holds at most [`MAX_BYTES`] and 65 535 entries. @@ -241,6 +242,16 @@ pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> { Ok(()) } +/// Check that an original's bytes are the photo its storage key names: originals are stored +/// under their content hash, and a restore that kept bytes under another photo's hash would +/// give that photo's catalog record a different image (the zip checksum can't tell). +pub fn verify_original(entry: &ZipEntry, hash: &str, data: &[u8]) -> Result<(), String> { + if crate::store::content_hash(data) != hash { + return Err(format!("{}: the file doesn't match its content hash (damaged or altered backup)", entry.name)); + } + Ok(()) +} + /// A name for the restored library's folder. pub fn restored_dir_name(now_ms: f64) -> String { format!("library-restored-{}", now_ms.max(0.0) as u64) @@ -283,6 +294,27 @@ mod tests { assert!(find_central(b"not a zip at all, just some text").is_err()); } + #[test] + fn originals_must_match_their_content_hash() { + let red: &[u8] = b"red corner photo"; + let blue: &[u8] = b"blue corner photo"; + let hash = crate::store::content_hash(red); + let name = original_entry(&hash, "IMG 1.png"); + let key = restore_key(&name, "d").unwrap(); + let key_hash = key.strip_prefix("originals/").unwrap(); + assert_eq!(key_hash, hash); + // the entry's own bytes pass; other bytes under the same name (valid zip CRC) don't + let zip = build(&[(name.as_str(), blue)]); + let tail = &zip[zip.len() - tail_len(zip.len() as u64) as usize..]; + let (off, size) = find_central(tail).unwrap(); + let entries = parse_central(&zip[off as usize..(off + size) as usize]).unwrap(); + let entry = &entries[0]; + verify(entry, blue).unwrap(); + let err = verify_original(entry, key_hash, blue).unwrap_err(); + assert!(err.contains(&name), "{err}"); + verify_original(entry, key_hash, red).unwrap(); + } + #[test] fn restore_keys_are_safe() { assert_eq!(restore_key("library/catalog.snap", "library-restored-1").as_deref(), Some("library-restored-1/catalog.snap")); diff --git a/apps/lightcraft-web/src/safety.rs b/apps/lightcraft-web/src/safety.rs index c0dac59c..3104fc7d 100644 --- a/apps/lightcraft-web/src/safety.rs +++ b/apps/lightcraft-web/src/safety.rs @@ -14,7 +14,7 @@ use wasm_bindgen_futures::JsFuture; use crate::backend::Backend; use crate::backup::{ ACTIVE_LIBRARY, README, ZipWriter, data_offset, find_central, original_entry, parse_central, restore_key, restored_dir_name, tail_len, - valid_hash, verify, + valid_hash, verify, verify_original, }; use crate::files::{Files, LIBRARY_FILES}; use crate::store::storage_key; @@ -208,6 +208,9 @@ pub async fn restore(file: web_sys::File, backend: Backend) -> Result/`, so an unzipped backup is browsable; entries are stored, not compressed; at most 4 GB). **File ▸ Restore Library from Backup…** reads such a zip into a new library folder (`library-restored-