Merge pull request #436 from LloydNicholson/feat/open-encrypted

Open password-encrypted workbooks (Agile and Standard encryption) (#11)

# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
#	docs/gaps.md
#	docs/target-app-parity.md
This commit is contained in:
Brandon Thomas 2026-10-11 18:06:04 -04:00
commit a34fe4a7af
24 changed files with 1437 additions and 47 deletions

51
Cargo.lock generated
View File

@ -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"

View File

@ -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

View File

@ -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 |

View File

@ -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])

View File

@ -25,4 +25,5 @@ gridcraft-pdf = { workspace = true }
image = { workspace = true }
[dev-dependencies]
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
zip = { workspace = true }

View File

@ -8,7 +8,7 @@ use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
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<Json> {
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<Json> {
} 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<Json> {
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::<Vec<_>>().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::<Vec<_>>().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));
}

View File

@ -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<String>)> {
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<String>)> {
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<String>)> {
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]

View File

@ -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<u8> {
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();
}

View File

@ -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.

View File

@ -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")}))),

View File

@ -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 {

View File

@ -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();

View File

@ -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<u8> {
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<String> {
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();
}

View File

@ -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 }

469
crates/xlsx/src/crypto.rs Normal file
View File

@ -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<String>) -> DecryptError {
DecryptError::Malformed(m.into())
}
/// The plain OOXML package (a zip) inside an encrypted workbook.
pub fn decrypt(bytes: &[u8], password: &str) -> Result<Vec<u8>, 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<u8>, Vec<u8>), 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<Vec<u8>, 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<Scheme<'_>, 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<u16> {
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<u32> {
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<Hash, DecryptError> {
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<u8> {
fn run<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
let mut d = D::new();
for p in parts {
d.update(p);
}
d.finalize().to_vec()
}
match self {
Hash::Sha1 => run::<sha1::Sha1>(parts),
Hash::Sha256 => run::<sha2::Sha256>(parts),
Hash::Sha384 => run::<sha2::Sha384>(parts),
Hash::Sha512 => run::<sha2::Sha512>(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<u8> {
let pw: Vec<u8> = 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<u8> {
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<u8> {
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<Aes, DecryptError> {
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::<BLOCK>();
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::<BLOCK>();
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<StandardHeader<'_>, 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<Vec<u8>, 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<u8>,
pub(crate) key_len: usize,
}
impl Params {
fn read(e: &El) -> Result<Params, DecryptError> {
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<u8> {
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<u8>,
verifier_hash: Vec<u8>,
key_value: Vec<u8>,
}
/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field.
fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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;

View File

@ -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<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
fn salt() -> Vec<u8> {
(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<u8> {
(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<Vec<u8>, 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("<keyData", "<other")));
assert!(bad("<encryption".into()));
}
#[test]
fn non_ascii_passwords_hash_as_utf16le_like_msoffcrypto() {
// "Pässwörd€" plus an emoji outside the BMP, a surrogate pair in UTF-16.
let h = iterated_hash(Hash::Sha512, "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}", &salt(), 1000);
assert_eq!(
h,
hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a")
);
}

View File

@ -1,5 +1,5 @@
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and ODS data import
//! and legacy Excel 97-2003 `.xls` (BIFF8) import.
//! and legacy Excel 97-2003 `.xls` (BIFF8) import, and password-encrypted workbooks (Agile and Standard encryption).
//!
//! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry
//! points work on byte slices (no file system access), so the crate builds for WebAssembly.
@ -15,6 +15,7 @@ mod array_formula;
mod cfb;
mod chart;
mod chartex;
mod crypto;
mod csv;
mod drawing;
mod feature_bags;
@ -29,6 +30,8 @@ mod sheet_read;
mod sheet_write;
mod styles;
mod tables;
#[cfg(any(test, feature = "test-support"))]
pub mod testing;
mod theme;
mod write;
mod xls;
@ -41,6 +44,7 @@ mod tests;
use gridcraft_core::DateSystem;
use gridcraft_model::Workbook;
pub use crypto::{DecryptError, check_encryption, decrypt};
pub use csv::{read_csv, write_csv};
pub use ods::read_ods;
pub use xls::read_xls;

262
crates/xlsx/src/testing.rs Normal file
View File

@ -0,0 +1,262 @@
//! Test support: builds password-encrypted workbooks in code, so tests here and in the crates
//! above (engine, UI) never need an encrypted file on disk. Compiled for this crate's tests and,
//! elsewhere, only with the `test-support` feature (a dev-dependency feature, never shipped).
//!
//! Salts and keys are fixed so the output is deterministic. This is not an encryptor for real
//! use: a fixed key protects nothing.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use aes::cipher::{BlockCipherEncrypt, KeyInit};
use crate::crypto::{
BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params, SEGMENT, derive, iterated_hash,
standard_key,
};
/// The spin count `encrypt_workbook` uses: Excel's default.
pub const SPIN_COUNT: u32 = 100_000;
const PASSWORD_SALT: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
const SECRET_KEY: [u8; 32] =
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95];
const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47];
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
/// (AES-256, SHA-512) inside a compound file.
pub fn encrypt_workbook(package: &[u8], password: &str) -> Vec<u8> {
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<u8> {
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<u8> {
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<u32> = 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<u8>> = 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<usize> = 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<u8> = 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<u8> {
let mut v = data.to_vec();
v.resize(data.len().div_ceil(16) * 16, 0);
v
}
fn encrypt_ecb(key: &[u8], data: &[u8]) -> Vec<u8> {
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<u8> {
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<u8>) -> Vec<u8> {
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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
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<u8> {
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<u8>, Vec<u8>) {
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<u8>, Vec<u8>) {
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)))
}

View File

@ -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<u8> = (0..3_000_000u32).map(|i| (i % 253) as u8).collect();
let mid: Vec<u8> = (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<u8> = (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())));
}

View File

@ -2,6 +2,7 @@
mod array_export;
mod csv_tests;
mod encrypted_tests;
mod fixtures;
mod malformed;
mod ods_tests;

View File

@ -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).

View File

@ -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) |

View File

@ -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) |

View File

@ -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 |