diff --git a/Cargo.lock b/Cargo.lock index afd71a2..2dec069 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -608,6 +619,16 @@ dependencies = [ "libc", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -706,6 +727,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1594,6 +1621,7 @@ dependencies = [ "epaint_default_fonts", "gridcraft-chart", "gridcraft-engine", + "gridcraft-xlsx", "image", "log", "serde", @@ -1626,10 +1654,13 @@ dependencies = [ name = "gridcraft-xlsx" version = "0.5.0" dependencies = [ + "aes", "gridcraft-core", "gridcraft-formula", "gridcraft-model", "quick-xml", + "sha1", + "sha2", "thiserror 2.0.21", "zip", ] @@ -1743,6 +1774,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -3116,6 +3156,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index 1693308..11e4edf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,9 @@ regex = { version = "1", default-features = false, features = ["std", "unicode", quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } sha2 = { version = "0.11", default-features = false } +# Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe). +aes = { version = "0.9", default-features = false } +sha1 = { version = "0.11", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # ui diff --git a/ROADMAP.md b/ROADMAP.md index 0748614..fbe48d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (community merges: worksheet functions 513 / 523, catalog 274 / 290) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (password-encrypted workbooks open, #11) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -32,7 +32,7 @@ This page is the summary. The detail is in: |---|---|---|---| | Full target (ready for real work) | ~50% | ~650–1,050 | Get & Transform and the data model, localization (twelve languages), charts depth, file fidelity (`.xls`, corpus, pass-through), PivotTables, collaboration, mobile | | Mainstream practitioner | ~50% | ~200–310 | File exchange with Excel users (corpus and Excel verification, pass-through, large files, `.xls`, encryption: 63–97 h), common-chart depth (30–45 h), PivotTables (20–30 h), keyboard and interaction (18–28 h), calc correctness and functions (20–33 h) | -| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` and encrypted import 28–42 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) | +| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` import and saving with a password 24–36 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) | Each tier is a subset of the one above, so its hours are too. Calibrated as in [Effort and calibration](docs/target-app-parity.md#effort-and-calibration) (~6–10 h per readiness point for the remaining, verification-heavy @@ -133,6 +133,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | +| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | | 2026-10-11 | Desktop printing opens the print PDF in the system viewer; Save As offers CSV, web page and PDF; macOS File › Properties opens File › Info (#370, #343) | | 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, so a missing or stray `dxcompiler.dll` no longer stops the app at start (#339, #354) | @@ -176,6 +177,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | | 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row | diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 25624cf..2f67466 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -304,19 +304,20 @@ fn main() -> eframe::Result<()> { { log::warn!("sample {s}: {e}"); } - for f in &files { - if let Err(e) = session.execute("file.open", json!({"path": f})) { - log::warn!("{f}: {e}"); - } - } if session.documents().is_empty() { session.new_workbook(); } let mut app = SheetApp::new(session, services()); app.watch_desktop_theme(); - app.after_engine(); // Show warnings from files opened on the command line. + app.after_engine(); // Show warnings from the sample. let control_port = control_port; load_prefs(&mut app); + // Files named on the command line (a double-click on Windows and Linux) open as File › Open + // opens them: a password-protected one asks for its password, and errors are shown, not + // only logged. Each replaces the untouched blank Book1, as a file opened from Finder does. + for f in &files { + app.open_path(f); + } let mut viewport = egui::ViewportBuilder::default() .with_title("GridCraft") .with_inner_size([1440.0, 900.0]) diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b559b83..e8ea91c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -25,4 +25,5 @@ gridcraft-pdf = { workspace = true } image = { workspace = true } [dev-dependencies] +gridcraft-xlsx = { workspace = true, features = ["test-support"] } zip = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d7765d2..3c4a101 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -8,7 +8,7 @@ use crate::DocState; pub fn specs() -> Vec { vec![ cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open), cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), @@ -55,7 +55,8 @@ fn open(s: &mut Session, p: &Json) -> Result { s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({}))); return ok(); }; - let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; + let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted; + let (wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?; let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls)) || matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls); @@ -64,8 +65,10 @@ fn open(s: &mut Session, p: &Json) -> Result { } else { std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string() }; - // An import has no save target: ordinary Save must ask for a new destination. - let path = str_param(p, "path").filter(|_| !imported).map(str::to_string); + // An import has no save target: ordinary Save must ask for a new destination. Neither does a + // workbook decrypted with its password, so Save never replaces the encrypted original with + // an unprotected copy. + let path = str_param(p, "path").filter(|_| !imported && !encrypted).map(str::to_string); // Replace an untouched blank Book1 like Excel does. if s.documents().len() == 1 && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) @@ -73,10 +76,12 @@ fn open(s: &mut Session, p: &Json) -> Result { s.close_document(0); } let i = s.add_document(DocState::new(wb, path, title)); - if !warnings.is_empty() { - let mut message = warnings.iter().take(5).cloned().collect::>().join("\n\n"); - if warnings.len() > 5 { - message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5)); + // The decrypted-workbook notice is for the info bar, not a message box. + let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect(); + if !shown.is_empty() { + let mut message = shown.iter().take(5).copied().collect::>().join("\n\n"); + if shown.len() > 5 { + message.push_str(&format!("\n\n{} additional import warnings.", shown.len() - 5)); } s.ui_requests.push(crate::UiRequest::Message(message)); } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index fd4ee89..e613611 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -38,16 +38,56 @@ impl FileKind { } } +/// The error for an encrypted workbook opened without a password contains this. The interface +/// asks for the password when it sees it; programmatic callers pass `password` instead. +pub const PASSWORD_REQUIRED: &str = "is password-protected. Enter its password to open it."; +/// The error for a wrong password contains this. +pub const WRONG_PASSWORD: &str = "The password is incorrect."; +/// The first open warning of a workbook decrypted with its password. +pub const DECRYPTED_NOTICE: &str = "This workbook was password-protected. GridCraft saves it without the password."; + /// Parses a file's bytes into a workbook. `name` picks the format by extension when the bytes -/// don't say. +/// don't say. An encrypted workbook needs [`open_bytes_with_password`]. pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { + open_bytes_with_password(name, bytes, None) +} + +/// [`open_bytes`], first decrypting a password-protected workbook with `password`. Errors never +/// quote the password. +pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Workbook, Vec)> { + if gridcraft_xlsx::sniff(bytes) != gridcraft_xlsx::Format::Encrypted { + return open_plain(name, bytes); + } + // A scheme no password opens is said straight away, rather than after asking for one. + let decrypt_error = |e: gridcraft_xlsx::DecryptError| EngineError::Other(decrypt_message(name, e)); + let Some(password) = password else { + gridcraft_xlsx::check_encryption(bytes).map_err(decrypt_error)?; + return Err(EngineError::Other(format!("'{name}' {PASSWORD_REQUIRED}"))); + }; + let plain = gridcraft_xlsx::decrypt(bytes, password).map_err(decrypt_error)?; + if gridcraft_xlsx::sniff(&plain) == gridcraft_xlsx::Format::Encrypted { + return Err(EngineError::Other(format!("We can't open '{name}': the encrypted file is damaged (it holds another encrypted file)."))); + } + let (wb, mut warnings) = open_plain(name, &plain)?; + warnings.insert(0, DECRYPTED_NOTICE.into()); + Ok((wb, warnings)) +} + +/// What to tell the user when an encrypted workbook can't be decrypted. Never quotes the password. +fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String { + match e { + gridcraft_xlsx::DecryptError::WrongPassword => format!("{WRONG_PASSWORD} Check that Caps Lock is off and try again."), + gridcraft_xlsx::DecryptError::Unsupported(scheme) => { + format!("'{name}' uses {scheme} encryption, which GridCraft can't open yet. Remove the password in Excel and try again.") + } + gridcraft_xlsx::DecryptError::Malformed(detail) => format!("We can't open '{name}': the encrypted file is damaged ({detail})."), + } +} + +/// Parses bytes that aren't encrypted. +fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { let kind = FileKind::from_path(name); let sniffed = gridcraft_xlsx::sniff(bytes); - if sniffed == gridcraft_xlsx::Format::Encrypted { - return Err(EngineError::Other(format!( - "'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again." - ))); - } if sniffed == gridcraft_xlsx::Format::Xls { let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?; return Ok((wb, report.warnings)); @@ -460,10 +500,28 @@ mod tests { #[test] fn encrypted_workbook_is_reported_not_misread() { - let err = open_bytes("secret.xlsx", &cfb(&["EncryptionInfo", "EncryptedPackage"])).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("password-protected"), "got: {msg}"); + let file = gridcraft_xlsx::testing::encrypt_workbook(b"package", "pw"); + let msg = open_bytes("secret.xlsx", &file).unwrap_err().to_string(); + assert!(msg.contains(PASSWORD_REQUIRED), "got: {msg}"); assert!(!msg.contains("zip"), "must not surface the zip error: {msg}"); + // A compound file naming the streams but unreadable is damage: no password is asked for, + // and none helps. + let file = cfb(&["EncryptionInfo", "EncryptedPackage"]); + for password in [None, Some("pw")] { + let msg = open_bytes_with_password("secret.xlsx", &file, password).unwrap_err().to_string(); + assert!(msg.contains("damaged") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + } + } + + #[test] + fn encryption_no_password_opens_is_reported_without_asking_for_one() { + let info: &[u8] = &[3, 0, 2, 0, 4, 0, 0, 0]; // Standard header flags: RC4, not AES + let file = gridcraft_xlsx::testing::compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); + let msg = open_bytes("rc4.xlsx", &file).unwrap_err().to_string(); + assert!(msg.contains("RC4") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + let irm = gridcraft_xlsx::testing::compound_file(&[("EncryptedPackage", &[0; 8])]); + let msg = open_bytes("irm.xlsx", &irm).unwrap_err().to_string(); + assert!(msg.contains("rights-management") && !msg.contains("damaged"), "got: {msg}"); } #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c5ab249..33fdc11 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2724,3 +2724,74 @@ fn editing_a_date_cell_shows_a_date_not_the_serial() { let d = s2.doc().unwrap(); assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), at), "42"); } + +/// A workbook with 42 in A1, saved as `.xlsx` and encrypted with `password`. +fn encrypted_book(password: &str) -> Vec { + let mut src = s(); + src.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + let xlsx = crate::io::save_bytes(&src.doc().unwrap().wb, "book.xlsx").unwrap(); + gridcraft_xlsx::testing::encrypt_workbook(&xlsx, password) +} + +#[test] +fn encrypted_workbooks_open_with_their_password() { + const PW: &str = "synthetic-pw-5521"; + let b64 = crate::io::base64_encode(&encrypted_book(PW)); + let open = |s: &mut Session, password: Option<&str>| { + let mut p = json!({"name": "Secret.xlsx", "base64": b64}); + if let Some(pw) = password { + p["password"] = json!(pw); + } + s.execute("file.open", p) + }; + let mut s = Session::new(); + + // Programmatic open without a password: an error carrying the marker, never a dialog. + let err = open(&mut s, None).unwrap_err().to_string(); + assert!(err.contains(crate::io::PASSWORD_REQUIRED), "{err}"); + assert!(err.contains("Secret.xlsx"), "{err}"); + assert!(s.take_ui_requests().is_empty(), "programmatic calls never open dialogs"); + assert!(s.documents().is_empty()); + + // Wrong and empty passwords are "incorrect", and the password is never quoted. + for wrong in ["not-it", ""] { + let err = open(&mut s, Some(wrong)).unwrap_err().to_string(); + assert!(err.contains(crate::io::WRONG_PASSWORD), "{err}"); + assert!(wrong.is_empty() || !err.contains(wrong), "{err}"); + } + assert!(s.documents().is_empty()); + + // The right password opens it: no save path, the file name as title, the notice first. + let r = open(&mut s, Some(PW)).unwrap(); + assert_eq!(r["warnings"][0], crate::io::DECRYPTED_NOTICE); + assert_eq!(r["title"], "Secret.xlsx"); + assert_eq!(v(&s, "A1"), Value::Number(42.0)); + assert_eq!(s.doc().unwrap().path, None); + // The notice belongs in the info bar, not a message box. + assert!(!s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Message(_)))); + let journal = serde_json::to_string(&s.journal).unwrap(); + assert!(!journal.contains(PW), "the password never reaches the journal"); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn a_decrypted_workbook_never_overwrites_its_encrypted_original() { + const PW: &str = "synthetic-pw-5522"; + #[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("gridcraft-encrypted-{}-{nonce}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("Secret.xlsx"); + let bytes = encrypted_book(PW); + std::fs::write(&path, &bytes).unwrap(); + + let mut s = s(); + s.execute("file.open", json!({"path": path, "password": PW})).unwrap(); + assert_eq!(s.doc().unwrap().path, None); + s.take_ui_requests(); + s.execute("cell.set", json!({"cell": "A1", "input": "43"})).unwrap(); + s.execute("file.save", json!({})).unwrap(); + assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Dialog(name, _) if name == "saveAs"))); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched"); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index e5847f0..bfce89e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,6 +27,7 @@ zbus = "=5.19.0" [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } epaint_default_fonts = { workspace = true } +gridcraft-xlsx = { workspace = true, features = ["test-support"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # The desktop's preferred language for the first-run interface language. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3ec3f72..6f6280e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -14,7 +14,7 @@ //! - `ui.edit.begin {text?}`, `ui.edit.commit {move?}`, `ui.edit.cancel` //! - `ui.screenshot {path?}` — PNG of the window (base64 when no path); animations are settled //! - `ui.resize {width, height}`, `ui.set {dark?, formulaBar?, ribbonCollapsed?, animations?}` -//! - `app.open {path}`, `app.save {path?}`, `app.quit` +//! - `app.open {path, password?}`, `app.save {path?}`, `app.quit` use std::sync::mpsc::Sender; @@ -351,10 +351,17 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "ui.screenshot" => return Outcome::Screenshot { path: s("path").map(str::to_string) }, "app.open" => { let Some(path) = s("path") else { return err("missing `path`") }; - app.open_path(path); - match app.message.take() { - Some((_, m)) => err(m), - None => ok(json!({"title": app.session.active().map(|d| d.display_title())})), + // Not `open_path`: that asks for a missing password in a dialog, and programmatic + // calls never open one. A password-protected workbook takes `password` instead. + let r = app.run("file.open", json!({"path": path, "password": s("password")})); + if let Ok(r) = &r { + app.after_open(Some(path), r); + } + app.after_engine(); + match (r, app.message.take()) { + (Err(e), _) => err(crate::clean_error(&e)), + (Ok(_), Some((_, m))) => err(m), + (Ok(_), None) => ok(json!({"title": app.session.active().map(|d| d.display_title())})), } } "app.save" => wrap(app.run("file.save", json!({"path": s("path")}))), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c3f0ea6..5e859c6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -236,6 +236,23 @@ impl Dialog { vec![Password { key: "password", label: "Password:" }], json!({"on": false}), ), + "openPassword" => { + // The file rides along in the values (`path` on the desktop, `name` and `base64` + // on the web) and goes back to `file.open` with the password. + let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default(); + let file = std::path::Path::new(&source).file_name().and_then(|n| n.to_str()).unwrap_or("This workbook").to_string(); + // An empty password from the start, so OK on the untouched field says "incorrect" + // rather than asking for the password again. + let mut values = params.clone(); + values["password"] = json!(""); + Dialog::form( + "openPassword", + "Password", + "file.open", + vec![Note(format!("'{file}' is protected.")), Password { key: "password", label: "Password:" }], + values, + ) + } "unprotectSheet" => Dialog::form( "unprotectSheet", "Unprotect Sheet", @@ -1101,12 +1118,20 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { open = false; if d.name == "solver" { app.open_dialog("solverResults", r); + } else if d.name == "openPassword" { + let path = d.values.get("path").and_then(Json::as_str).map(str::to_string); + app.after_open(path.as_deref(), &r); } } // Data Analysis asks before replacing data in its output range. Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => { d.values.insert("askOverwrite".into(), json!(true)); } + // A wrong password: say so, clear the field and stay open for another try. + Err(e) if d.name == "openPassword" => { + d.values.insert("password".into(), json!("")); + app.message = Some((d.title.clone(), crate::clean_error(&e))); + } Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), } } else if confirm { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9ef0d50..60a5afd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -624,17 +624,27 @@ impl SheetApp { pub fn open_path(&mut self, path: &str) { match self.run("file.open", json!({"path": path})) { - Ok(r) => { - self.ui.recent.retain(|p| p != path); - self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(20); - self.show_open_warnings(&r); - } + Ok(r) => self.after_open(Some(path), &r), + // A password-protected workbook: ask for the password; the dialog opens it. + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})), Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); } + /// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent + /// list (for a file on disk) and the open warnings notice. Shared by File › Open, the + /// password dialog and the control channel. + pub fn after_open(&mut self, path: Option<&str>, r: &Json) { + self.backstage = None; + if let Some(path) = path { + self.ui.recent.retain(|p| p != path); + self.ui.recent.insert(0, path.to_string()); + self.ui.recent.truncate(20); + } + self.show_open_warnings(r); + } + /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the /// most important one (a preserved VBA project outranks, say, a skipped optional part). /// Kept deliberately generic: any open-time warning can reach the user this way. @@ -893,12 +903,16 @@ impl SheetApp { .unwrap_or_default(); for (name, bytes) in arrived { let b64 = gridcraft_engine::io::base64_encode(&bytes); - match self.run("file.open", json!({"name": name, "base64": b64})) { + match self.run("file.open", json!({"name": &name, "base64": &b64})) { // A file picked from File › Open (web) arrives here: show it, not the Backstage. Ok(r) => { self.backstage = None; self.show_open_warnings(&r); } + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => { + self.backstage = None; + self.open_dialog("openPassword", json!({"name": name, "base64": b64})); + } Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); diff --git a/crates/ui-egui/tests/open_encrypted.rs b/crates/ui-egui/tests/open_encrypted.rs new file mode 100644 index 0000000..fabbd56 --- /dev/null +++ b/crates/ui-egui/tests/open_encrypted.rs @@ -0,0 +1,172 @@ +//! Opening a password-protected workbook asks for its password (#11). + +use egui::accesskit::Role; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::{Session, core::CellRef, core::Value}; +use gridcraft_ui_egui::{Inbox, Services, SheetApp}; +use serde_json::json; + +const PW: &str = "synthetic-pw-8842"; + +fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, services), + ); + h.run_steps(4); + h +} + +/// A workbook with 42 in A1, saved as `.xlsx` and encrypted with `PW`. +fn encrypted_bytes() -> Vec { + let mut s = Session::new(); + s.new_workbook(); + s.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + let xlsx = gridcraft_engine::io::save_bytes(&s.active().unwrap().wb, "book.xlsx").unwrap(); + gridcraft_xlsx::testing::encrypt_workbook(&xlsx, PW) +} + +/// `encrypted_bytes()` written to `Secret.xlsx` in a fresh temp dir named after `tag`. +fn encrypted_file(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("gridcraft-encrypted-ui-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Secret.xlsx"); + std::fs::write(&path, encrypted_bytes()).unwrap(); + path +} + +fn dialog(h: &egui_kittest::Harness<'static, SheetApp>) -> Option { + h.state().dialog.as_ref().map(|d| d.name().to_string()) +} + +fn a1(h: &egui_kittest::Harness<'static, SheetApp>) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap()) +} + +#[test] +fn opening_an_encrypted_file_asks_for_its_password() { + let path = encrypted_file("desktop"); + let p = path.to_string_lossy().to_string(); + let mut h = harness(Services::default()); + h.state_mut().open_path(&p); + h.run_steps(3); + assert_eq!(dialog(&h).as_deref(), Some("openPassword")); + assert!(h.state().message.is_none(), "no error box: the dialog asks instead"); + assert!(h.query_by_role(Role::PasswordInput).is_some(), "the password is masked"); + + // A wrong password says so, clears the field, and keeps the dialog and its file. + h.get_by_role(Role::PasswordInput).click(); + h.run_steps(2); + h.input_mut().events.push(egui::Event::Text("wrong-guess".into())); + h.run_steps(2); + h.get_by_label(" OK ").click(); + h.run_steps(3); + let msg = h.state().message.as_ref().map(|(_, m)| m.clone()).unwrap_or_default(); + assert!(msg.contains(gridcraft_engine::io::WRONG_PASSWORD), "{msg}"); + assert!(!msg.contains("wrong-guess"), "the password is never quoted: {msg}"); + assert_eq!(dialog(&h).as_deref(), Some("openPassword")); + assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("password")), Some(&json!(""))); + assert_eq!(h.state().dialog.as_ref().and_then(|d| d.values.get("path")), Some(&json!(p))); + h.state_mut().message = None; + + // The right one opens the workbook: no save path, in Recent, with the notice. + h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW)); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + assert_eq!(a1(&h), Value::Number(42.0)); + assert_eq!(h.state().session.active().unwrap().path, None); + assert_eq!(h.state().ui.recent.first(), Some(&p)); + assert!(h.state().notice.as_ref().is_some_and(|n| n.text.contains("password-protected"))); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); +} + +#[test] +fn cancel_leaves_nothing_open() { + let path = encrypted_file("cancel"); + let mut h = harness(Services::default()); + let before = h.state().session.documents().len(); + h.state_mut().open_path(&path.to_string_lossy()); + h.run_steps(3); + assert_eq!(dialog(&h).as_deref(), Some("openPassword")); + h.get_by_label(" Cancel ").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + assert_eq!(h.state().session.documents().len(), before); + assert!(h.state().message.is_none()); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); +} + +#[test] +fn a_file_picked_on_the_web_asks_for_its_password_too() { + let inbox: Inbox = Default::default(); + let mut h = harness(Services { inbox: Some(inbox.clone()), ..Default::default() }); + inbox.lock().unwrap().push(("Secret.xlsx".into(), encrypted_bytes())); + h.run_steps(3); + assert_eq!(dialog(&h).as_deref(), Some("openPassword")); + assert!(h.state().dialog.as_ref().is_some_and(|d| d.values.contains_key("base64"))); + h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW)); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert_eq!(dialog(&h), None); + assert_eq!(a1(&h), Value::Number(42.0)); + assert_eq!(h.state().session.active().unwrap().path, None); +} + +#[test] +fn an_untouched_password_field_is_incorrect_not_required() { + let path = encrypted_file("untouched"); + let mut h = harness(Services::default()); + h.state_mut().open_path(&path.to_string_lossy()); + h.run_steps(3); + h.get_by_label(" OK ").click(); + h.run_steps(3); + let msg = h.state().message.as_ref().map(|(_, m)| m.clone()).unwrap_or_default(); + assert!(msg.contains(gridcraft_engine::io::WRONG_PASSWORD), "{msg}"); + assert_eq!(dialog(&h).as_deref(), Some("openPassword")); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); +} + +#[test] +fn the_backstage_closes_once_the_password_opens_the_workbook() { + let path = encrypted_file("backstage"); + let mut h = harness(Services::default()); + h.state_mut().backstage = Some(gridcraft_ui_egui::backstage::Backstage::new(gridcraft_ui_egui::backstage::Page::Open)); + h.state_mut().open_path(&path.to_string_lossy()); + h.run_steps(3); + h.state_mut().dialog.as_mut().unwrap().values.insert("password".into(), json!(PW)); + h.get_by_label(" OK ").click(); + h.run_steps(3); + assert_eq!(a1(&h), Value::Number(42.0)); + assert!(h.state().backstage.is_none(), "the opened workbook shows, not the Backstage"); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); +} + +#[test] +fn app_open_over_the_control_channel_never_opens_a_dialog() { + use gridcraft_ui_egui::control::{Outcome, handle}; + let path = encrypted_file("control"); + let p = path.to_string_lossy().to_string(); + let mut h = harness(Services::default()); + let ctx = h.ctx.clone(); + let reply = |o: Outcome| match o { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + }; + let r = reply(handle(h.state_mut(), &ctx, "app.open", &json!({"path": p}))); + assert_eq!(r["ok"], json!(false), "{r}"); + assert!(r["error"].as_str().unwrap_or_default().contains(gridcraft_engine::io::PASSWORD_REQUIRED), "{r}"); + assert_eq!(dialog(&h), None, "programmatic calls never open dialogs"); + let r = reply(handle(h.state_mut(), &ctx, "app.open", &json!({"path": p, "password": PW}))); + assert_eq!(r["ok"], json!(true), "{r}"); + assert_eq!(r["result"]["title"], json!("Secret.xlsx")); + assert_eq!(a1(&h), Value::Number(42.0)); + assert_eq!(h.state().ui.recent.first(), Some(&p)); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); +} diff --git a/crates/xlsx/Cargo.toml b/crates/xlsx/Cargo.toml index 8f79447..b53a448 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -8,6 +8,11 @@ description = "GridCraft file formats: XLSX (SpreadsheetML) and CSV import/expor [lints] workspace = true +[features] +# Builds password-encrypted workbooks in code for other crates' tests (a dev-dependency feature; +# never enabled in a shipped build). +test-support = [] + [dependencies] gridcraft-core = { workspace = true } gridcraft-formula = { workspace = true } @@ -15,3 +20,6 @@ gridcraft-model = { workspace = true } thiserror = { workspace = true } quick-xml = { workspace = true } zip = { workspace = true } +aes = { workspace = true } +sha1 = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/xlsx/src/crypto.rs b/crates/xlsx/src/crypto.rs new file mode 100644 index 0000000..46a19d0 --- /dev/null +++ b/crates/xlsx/src/crypto.rs @@ -0,0 +1,469 @@ +//! Password-encrypted workbooks. Excel wraps the ordinary OOXML package in a compound file with +//! two streams: `EncryptionInfo` (how the key comes from the password, and a verifier that tells +//! a wrong password) and `EncryptedPackage` (the package, encrypted with AES). Agile encryption +//! (Excel 2010 onward) and Standard encryption (Excel 2007) are read; RC4, Extensible and +//! certificate-only encryption are reported as unsupported. +//! +//! Clean-room from Microsoft's public [MS-OFFCRYPTO] specification. Every read is +//! bounds-checked, and the costly inputs (spin count, stated sizes) are capped. + +use aes::cipher::{BlockCipherDecrypt, KeyInit}; +use sha2::Digest; + +use crate::cfb::Compound; +use crate::xml::El; + +/// Spin counts above this are refused (the cap sheet-protection hashes use too). +const MAX_SPIN_COUNT: u32 = 10_000_000; +/// Standard encryption always iterates this many times. +const STANDARD_SPIN_COUNT: u32 = 50_000; +/// Agile packages are encrypted in segments of this many bytes, each with its own IV. +pub(crate) const SEGMENT: usize = 4096; +/// AES block size. +const BLOCK: usize = 16; + +/// Agile block keys ([MS-OFFCRYPTO] 2.3.4.13): one per value derived from the password. +pub(crate) const BLOCK_VERIFIER_INPUT: [u8; 8] = [0xFE, 0xA7, 0xD2, 0x76, 0x3B, 0x4B, 0x9E, 0x79]; +pub(crate) const BLOCK_VERIFIER_HASH: [u8; 8] = [0xD7, 0xAA, 0x0F, 0x6D, 0x30, 0x61, 0x34, 0x4E]; +pub(crate) const BLOCK_KEY: [u8; 8] = [0x14, 0x6E, 0x0B, 0xE7, 0xAB, 0xAC, 0xD0, 0xD6]; + +/// The password key encryptor's URI (the other kind is certificate-based). +pub(crate) const PASSWORD_ENCRYPTOR: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password"; + +/// Standard encryption header flags ([MS-OFFCRYPTO] 2.3.1). +pub(crate) const F_CRYPTOAPI: u32 = 0x04; +const F_EXTERNAL: u32 = 0x10; +pub(crate) const F_AES: u32 = 0x20; + +/// Why an encrypted workbook couldn't be decrypted. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum DecryptError { + #[error("the password is incorrect")] + WrongPassword, + /// The scheme, e.g. "RC4" or "Extensible", to complete "uses {0} encryption". + #[error("{0} encryption isn't supported")] + Unsupported(String), + #[error("the encrypted file is damaged ({0})")] + Malformed(String), +} + +fn malformed(m: impl Into) -> DecryptError { + DecryptError::Malformed(m.into()) +} + +/// The plain OOXML package (a zip) inside an encrypted workbook. +pub fn decrypt(bytes: &[u8], password: &str) -> Result, DecryptError> { + let (info, package) = streams(bytes)?; + decrypt_streams(&info, &package, password) +} + +/// Whether the right password could decrypt this encrypted workbook, checked without one. An +/// error names a scheme no password opens (RC4, rights management…) or damage, so callers can +/// say so instead of asking for a password first. +pub fn check_encryption(bytes: &[u8]) -> Result<(), DecryptError> { + let (info, _) = streams(bytes)?; + match scheme(&info)? { + Scheme::Agile(xml) => agile_descriptor(xml).map(|_| ()), + Scheme::Standard(info) => standard_header(info).map(|_| ()), + } +} + +/// The `EncryptionInfo` and `EncryptedPackage` streams. Rights management (IRM) keeps the +/// package but describes its encryption elsewhere, with no `EncryptionInfo`. +fn streams(bytes: &[u8]) -> Result<(Vec, Vec), DecryptError> { + let cf = Compound::open(bytes).map_err(malformed)?; + let info = cf.root_stream("EncryptionInfo").map_err(malformed)?; + let package = cf.root_stream("EncryptedPackage").map_err(malformed)?.ok_or_else(|| malformed("no EncryptedPackage stream"))?; + let info = info.ok_or_else(|| DecryptError::Unsupported("rights-management".into()))?; + Ok((info, package)) +} + +/// Decrypts from the two streams' contents. +pub(crate) fn decrypt_streams(info: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { + match scheme(info)? { + Scheme::Agile(xml) => agile(xml, package, password), + Scheme::Standard(info) => standard(info, package, password), + } +} + +/// The two schemes read, with the part of `EncryptionInfo` each one parses. +enum Scheme<'a> { + /// The XML descriptor after the version and reserved field. + Agile(&'a [u8]), + /// Everything after the version. + Standard(&'a [u8]), +} + +fn scheme(info: &[u8]) -> Result, DecryptError> { + let short = || malformed("truncated EncryptionInfo"); + let major = u16_at(info, 0).ok_or_else(short)?; + let minor = u16_at(info, 2).ok_or_else(short)?; + match (major, minor) { + (4, 4) => Ok(Scheme::Agile(info.get(8..).ok_or_else(short)?)), + (2..=4, 2) => Ok(Scheme::Standard(info.get(4..).ok_or_else(short)?)), + (3 | 4, 3) => Err(DecryptError::Unsupported("Extensible".into())), + _ => Err(DecryptError::Unsupported(format!("version {major}.{minor}"))), + } +} + +fn u16_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?; + Some(u16::from_le_bytes([*s.first()?, *s.get(1)?])) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?])) +} + +/// The plaintext size an `EncryptedPackage` states, and the ciphertext after it. A size larger +/// than the ciphertext is damage, so it never drives an allocation. +fn package_parts(package: &[u8]) -> Result<(usize, &[u8]), DecryptError> { + let head: [u8; 8] = package.get(..8).and_then(|h| h.try_into().ok()).ok_or_else(|| malformed("truncated EncryptedPackage"))?; + let data = package.get(8..).unwrap_or_default(); + let size = usize::try_from(u64::from_le_bytes(head)).unwrap_or(usize::MAX); + if size > data.len() { + return Err(malformed("the package is shorter than its stated size")); + } + Ok((size, data)) +} + +// ---- hashing ------------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Hash { + Sha1, + Sha256, + Sha384, + Sha512, +} + +impl Hash { + fn from_name(name: &str) -> Result { + Ok(match name { + "SHA1" | "SHA-1" => Hash::Sha1, + "SHA256" | "SHA-256" => Hash::Sha256, + "SHA384" | "SHA-384" => Hash::Sha384, + "SHA512" | "SHA-512" => Hash::Sha512, + other => return Err(DecryptError::Unsupported(format!("{other} hash"))), + }) + } + + /// The digest of the concatenated `parts`. + pub(crate) fn digest(self, parts: &[&[u8]]) -> Vec { + fn run(parts: &[&[u8]]) -> Vec { + let mut d = D::new(); + for p in parts { + d.update(p); + } + d.finalize().to_vec() + } + match self { + Hash::Sha1 => run::(parts), + Hash::Sha256 => run::(parts), + Hash::Sha384 => run::(parts), + Hash::Sha512 => run::(parts), + } + } +} + +/// H0 = H(salt ‖ UTF-16LE(password)), then Hn = H(LE32(n - 1) ‖ Hn-1) for `spin` rounds. +pub(crate) fn iterated_hash(hash: Hash, password: &str, salt: &[u8], spin: u32) -> Vec { + let pw: Vec = password.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let mut h = hash.digest(&[salt, &pw]); + for i in 0..spin { + h = hash.digest(&[&i.to_le_bytes(), &h]); + } + h +} + +/// H(a ‖ b), truncated or padded with 0x36 to `len`: Agile's derived keys and IVs. +pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec { + let mut k = hash.digest(&[a, b]); + k.resize(len, 0x36); + k +} + +/// The Standard encryption key ([MS-OFFCRYPTO] 2.3.4.7): SHA-1 iterated 50,000 times, the +/// block-0 hash, then the 0x36 / 0x5C expansion, truncated to `key_len`. +pub(crate) fn standard_key(password: &str, salt: &[u8], key_len: usize) -> Vec { + let h = iterated_hash(Hash::Sha1, password, salt, STANDARD_SPIN_COUNT); + let h = Hash::Sha1.digest(&[&h, &0u32.to_le_bytes()]); + let expand = |pad: u8| { + let mut buf = [pad; 64]; + for (b, x) in buf.iter_mut().zip(&h) { + *b ^= x; + } + Hash::Sha1.digest(&[&buf]) + }; + let mut key = expand(0x36); + key.extend(expand(0x5C)); + key.truncate(key_len); + key +} + +// ---- AES ----------------------------------------------------------------------------------- + +enum Aes { + A128(aes::Aes128), + A192(aes::Aes192), + A256(aes::Aes256), +} + +impl Aes { + fn new(key: &[u8]) -> Result { + let bad = |_| malformed("bad AES key"); + Ok(match key.len() { + 16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(bad)?), + 24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(bad)?), + 32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(bad)?), + n => return Err(malformed(format!("a {n}-byte AES key"))), + }) + } + + fn decrypt_block(&self, block: &mut [u8; BLOCK]) { + let b = aes::Block::cast_from_core_mut(block); + match self { + Aes::A128(c) => c.decrypt_block(b), + Aes::A192(c) => c.decrypt_block(b), + Aes::A256(c) => c.decrypt_block(b), + } + } + + /// ECB: each block on its own. `data` must be whole blocks. + fn decrypt_ecb(&self, data: &mut [u8]) -> Result<(), DecryptError> { + let (blocks, rest) = data.as_chunks_mut::(); + if !rest.is_empty() { + return Err(malformed("ciphertext isn't whole AES blocks")); + } + blocks.iter_mut().for_each(|b| self.decrypt_block(b)); + Ok(()) + } + + /// CBC: each block XORed with the previous ciphertext block (the IV for the first). + fn decrypt_cbc(&self, iv: &[u8], data: &mut [u8]) -> Result<(), DecryptError> { + let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| malformed("an IV that isn't one AES block"))?; + let (blocks, rest) = data.as_chunks_mut::(); + if !rest.is_empty() { + return Err(malformed("ciphertext isn't whole AES blocks")); + } + for b in blocks { + let cipher = *b; + self.decrypt_block(b); + b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p); + prev = cipher; + } + Ok(()) + } +} + +// ---- Standard encryption ------------------------------------------------------------------- + +/// What a Standard `EncryptionInfo` holds: the key size and the password verifier. +struct StandardHeader<'a> { + key_len: usize, + salt: &'a [u8], + verifier: &'a [u8], + verifier_hash: &'a [u8], +} + +/// `info` starts after the version: flags, header size, header, verifier. +fn standard_header(info: &[u8]) -> Result, DecryptError> { + let short = || malformed("truncated Standard encryption header"); + let flags = u32_at(info, 0).ok_or_else(short)?; + if flags & F_EXTERNAL != 0 { + return Err(DecryptError::Unsupported("external".into())); + } + if flags & F_AES == 0 || flags & F_CRYPTOAPI == 0 { + return Err(DecryptError::Unsupported("RC4".into())); + } + let header_size = usize::try_from(u32_at(info, 4).ok_or_else(short)?).map_err(|_| short())?; + let header = info.get(8..8usize.checked_add(header_size).ok_or_else(short)?).ok_or_else(short)?; + let alg_id = u32_at(header, 8).ok_or_else(short)?; + let alg_hash = u32_at(header, 12).ok_or_else(short)?; + let key_bits = u32_at(header, 16).ok_or_else(short)?; + let key_len = match (alg_id, key_bits) { + (0x660E | 0, 128) => 16, + (0x660F, 192) => 24, + (0x6610, 256) => 32, + (0x660E..=0x6610, _) => return Err(malformed(format!("AES with a {key_bits}-bit key"))), + _ => return Err(DecryptError::Unsupported(format!("cipher 0x{alg_id:04X}"))), + }; + if alg_hash != 0x8004 && alg_hash != 0 { + return Err(DecryptError::Unsupported(format!("hash 0x{alg_hash:04X}"))); + } + // Verifier: salt size, salt, encrypted verifier, verifier hash size, encrypted hash. + let v = info.get(8 + header_size..).ok_or_else(short)?; + if u32_at(v, 0) != Some(16) { + return Err(malformed("a Standard salt that isn't 16 bytes")); + } + let salt = v.get(4..20).ok_or_else(short)?; + let verifier = v.get(20..36).ok_or_else(short)?; + let verifier_hash = v.get(40..72).ok_or_else(short)?; + Ok(StandardHeader { key_len, salt, verifier, verifier_hash }) +} + +fn standard(info: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { + let h = standard_header(info)?; + let (mut verifier, mut verifier_hash) = (h.verifier.to_vec(), h.verifier_hash.to_vec()); + let aes = Aes::new(&standard_key(password, h.salt, h.key_len))?; + aes.decrypt_ecb(&mut verifier)?; + aes.decrypt_ecb(&mut verifier_hash)?; + if verifier_hash.get(..20) != Some(Hash::Sha1.digest(&[&verifier]).as_slice()) { + return Err(DecryptError::WrongPassword); + } + let (size, data) = package_parts(package)?; + let mut out = data.get(..size.div_ceil(BLOCK) * BLOCK).ok_or_else(|| malformed("the package isn't whole AES blocks"))?.to_vec(); + aes.decrypt_ecb(&mut out)?; + out.truncate(size); + Ok(out) +} + +// ---- Agile encryption ---------------------------------------------------------------------- + +/// The cipher parameters shared by `keyData` and a password `encryptedKey` element. +pub(crate) struct Params { + pub(crate) hash: Hash, + pub(crate) salt: Vec, + pub(crate) key_len: usize, +} + +impl Params { + fn read(e: &El) -> Result { + let cipher = e.attr("cipherAlgorithm").unwrap_or("AES"); + if cipher != "AES" { + return Err(DecryptError::Unsupported(cipher.into())); + } + let chaining = e.attr("cipherChaining").unwrap_or("ChainingModeCBC"); + if chaining != "ChainingModeCBC" { + return Err(DecryptError::Unsupported(format!("AES {chaining}"))); + } + if e.attr_u32("blockSize") != Some(16) { + return Err(malformed("an AES block size that isn't 16")); + } + let key_len = match e.attr_u32("keyBits") { + Some(128) => 16, + Some(192) => 24, + Some(256) => 32, + other => return Err(malformed(format!("AES with keyBits {}", other.unwrap_or(0)))), + }; + let hash = Hash::from_name(e.attr("hashAlgorithm").unwrap_or("SHA1"))?; + let salt = base64(e.attr("saltValue").ok_or_else(|| malformed("no saltValue"))?)?; + if salt.is_empty() || salt.len() > 64 { + return Err(malformed("a salt that isn't 1 to 64 bytes")); + } + Ok(Params { hash, salt, key_len }) + } + + /// An IV: H(salt ‖ block key) for a block key, else the salt, sized to one AES block. + pub(crate) fn iv(&self, block_key: Option<&[u8]>) -> Vec { + match block_key { + Some(k) => derive(self.hash, &self.salt, k, BLOCK), + None => { + let mut iv = self.salt.clone(); + iv.resize(BLOCK, 0x36); + iv + } + } + } +} + +/// What an Agile descriptor holds: the package's cipher, and the password key encryptor with its +/// three encrypted values (verifier input, verifier hash, package key). +struct AgileDescriptor { + key_data: Params, + key: Params, + spin: u32, + verifier_input: Vec, + verifier_hash: Vec, + key_value: Vec, +} + +/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field. +fn agile_descriptor(xml: &[u8]) -> Result { + let root = crate::xml::parse(xml).map_err(|e| malformed(e.to_string()))?; + let key_data = Params::read(root.child("keyData").ok_or_else(|| malformed("no keyData"))?)?; + let encryptor = root + .child("keyEncryptors") + .and_then(|k| k.kids("keyEncryptor").find(|e| e.attr("uri") == Some(PASSWORD_ENCRYPTOR))) + .ok_or_else(|| DecryptError::Unsupported("certificate".into()))? + .child("encryptedKey") + .ok_or_else(|| malformed("no encryptedKey"))?; + let key = Params::read(encryptor)?; + let spin = encryptor.attr_u32("spinCount").ok_or_else(|| malformed("no spinCount"))?; + if spin > MAX_SPIN_COUNT { + return Err(malformed(format!("a spin count of {spin}, more than the {MAX_SPIN_COUNT} allowed"))); + } + let field = |name: &str| base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?); + Ok(AgileDescriptor { + key_data, + key, + spin, + verifier_input: field("encryptedVerifierHashInput")?, + verifier_hash: field("encryptedVerifierHashValue")?, + key_value: field("encryptedKeyValue")?, + }) +} + +fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { + let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value } = agile_descriptor(xml)?; + let h = iterated_hash(key.hash, password, &key.salt, spin); + let iv = key.iv(None); + let field = |data: &[u8], block_key: &[u8]| -> Result, DecryptError> { + let mut data = data.to_vec(); + Aes::new(&derive(key.hash, &h, block_key, key.key_len))?.decrypt_cbc(&iv, &mut data)?; + Ok(data) + }; + let input = field(&verifier_input, &BLOCK_VERIFIER_INPUT)?; + let expected = field(&verifier_hash, &BLOCK_VERIFIER_HASH)?; + let input = input.get(..key.salt.len()).unwrap_or(&input); + let actual = key.hash.digest(&[input]); + if expected.get(..actual.len()) != Some(actual.as_slice()) { + return Err(DecryptError::WrongPassword); + } + let secret = field(&key_value, &BLOCK_KEY)?; + let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?; + let (size, data) = package_parts(package)?; + let mut out = Vec::with_capacity(size); + for (i, segment) in data.chunks(SEGMENT).enumerate() { + if out.len() >= size { + break; + } + let index = u32::try_from(i).map_err(|_| malformed("too many segments"))?; + let start = out.len(); + out.extend_from_slice(segment); + let block = out.get_mut(start..).unwrap_or_default(); + aes.decrypt_cbc(&key_data.iv(Some(&index.to_le_bytes())), block)?; + } + out.truncate(size); + Ok(out) +} + +// ---- base64 -------------------------------------------------------------------------------- + +/// Standard base64 (whitespace ignored), as Agile's XML uses for every binary value. +fn base64(s: &str) -> Result, DecryptError> { + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let (mut buf, mut bits) = (0u32, 0u32); + for c in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return Err(malformed("invalid base64")), + }; + buf = (buf << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + Ok(out) +} + +#[cfg(test)] +mod tests; diff --git a/crates/xlsx/src/crypto/tests.rs b/crates/xlsx/src/crypto/tests.rs new file mode 100644 index 0000000..0fb0b92 --- /dev/null +++ b/crates/xlsx/src/crypto/tests.rs @@ -0,0 +1,157 @@ +//! Decryption tests. The expected keys and IVs below were computed independently with +//! msoffcrypto-tool 6.0.0 (MIT) for the synthetic password and salts used here; the encrypted +//! files are built in code by `crate::testing`. No Excel-produced file is used. + +use super::*; +use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header}; + +const PASSWORD: &str = "Pass1234"; + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +fn salt() -> Vec { + (0u8..16).collect() +} + +#[test] +fn aes_decrypts_the_fips_197_vectors() { + let mut b: [u8; 16] = hex("69c4e0d86a7b0430d8cdb78070b4c55a").try_into().unwrap(); + Aes::new(&hex("000102030405060708090a0b0c0d0e0f")).unwrap().decrypt_block(&mut b); + assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff")); + let mut b: [u8; 16] = hex("8ea2b7ca516745bfeafc49904b496089").try_into().unwrap(); + Aes::new(&hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")).unwrap().decrypt_block(&mut b); + assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff")); +} + +#[test] +fn agile_keys_match_msoffcrypto() { + let h = iterated_hash(Hash::Sha512, PASSWORD, &salt(), 1000); + assert_eq!(derive(Hash::Sha512, &h, &BLOCK_VERIFIER_INPUT, 32), hex("c57def04938e1e83497271f6ec099e890bfd2bd9cf05ff208f8b2405df361ae6")); + assert_eq!(derive(Hash::Sha512, &h, &BLOCK_KEY, 32), hex("4ab08cf2ed95199d58d04f19331049a8a07cc1ff508431a1dd0785c84e261d03")); + let h = iterated_hash(Hash::Sha1, PASSWORD, &salt(), 1000); + assert_eq!(derive(Hash::Sha1, &h, &BLOCK_VERIFIER_INPUT, 16), hex("88c7567d028eae33a9a55f65729dcd2b")); + let key_data = Params { hash: Hash::Sha512, salt: (16u8..32).collect(), key_len: 32 }; + assert_eq!(key_data.iv(Some(&1u32.to_le_bytes())), hex("da22ccba062aff577d207b2dee4e165b")); +} + +#[test] +fn agile_pads_short_hashes_with_0x36() { + // SHA-1 gives 20 bytes; an AES-256 key needs 32, so 12 bytes of 0x36 follow. + let k = derive(Hash::Sha1, b"a", b"b", 32); + assert_eq!(k[..20], Hash::Sha1.digest(&[b"ab"])[..]); + assert_eq!(k[20..], [0x36; 12]); +} + +#[test] +fn standard_keys_match_msoffcrypto() { + assert_eq!(standard_key(PASSWORD, &salt(), 16), hex("ae0b402aeb23a779471a130843e431d4")); + assert_eq!(standard_key(PASSWORD, &salt(), 32), hex("ae0b402aeb23a779471a130843e431d47b416314b5146b491cb815283663d26f")); +} + +#[test] +fn base64_decodes_and_rejects_junk() { + assert_eq!(base64("AAECAwQFBgcICQoLDA0ODw==").unwrap(), salt()); + assert_eq!(base64(" AAEC\nAw== ").unwrap(), vec![0, 1, 2, 3]); + assert!(matches!(base64("AA*C"), Err(DecryptError::Malformed(_)))); +} + +/// A payload that isn't a whole number of segments or blocks. +fn payload(len: usize) -> Vec { + (0..len).map(|i| (i * 7 + i / 251) as u8).collect() +} + +#[test] +fn agile_round_trips() { + let plain = payload(10_000); + let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); + assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain); +} + +#[test] +fn standard_round_trips() { + let plain = payload(10_000); + let (info, package) = encrypt_standard(&plain, PASSWORD); + assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain); +} + +#[test] +fn non_ascii_passwords_round_trip() { + // UTF-16LE, surrogate pairs included: "Pässwörd€" plus an emoji outside the BMP. + let pw = "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}"; + let (info, package) = encrypt_agile(&payload(100), pw, 1000); + assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100)); + assert_eq!(decrypt_streams(&info, &package, "Passwrd"), Err(DecryptError::WrongPassword)); + let (info, package) = encrypt_standard(&payload(100), pw); + assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100)); +} + +#[test] +fn a_wrong_password_is_reported_as_such() { + let (info, package) = encrypt_agile(&payload(100), PASSWORD, 1000); + assert_eq!(decrypt_streams(&info, &package, "pass1234"), Err(DecryptError::WrongPassword)); + assert_eq!(decrypt_streams(&info, &package, ""), Err(DecryptError::WrongPassword)); + let (info, package) = encrypt_standard(&payload(100), PASSWORD); + assert_eq!(decrypt_streams(&info, &package, "Pass12345"), Err(DecryptError::WrongPassword)); +} + +#[test] +fn unsupported_schemes_are_named() { + let unsupported = |info: &[u8]| match decrypt_streams(info, &[0; 8], PASSWORD) { + Err(DecryptError::Unsupported(s)) => s, + other => panic!("expected Unsupported, got {other:?}"), + }; + let (mut rc4, _) = encrypt_standard(&payload(10), PASSWORD); + rc4[4..8].copy_from_slice(&F_CRYPTOAPI.to_le_bytes()); // fCryptoAPI without fAES + assert_eq!(unsupported(&rc4), "RC4"); + assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible"); + assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1"); + let des = agile_info(PASSWORD, 1000).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\""); + assert_eq!(unsupported(&with_agile_header(&des)), "3DES"); + let cert = agile_info(PASSWORD, 1000).replace("keyEncryptor/password", "keyEncryptor/certificate"); + assert_eq!(unsupported(&with_agile_header(&cert)), "certificate"); +} + +#[test] +fn damaged_files_are_malformed_not_panics() { + let is_malformed = |r: Result, DecryptError>| matches!(r, Err(DecryptError::Malformed(_))); + let plain = payload(5000); + for (info, package) in [encrypt_agile(&plain, PASSWORD, 1000), encrypt_standard(&plain, PASSWORD)] { + // Every truncation of either stream. + for cut in (0..info.len()).step_by(7) { + let _ = decrypt_streams(&info[..cut], &package, PASSWORD); + } + for cut in (0..package.len()).step_by(97) { + let _ = decrypt_streams(&info, &package[..cut], PASSWORD); + } + // A stated size past the ciphertext never drives an allocation. + let mut huge = package.clone(); + huge[..8].copy_from_slice(&u64::MAX.to_le_bytes()); + assert!(is_malformed(decrypt_streams(&info, &huge, PASSWORD))); + // Ciphertext that isn't whole AES blocks. + let mut ragged = package.clone(); + ragged.truncate(ragged.len() - 3); + let len = (ragged.len() - 8) as u64; + ragged[..8].copy_from_slice(&len.to_le_bytes()); + assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD))); + } + let xml = agile_info(PASSWORD, 1000); + let bad = |s: String| is_malformed(decrypt_streams(&with_agile_header(&s), &[0; 8], PASSWORD)); + assert!(bad(xml.replace("spinCount=\"1000\"", "spinCount=\"4000000000\""))); + assert!(bad(xml.replace("saltValue=\"", "saltValue=\"*"))); + assert!(bad(xml.replace("keyBits=\"256\"", "keyBits=\"100\""))); + assert!(bad(xml.replace("blockSize=\"16\"", "blockSize=\"8\""))); + assert!(bad(xml.replace(" Vec { + let (info, encrypted) = encrypt_agile(package, password, SPIN_COUNT); + compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)]) +} + +/// The same with Standard encryption (AES-128, SHA-1), as Excel 2007 wrote it. +pub fn encrypt_workbook_standard(package: &[u8], password: &str) -> Vec { + let (info, encrypted) = encrypt_standard(package, password); + compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)]) +} + +/// A version 3 compound file (512-byte sectors) holding `streams` under the root, laid out as +/// Excel lays it out: streams under 4096 bytes in the mini stream, larger ones in regular +/// sectors. Up to 109 FAT sectors (about 7 MB of streams). +pub fn compound_file(streams: &[(&str, &[u8])]) -> Vec { + const SECTOR: usize = 512; + const MINI: usize = 64; + const CUTOFF: usize = 4096; + const FREE: u32 = 0xFFFF_FFFF; + const END: u32 = 0xFFFF_FFFE; + const FAT_SECTOR: u32 = 0xFFFF_FFFD; + let is_mini = |d: &[u8]| d.len() < CUTOFF; + + // The mini stream: small streams in 64-byte mini sectors, chained by the mini FAT. + let mut container = vec![]; + let mut mini_fat: Vec = vec![]; + let mut mini_starts = vec![]; + for (_, d) in streams { + if !is_mini(d) || d.is_empty() { + mini_starts.push(END); + continue; + } + let first = mini_fat.len(); + let n = d.len().div_ceil(MINI); + for k in 0..n { + mini_fat.push(if k + 1 == n { END } else { (first + k + 1) as u32 }); + } + mini_starts.push(first as u32); + container.extend_from_slice(d); + container.resize((first + n) * MINI, 0); + } + + // Regular-sector blobs, in order: directory, mini FAT, mini stream, then each large stream. + let dir_len = (streams.len() + 1).div_ceil(4) * SECTOR; + let mut blobs: Vec> = vec![vec![0; dir_len], mini_fat.iter().flat_map(|v| v.to_le_bytes()).collect(), container.clone()]; + blobs.extend(streams.iter().map(|(_, d)| if is_mini(d) { vec![] } else { d.to_vec() })); + let counts: Vec = blobs.iter().map(|b| b.len().div_ceil(SECTOR)).collect(); + let body: usize = counts.iter().sum(); + let mut fat_sectors = 1; + while fat_sectors * 128 < fat_sectors + body { + fat_sectors += 1; + } + assert!(fat_sectors <= 109, "the test writer has no DIFAT sectors"); + let mut fat = vec![FREE; fat_sectors * 128]; + fat[..fat_sectors].fill(FAT_SECTOR); + let mut starts = vec![]; + let mut next = fat_sectors; + for &n in &counts { + if n == 0 { + starts.push(END); + continue; + } + starts.push(next as u32); + for k in 0..n { + fat[next + k] = if k + 1 == n { END } else { (next + k + 1) as u32 }; + } + next += n; + } + + let put16 = |b: &mut [u8], at: usize, v: u16| b[at..at + 2].copy_from_slice(&v.to_le_bytes()); + let put32 = |b: &mut [u8], at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes()); + // Directory: the root (owning the mini stream), then the streams chained as right siblings. + let dir = &mut blobs[0]; + for k in 0..dir_len / 128 { + for at in [68, 72, 76] { + put32(dir, k * 128 + at, FREE); + } + } + let mut entry = |k: usize, name: &str, kind: u8, start: u32, size: usize| { + let e = k * 128; + let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); + dir[e..e + units.len()].copy_from_slice(&units); + put16(dir, e + 64, units.len() as u16 + 2); + dir[e + 66] = kind; + dir[e + 67] = 1; // black + if kind == 5 && !streams.is_empty() { + put32(dir, e + 76, 1); + } + if kind == 2 && k < streams.len() { + put32(dir, e + 72, k as u32 + 1); + } + put32(dir, e + 116, start); + put32(dir, e + 120, size as u32); + }; + entry(0, "Root Entry", 5, starts[2], container.len()); + for (k, (name, data)) in streams.iter().enumerate() { + let start = if is_mini(data) { mini_starts[k] } else { starts[3 + k] }; + entry(k + 1, name, 2, start, data.len()); + } + + let mut out = vec![0u8; SECTOR * (1 + fat_sectors + body)]; + out[..8].copy_from_slice(&crate::cfb::SIGNATURE); + put16(&mut out, 24, 0x3E); // minor version + put16(&mut out, 26, 3); // major version + put16(&mut out, 28, 0xFFFE); // byte order + put16(&mut out, 30, 9); // 512-byte sectors + put16(&mut out, 32, 6); // 64-byte mini sectors + put32(&mut out, 44, fat_sectors as u32); + put32(&mut out, 48, starts[0]); + put32(&mut out, 56, CUTOFF as u32); + put32(&mut out, 60, starts[1]); // first mini FAT sector (END when there is none) + put32(&mut out, 64, counts[1] as u32); + put32(&mut out, 68, END); // no DIFAT sectors + for i in 0..109 { + put32(&mut out, 76 + i * 4, if i < fat_sectors { i as u32 } else { FREE }); + } + for (i, v) in fat.iter().enumerate() { + put32(&mut out, SECTOR + i * 4, *v); + } + for (blob, start) in blobs.iter().zip(&starts) { + if !blob.is_empty() { + let at = SECTOR * (1 + *start as usize); + out[at..at + blob.len()].copy_from_slice(blob); + } + } + out +} + +fn encrypt_block(key: &[u8], block: &mut [u8; 16]) { + let b = aes::Block::cast_from_core_mut(block); + match key.len() { + 16 => aes::Aes128::new_from_slice(key).unwrap().encrypt_block(b), + 32 => aes::Aes256::new_from_slice(key).unwrap().encrypt_block(b), + n => panic!("no {n}-byte AES in the test encryptor"), + } +} + +fn pad(data: &[u8]) -> Vec { + let mut v = data.to_vec(); + v.resize(data.len().div_ceil(16) * 16, 0); + v +} + +fn encrypt_ecb(key: &[u8], data: &[u8]) -> Vec { + let mut v = pad(data); + v.as_chunks_mut::<16>().0.iter_mut().for_each(|b| encrypt_block(key, b)); + v +} + +fn encrypt_cbc(key: &[u8], iv: &[u8], data: &[u8]) -> Vec { + let mut v = pad(data); + let mut prev: [u8; 16] = iv.try_into().unwrap(); + for b in v.as_chunks_mut::<16>().0 { + b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p); + encrypt_block(key, b); + prev = *b; + } + v +} + +fn base64_encode(b: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::new(); + for c in b.chunks(3) { + let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0)); + for i in 0..4 { + s.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' }); + } + } + s +} + +fn package(size: usize, body: Vec) -> Vec { + let mut p = (size as u64).to_le_bytes().to_vec(); + p.extend(body); + p +} + +/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16. +pub(crate) fn agile_info(password: &str, spin: u32) -> String { + let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 }; + let h = iterated_hash(Hash::Sha512, password, &key.salt, spin); + let iv = key.iv(None); + let field = |block_key: &[u8], data: &[u8]| base64_encode(&encrypt_cbc(&derive(Hash::Sha512, &h, block_key, 32), &iv, data)); + let input = field(&BLOCK_VERIFIER_INPUT, &VERIFIER); + let value = field(&BLOCK_VERIFIER_HASH, &Hash::Sha512.digest(&[&VERIFIER])); + let secret = field(&BLOCK_KEY, &SECRET_KEY); + let params = |salt: &[u8]| { + format!( + "saltSize=\"16\" blockSize=\"16\" keyBits=\"256\" hashSize=\"64\" cipherAlgorithm=\"AES\" cipherChaining=\"ChainingModeCBC\" hashAlgorithm=\"SHA512\" saltValue=\"{}\"", + base64_encode(salt) + ) + }; + format!( + "\r\n", + params(&KEY_DATA_SALT), + params(&PASSWORD_SALT), + ) +} + +/// `EncryptionInfo` for an Agile descriptor: version 4.4, reserved 0x40, then the XML. +pub(crate) fn with_agile_header(xml: &str) -> Vec { + let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0]; + info.extend_from_slice(xml.as_bytes()); + info +} + +/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512). +pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec, Vec) { + let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 }; + let mut body = vec![]; + for (i, segment) in plain.chunks(SEGMENT).enumerate() { + body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment)); + } + (with_agile_header(&agile_info(password, spin)), package(plain.len(), body)) +} + +/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1). +pub(crate) fn encrypt_standard(plain: &[u8], password: &str) -> (Vec, Vec) { + let key = standard_key(password, &PASSWORD_SALT, 16); + let mut header = vec![]; + for v in [F_CRYPTOAPI | F_AES, 0, 0x660E, 0x8004, 128, 0x18, 0, 0] { + header.extend_from_slice(&u32::to_le_bytes(v)); + } + header.extend("\0".encode_utf16().flat_map(u16::to_le_bytes)); + let mut info = vec![4, 0, 2, 0]; + info.extend_from_slice(&(F_CRYPTOAPI | F_AES).to_le_bytes()); + info.extend_from_slice(&(header.len() as u32).to_le_bytes()); + info.extend(header); + info.extend_from_slice(&16u32.to_le_bytes()); + info.extend(PASSWORD_SALT); + info.extend(encrypt_ecb(&key, &VERIFIER)); + info.extend_from_slice(&20u32.to_le_bytes()); + info.extend(encrypt_ecb(&key, &Hash::Sha1.digest(&[&VERIFIER]))); + (info, package(plain.len(), encrypt_ecb(&key, plain))) +} diff --git a/crates/xlsx/src/tests/encrypted_tests.rs b/crates/xlsx/src/tests/encrypted_tests.rs new file mode 100644 index 0000000..7a1a9bb --- /dev/null +++ b/crates/xlsx/src/tests/encrypted_tests.rs @@ -0,0 +1,66 @@ +//! Encrypted workbooks end to end: compound file → streams → plain package. + +use crate::testing::{compound_file, encrypt_workbook, encrypt_workbook_standard}; +use crate::{DecryptError, decrypt}; + +#[test] +fn the_test_compound_writer_reads_back() { + let big: Vec = (0..3_000_000u32).map(|i| (i % 253) as u8).collect(); + let mid: Vec = (0..4095u32).map(|i| i as u8).collect(); + let file = compound_file(&[("Small", b"hello"), ("Big", &big), ("Empty", b""), ("Mid", &mid)]); + let cf = crate::cfb::Compound::open(&file).unwrap(); + assert_eq!(cf.root_stream("Mid").unwrap().unwrap(), mid, "just under the cutoff: the mini stream"); + assert_eq!(cf.root_stream("Small").unwrap().unwrap(), b"hello"); + assert_eq!(cf.root_stream("big").unwrap().unwrap(), big); + assert_eq!(cf.root_stream("Empty").unwrap().unwrap(), b""); + assert_eq!(cf.root_stream("Missing").unwrap(), None); +} + +#[test] +fn encrypted_files_decrypt_to_the_package() { + let package: Vec = (0..3_000_000u32).map(|i| (i * 31 % 251) as u8).collect(); + let agile = encrypt_workbook(&package, "Pass1234"); + assert!(crate::cfb::is_encrypted(&agile)); + assert_eq!(decrypt(&agile, "Pass1234").unwrap(), package); + assert_eq!(decrypt(&agile, "wrong"), Err(DecryptError::WrongPassword)); + let standard = encrypt_workbook_standard(&package, "Pass1234"); + assert_eq!(decrypt(&standard, "Pass1234").unwrap(), package); +} + +#[test] +fn a_compound_file_without_the_streams_is_malformed() { + let file = compound_file(&[("EncryptionInfo", &[4, 0, 4, 0])]); + assert!(matches!(decrypt(&file, "x"), Err(DecryptError::Malformed(_)))); + assert!(matches!(decrypt(b"not a compound file", "x"), Err(DecryptError::Malformed(_)))); +} + +#[test] +fn a_gridcraft_workbook_survives_encryption() { + let b2 = gridcraft_core::CellRef::parse("B2").unwrap(); + let mut wb = gridcraft_model::Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(b2, gridcraft_core::Value::Number(42.0)); + let xlsx = crate::write_xlsx(&wb).unwrap(); + let file = encrypt_workbook(&xlsx, "Pass1234"); + assert_eq!(crate::sniff(&file), crate::Format::Encrypted); + let (back, _) = crate::read_xlsx(&decrypt(&file, "Pass1234").unwrap()).unwrap(); + assert_eq!(back.active().unwrap().value(b2), gridcraft_core::Value::Number(42.0)); +} + +#[test] +fn schemes_no_password_opens_are_named_before_asking_for_one() { + use crate::check_encryption; + use crate::testing::{agile_info, with_agile_header}; + let unsupported = |s: &str| Err(DecryptError::Unsupported(s.into())); + assert_eq!(check_encryption(&encrypt_workbook(b"package", "pw")), Ok(())); + assert_eq!(check_encryption(&encrypt_workbook_standard(b"package", "pw")), Ok(())); + let file = |info: &[u8]| compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); + assert_eq!(check_encryption(&file(&[3, 0, 2, 0, 4, 0, 0, 0])), unsupported("RC4")); + assert_eq!(check_encryption(&file(&[4, 0, 3, 0, 0, 0, 0, 0])), unsupported("Extensible")); + let cert = agile_info("pw", 1000).replace("keyEncryptor/password", "keyEncryptor/certificate"); + assert_eq!(check_encryption(&file(&with_agile_header(&cert))), unsupported("certificate")); + assert!(matches!(check_encryption(&file(&[4, 0])), Err(DecryptError::Malformed(_)))); + // Rights management (IRM) keeps an EncryptedPackage but no EncryptionInfo. + let irm = compound_file(&[("EncryptedPackage", &[0; 8])]); + assert_eq!(check_encryption(&irm), unsupported("rights-management")); + assert_eq!(decrypt(&irm, "pw"), Err(DecryptError::Unsupported("rights-management".into()))); +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index f266bfa..d5d2f2f 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -2,6 +2,7 @@ mod array_export; mod csv_tests; +mod encrypted_tests; mod fixtures; mod malformed; mod ods_tests; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c142ebf..a16208a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -36,7 +36,7 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state | | `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?, animations?}` | UI preferences (`formulaBar` runs `view.formulaBar`) | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | -| `app.open` / `app.save` / `app.quit` | `{path}` / `{path?}` | Files and lifetime | +| `app.open` / `app.save` / `app.quit` | `{path, password?}` / `{path?}` | Files and lifetime. `password` opens a password-protected workbook; without it, such a workbook returns an error (never a dialog) | Any other method name that is a command id (e.g. `home.bold`) runs that command. @@ -56,5 +56,12 @@ the View ▸ Animations checkbox, saved with the other UI preferences and on by document text. The choice is saved in `ui.json`; with nothing saved the desktop app follows the system language. +`{command: "file.open", params: {path: "Secret.xlsx", password: "…"}}` opens a password-protected +workbook (Agile or Standard encryption). Without `password` it returns an error containing +"is password-protected. Enter its password to open it." and never opens a dialog; a wrong +password returns "The password is incorrect." The password is left out of the journal and never +quoted in an error. The opened workbook has no save path, so Save asks where to save it, without +the password, and never replaces the encrypted original. + For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`; see [`mcp.md`](mcp.md) and [`cli.md`](cli.md). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6ac9bf7..8510659 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -27,7 +27,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and | `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — | | `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — | | `.ods` OpenDocument Spreadsheet | R/W | **values only** (#100) | no | Sheets, values, dates, merges, repeated cells, cached formula results; no formulas, formatting, charts; encrypted ODS refused | `ods_tests.rs` | -| Password-encrypted workbooks (Agile/Standard encryption) | R/W | **no**; recognised and refused with a clear message since #17 | no | Common in business; needs AES decryption on top of the new CFB reader (`crates/xlsx/src/cfb.rs`) | — | +| Password-encrypted workbooks (Agile/Standard encryption) | R | **yes** (#11): asks for the password on open; Agile (AES-128/192/256, SHA-1/256/384/512) and Standard (AES, SHA-1) | no (opens with no save path; Save As writes it without the password) | Clean-room from [MS-OFFCRYPTO] on the CFB reader. RC4, Extensible and certificate-only encryption refused with a message. Data-integrity HMAC not checked | `crypto/tests.rs`, `encrypted_tests.rs` | | `.xlw` Excel 4.0 workbook, `.wk*`, `.dbf` | open only (some) | no | no | Rare | — | ## Text and data formats @@ -61,7 +61,7 @@ Server, Access, ODBC, …), web pages, SharePoint and online services, through P GridCraft imports CSV/TSV, JSON and HTML tables only, with no query editor, no connections and no refresh. Counted under features (Get & Transform, ~10% ready), not here. -## Work (80–120 h) +## Work (76–114 h) | Work | Hours | |---|---| @@ -69,7 +69,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 | | Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 | | Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 | -| Encrypted workbooks: open with password prompt, save with password (#11) | 8–12 | +| Encrypted workbooks: save with password (#11) | 4–6 | | XLSB and ODS: formulas and formatting on import; then write | 10–15 | | CSV/text: encoding choice, `.prn`, `.dif`, `.slk`, XML Spreadsheet 2003 | 4–7 | | PDF: embed subsetted system fonts so every script exports | (counted in [localization](localization-parity.md) script work) | @@ -82,6 +82,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` | | 2026-10-11 | minor | CSV/TSV export writes displayed values (number formats applied, no `#####`); `.xltx`, `.xltm` and `.xlsm` saved with their own workbook content types | | 2026-10-11 | minor | Table AutoFilter criteria are read and written (they were dropped, leaving only the filter button), and `iconFilter` criteria are kept on table and sheet AutoFilters | +| 2026-10-11 | minor | Password-encrypted workbooks open with their password (Agile and Standard encryption, #11) | | 2026-10-11 | minor | `.xls` (BIFF8) import: values, formulas, number formats, merges, sizes, names (#121) | | 2026-10-11 | minor | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls, OLE objects, sheet backgrounds kept on save (#330, #324) | | 2026-10-11 | minor | XLSX: threaded comments and persons parts written (they came back as notes before); note visibility read from VML (#300) | diff --git a/docs/gaps.md b/docs/gaps.md index 1df011f..09870ba 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -66,14 +66,16 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Impact:** wrong numbers are worse than missing features. - **Estimate:** 10–15 h. [function-parity.md](function-parity.md#formula-language) -### 7. Legacy `.xls` only partly read; no password-protected workbooks (FF) +### 7. Legacy `.xls` only partly read; encrypted workbooks can't be saved with a password (FF) - **Evidence:** #121: Excel 97-2003 (BIFF8) `.xls` files now open (values, formulas, number formats, merges, sizes, names); fonts, fills, borders, charts, pictures, comments, conditional - formats, validation and BIFF5 files are not read, and `.xls` is never written. Encrypted - workbooks (OOXML and `.xls`) are recognised and refused with a clear message since #17 (closed #11). + formats, validation and BIFF5 files are not read, and `.xls` is never written. Password-encrypted + `.xlsx` workbooks (Agile and Standard encryption) open with their password since #11, but are + saved without it: GridCraft can't write an encrypted file yet. Encrypted `.xls` (FILEPASS) and + RC4-encrypted files are still refused with a message. - **Impact:** common in finance, government and anything older than 2007; encrypted files are routine in business. -- **Estimate:** 18–27 h (`.xls` styles and objects 10–15, encryption 8–12). [file-format-parity.md](file-format-parity.md) +- **Estimate:** 14–21 h (`.xls` styles and objects 10–15, encryption 4–6). [file-format-parity.md](file-format-parity.md) ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, @@ -161,6 +163,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | | 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | | 2026-10-11 | minor | §4: trendlines (six types, forecast, equation and R²) read, written and drawn | +| 2026-10-11 | minor | §7: password-encrypted workbooks open with their password; saving with one remains (#11) | | 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) | | 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) | | 2026-10-11 | minor | §3: XLSX pass-through keeps custom XML, external links, connections, query tables, printer settings, controls and OLE objects (#330, #324) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d6dbf7e..8b82092 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -29,7 +29,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). |---|---|---|---| | Full target (ready for real work) | ~50% | ~650–1,050 | Get & Transform and the data model, localization (twelve languages), charts depth, file fidelity (`.xls`, corpus, pass-through), PivotTables, collaboration, mobile | | Mainstream practitioner | ~50% | ~200–310 | File exchange with Excel users (corpus and Excel verification, pass-through, large files, `.xls`, encryption: 63–97 h), common-chart depth (30–45 h), PivotTables (20–30 h), keyboard and interaction (18–28 h), calc correctness and functions (20–33 h) | -| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` and encrypted import 28–42 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) | +| Essentials user | ~63% | ~60–100 | Opening files people send (`.xls` import and saving with a password 24–36 h; macOS Finder open 2–4 h), launch stability (6–10 h), basic chart resize and polish (6–10 h), print and Page Layout (8–12 h), small editing and UI fixes (10–16 h) | Each tier is a subset of the one above, so its hours are too. Calibrated as in [Effort and calibration](#effort-and-calibration) (~6–10 h per readiness point for the remaining, verification-heavy @@ -333,6 +333,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| | 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | +| 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | | 2026-10-11 | minor | Worksheet functions 503 / 523 (96.2%; 494 working): PERCENTOF, EUROCONVERT | | 2026-10-11 | minor | Worksheet functions 502 / 523 (96.0%; 493 working): BAHTTEXT |