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:
commit
a34fe4a7af
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -25,4 +25,5 @@ gridcraft-pdf = { workspace = true }
|
|||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
|
||||
zip = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")}))),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -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())));
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
mod array_export;
|
||||
mod csv_tests;
|
||||
mod encrypted_tests;
|
||||
mod fixtures;
|
||||
mod malformed;
|
||||
mod ods_tests;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
11
docs/gaps.md
11
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) |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue