diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 22cb1c188..f6eed1166 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -39,6 +39,14 @@ jobs: run: | cargo build --manifest-path ./cli/Cargo.toml + - name: Build kwallet-parser + run: | + cargo build --manifest-path ./kwallet/parser/Cargo.toml + + - name: Build kwallet-cli + run: | + cargo build --manifest-path ./kwallet/cli/Cargo.toml + - name: Build Server (native) run: | cargo build --manifest-path ./server/Cargo.toml @@ -68,6 +76,10 @@ jobs: run: | cargo test --manifest-path ./server/Cargo.toml --no-default-features --features openssl_crypto + - name: Test kwallet-parser + run: | + cargo test --manifest-path ./kwallet/parser/Cargo.toml + cargo-deny: runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index f5ebbc03f..13d1cf685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,9 +81,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "ashpd" -version = "0.13.6" +version = "0.13.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2db8976f2184938619146d8416140cea548c91256158f59258849069ce7787c0" +checksum = "13bdf0fd848239dcd5e64eeeee35dbc00378ebcc6f3aa4ead0a305eec83d0cfb" dependencies = [ "async-trait", "enumflags2", @@ -255,6 +255,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.0" @@ -298,12 +304,28 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -345,9 +367,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -471,6 +493,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "endi" version = "1.1.1" @@ -782,20 +813,52 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "kwallet-cli" +version = "0.6.0-alpha" +dependencies = [ + "clap", + "kwallet-parser", + "oo7", + "rpassword", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "kwallet-parser" +version = "0.6.0-alpha" +dependencies = [ + "base64", + "blowfish", + "cbc", + "cipher", + "ecb", + "hex", + "md5", + "pbkdf2", + "serde", + "serde_json", + "sha1", + "sha2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -882,6 +945,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.0" @@ -899,9 +968,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -969,9 +1038,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -1126,6 +1195,7 @@ dependencies = [ "formatx", "gettext-rs", "hkdf", + "kwallet-parser", "libc", "num", "num-bigint-dig", @@ -1270,6 +1340,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -1278,6 +1359,8 @@ checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", "hmac", + "password-hash", + "sha2", ] [[package]] @@ -1466,7 +1549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1476,9 +1559,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -1642,6 +1731,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1852,32 +1952,32 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", "toml_datetime", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -1989,9 +2089,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -2043,9 +2143,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -2056,9 +2156,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2066,9 +2166,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", @@ -2079,9 +2179,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] @@ -2322,6 +2422,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2440,7 +2549,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -2468,24 +2577,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow", + "winnow 0.7.15", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -2528,7 +2637,7 @@ dependencies = [ "enumflags2", "serde", "serde_bytes", - "winnow", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] @@ -2556,5 +2665,5 @@ dependencies = [ "quote", "serde", "syn", - "winnow", + "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index 5d4823f49..12685da25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ members = [ "cargo-credential", "client", "cli", + "kwallet/cli", + "kwallet/parser", "macros", "pam", "portal", @@ -27,6 +29,7 @@ exclude = ["org.freedesktop.Secrets.xml"] [workspace.dependencies] zvariant = { version = "5.8", default-features = false, features = ["gvariant", "serde_bytes"]} ashpd = {version = "0.13", default-features = false} +base64 = "0.22" endi = "1.1" clap = { version = "4.5", features = [ "cargo", "derive" ] } futures-channel = "0.3" @@ -35,6 +38,7 @@ futures-util = "0.3" num-bigint-dig = { version = "0.9", features = ["zeroize"] } oo7 = { path = "client", version = "0.6.0-alpha", default-features = false, features = ["unstable", "tracing"]} serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" tokio = { version = "1.50", default-features = false } tempfile = "3.26" tracing = "0.1" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 8b359b431..e37cf1b9e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -20,8 +20,8 @@ hex = "0.4" oo7 = { workspace = true, features = ["tokio"] } rpassword = "7.4.0" tokio = { workspace = true, features = [ "macros", "rt"] } -serde_json = "1.0" -serde = "1.0" +serde_json = { workspace = true } +serde = { workspace = true } [features] default = ["native_crypto"] diff --git a/coverage.sh b/coverage.sh index 0e1031995..3dc731837 100755 --- a/coverage.sh +++ b/coverage.sh @@ -58,6 +58,7 @@ grcov coverage-raw/combined.info \ --ignore "**/pam/*" \ --ignore "**/tests/*" \ --ignore "**/examples/*" \ + --ignore "**/kwallet/*" \ --ignore "**/target/*" \ --ignore "**/error.rs" @@ -75,6 +76,7 @@ grcov coverage-raw/combined.info \ --ignore "**/pam/*" \ --ignore "**/tests/*" \ --ignore "**/examples/*" \ + --ignore "**/kwallet/*" \ --ignore "**/target/*" \ --ignore "**/error.rs" diff --git a/deny.toml b/deny.toml index e69825c32..c23508900 100644 --- a/deny.toml +++ b/deny.toml @@ -27,4 +27,6 @@ multiple-versions = "deny" wildcards = "deny" highlight = "all" skip = [ + { name = "rand_core" }, + { name = "winnow" }, ] diff --git a/kwallet/cli/Cargo.toml b/kwallet/cli/Cargo.toml new file mode 100644 index 000000000..ae33ab410 --- /dev/null +++ b/kwallet/cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "kwallet-cli" +description = "CLI tool for reading KWallet files" +version.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +kwallet-parser = { version = "0.6.0-alpha", path = "../parser" } +oo7 = { workspace = true, features = ["tokio", "native_crypto"] } +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +rpassword = "7.3" +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/kwallet/cli/README.md b/kwallet/cli/README.md new file mode 100644 index 000000000..a65345479 --- /dev/null +++ b/kwallet/cli/README.md @@ -0,0 +1,43 @@ +# kwallet-cli + +Command-line tool for reading KWallet files. + +## Usage + +```bash +# Read wallet (prompts for password) +kwallet-cli ~/.local/share/kwalletd/kdewallet.kwl + +# Provide password via argument +kwallet-cli wallet.kwl -p mypassword + +# Export as JSON +kwallet-cli wallet.kwl --json + +# Migrate to Secret Service +kwallet-cli wallet.kwl --migrate +``` + +## Output + +Normal mode shows entries organized by folder: +``` +📁 Passwords: + 🔑 mysite (password): secret123 + 📋 github (map): + username = user + token = ghp_xxx + 📄 cert (stream): 2048 bytes +``` + +JSON mode outputs structured data: +```json +{ + "Passwords": { + "mysite": { + "type": "password", + "value": "secret123" + } + } +} +``` diff --git a/kwallet/cli/src/main.rs b/kwallet/cli/src/main.rs new file mode 100644 index 000000000..2b2b984b2 --- /dev/null +++ b/kwallet/cli/src/main.rs @@ -0,0 +1,194 @@ +use std::{collections::HashMap, io::Write, path::PathBuf, process::ExitCode}; + +use clap::Parser; +use kwallet_parser::{EntryType, KWalletFile}; +use serde::Serialize; + +#[derive(Parser)] +#[command(about = "Read KWallet files")] +struct Cli { + /// Path to wallet file (.kwl) + wallet: PathBuf, + + /// Wallet password + #[arg(short, long)] + password: Option, + + /// Output as JSON + #[arg(long)] + json: bool, + + /// Migrate entries to Secret Service + #[arg(long)] + migrate: bool, +} + +#[derive(Serialize)] +#[serde(untagged)] +enum EntryValue { + Password(String), + Map(HashMap), + Stream(Vec), +} + +#[derive(Serialize)] +struct EntryJson { + #[serde(rename = "type")] + entry_type: EntryType, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, +} + +#[tokio::main] +async fn main() -> ExitCode { + let cli = Cli::parse(); + + let password = match cli.password { + Some(p) => p, + None => { + eprint!("Password: "); + std::io::stderr().flush().unwrap(); + match rpassword::read_password() { + Ok(p) => p, + Err(e) => { + eprintln!("Can't read password: {}", e); + return ExitCode::FAILURE; + } + } + } + }; + + let wallet = match KWalletFile::open(&cli.wallet, password.as_bytes()) { + Ok(w) => w, + Err(e) => { + eprintln!("Error: {}", e); + return ExitCode::FAILURE; + } + }; + + if cli.migrate { + match migrate_to_secret_service(&wallet).await { + Ok(count) => { + println!( + "✓ Successfully migrated {} entries to Secret Service", + count + ); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("✗ Migration failed: {}", e); + ExitCode::FAILURE + } + } + } else if cli.json { + let mut output: HashMap> = HashMap::new(); + + for (folder_name, folder) in wallet.wallet() { + let mut entries = HashMap::new(); + + for (key, entry) in folder { + let entry_json = match entry.entry_type() { + EntryType::Password => EntryJson { + entry_type: entry.entry_type(), + value: entry.as_password().ok().map(EntryValue::Password), + }, + EntryType::Map => EntryJson { + entry_type: entry.entry_type(), + value: entry.as_map().ok().map(EntryValue::Map), + }, + EntryType::Stream => EntryJson { + entry_type: entry.entry_type(), + value: Some(EntryValue::Stream(entry.as_stream().to_vec())), + }, + EntryType::Unknown => EntryJson { + entry_type: entry.entry_type(), + value: None, + }, + }; + + entries.insert(key.clone(), entry_json); + } + + output.insert(folder_name.clone(), entries); + } + + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + ExitCode::SUCCESS + } else { + let mut first = true; + for (folder_name, folder) in wallet.wallet() { + if !first { + println!(); + } + first = false; + + println!("📁 {}:", folder_name); + + let entries: Vec<_> = folder.iter().collect(); + if entries.is_empty() { + println!(" (empty)"); + } else { + for (key, entry) in entries { + match entry.entry_type() { + EntryType::Password => { + if let Ok(password) = entry.as_password() { + println!(" 🔑 {} (password): {}", key, password); + } + } + EntryType::Map => { + if let Ok(map) = entry.as_map() { + println!(" 📋 {} (map):", key); + for (k, v) in map { + println!(" {} = {}", k, v); + } + } + } + EntryType::Stream => { + println!(" 📄 {} (stream): {} bytes", key, entry.as_stream().len()); + } + EntryType::Unknown => { + println!(" ❓ {} (unknown)", key); + } + } + } + } + } + ExitCode::SUCCESS + } +} + +async fn migrate_to_secret_service( + wallet: &KWalletFile, +) -> Result> { + let keyring = oo7::Keyring::new().await?; + let mut count = 0; + + for (folder_name, folder) in wallet.wallet() { + for (key, entry) in folder { + match kwallet_parser::convert_entry(folder_name, key, entry) { + Ok(ss_entry) => { + keyring + .create_item( + ss_entry.label(), + ss_entry.attributes(), + oo7::Secret::blob(ss_entry.secret()), + true, + ) + .await?; + count += 1; + let entry_type = ss_entry + .attributes() + .get("type") + .map(|s| s.as_str()) + .unwrap_or("unknown"); + println!(" ✓ Migrated {} ({})", ss_entry.label(), entry_type); + } + Err(e) => { + eprintln!(" ✗ Skipped {}/{}: {}", folder_name, key, e); + } + } + } + } + + Ok(count) +} diff --git a/kwallet/parser/Cargo.toml b/kwallet/parser/Cargo.toml new file mode 100644 index 000000000..5f06c1a65 --- /dev/null +++ b/kwallet/parser/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "kwallet-parser" +description = "Read-only parser for KWallet file format" +version.workspace = true +edition.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true +rust-version.workspace = true +exclude.workspace = true + +[dependencies] +blowfish = "0.9" +cbc = "0.1" +ecb = "0.1" +md5 = "0.7" +sha1 = "0.10" +sha2 = "0.10" +pbkdf2 = { version = "0.12", default-features = false, features = ["simple"] } +cipher = { version = "0.4", features = ["block-padding"] } +zeroize = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +base64 = { workspace = true } + +[dev-dependencies] +hex = "0.4" diff --git a/kwallet/parser/README.md b/kwallet/parser/README.md new file mode 100644 index 000000000..9f1d6dfbd --- /dev/null +++ b/kwallet/parser/README.md @@ -0,0 +1,30 @@ +# kwallet-parser + +Read-only parser for KWallet file format supporting legacy (Blowfish-ECB + SHA1) and modern (Blowfish-CBC + PBKDF2-SHA512) formats. + +## Usage + +```rust +use kwallet_parser::KWalletFile; + +let wallet = KWalletFile::open("path/to/wallet.kwl", b"password")?; + +for (folder_name, folder) in wallet.wallet() { + for (key, entry) in folder { + match entry.entry_type() { + kwallet_parser::EntryType::Password => { + println!("{}: {}", key, entry.as_password()?); + } + kwallet_parser::EntryType::Map => { + println!("{}: {:?}", key, entry.as_map()?); + } + kwallet_parser::EntryType::Stream => { + println!("{}: {} bytes", key, entry.as_stream().len()); + } + _ => {} + } + } +} +``` + +See library documentation for full API. diff --git a/kwallet/parser/src/crypto.rs b/kwallet/parser/src/crypto.rs new file mode 100644 index 000000000..2d9d34c78 --- /dev/null +++ b/kwallet/parser/src/crypto.rs @@ -0,0 +1,281 @@ +use pbkdf2::pbkdf2_hmac; +use sha1::{Digest, Sha1}; +use sha2::Sha512; +use zeroize::Zeroizing; + +use crate::error::{Error, Result}; + +pub const BLOWFISH_BLOCK_SIZE: usize = 8; +pub const PBKDF2_SHA512_KEYSIZE: usize = 56; +pub const PBKDF2_SHA512_ITERATIONS: u32 = 50000; + +#[cfg(target_endian = "little")] +fn kwallet_sha1_le(data: &[u8]) -> [u8; 20] { + const K1: u32 = 0x5a827999; + const K2: u32 = 0x6ed9eba1; + const K3: u32 = 0x8f1bbcdc; + const K4: u32 = 0xca62c1d6; + + let mut h0: u32 = 0x67452301; + let mut h1: u32 = 0xefcdab89; + let mut h2: u32 = 0x98badcfe; + let mut h3: u32 = 0x10325476; + let mut h4: u32 = 0xc3d2e1f0; + + let mut padded = data.to_vec(); + let bit_len = (data.len() as u64) * 8; + padded.push(0x80); + while (padded.len() % 64) != 56 { + padded.push(0); + } + padded.extend_from_slice(&bit_len.to_be_bytes()); + + for chunk in padded.chunks_exact(64) { + let mut w = [0u32; 16]; + + for i in 0..16 { + w[i] = u32::from_le_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + + let mut a = h0; + let mut b = h1; + let mut c = h2; + let mut d = h3; + let mut e = h4; + + for i in 0..80 { + let (f, k) = match i { + 0..=19 => (d ^ (b & (c ^ d)), K1), + 20..=39 => (b ^ c ^ d, K2), + 40..=59 => ((b & c) | (d & (b | c)), K3), + 60..=79 => (b ^ c ^ d, K4), + _ => unreachable!(), + }; + + let m = if i < 16 { + w[i] + } else { + let tm = w[i & 0x0f] ^ w[(i - 14) & 0x0f] ^ w[(i - 8) & 0x0f] ^ w[(i - 3) & 0x0f]; + w[i & 0x0f] = tm.rotate_left(1); + w[i & 0x0f] + }; + + let temp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(m); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = temp; + } + + h0 = h0.wrapping_add(a); + h1 = h1.wrapping_add(b); + h2 = h2.wrapping_add(c); + h3 = h3.wrapping_add(d); + h4 = h4.wrapping_add(e); + } + + let mut result = [0u8; 20]; + result[0..4].copy_from_slice(&h0.to_le_bytes()); + result[4..8].copy_from_slice(&h1.to_le_bytes()); + result[8..12].copy_from_slice(&h2.to_le_bytes()); + result[12..16].copy_from_slice(&h3.to_le_bytes()); + result[16..20].copy_from_slice(&h4.to_le_bytes()); + + result +} + +/// KWallet's SHA-1 with endianness bug (reads/writes data as LE on +/// little-endian systems) +fn kwallet_sha1(data: &[u8]) -> [u8; 20] { + #[cfg(target_endian = "little")] + { + kwallet_sha1_le(data) + } + + #[cfg(target_endian = "big")] + { + let mut hasher = Sha1::new(); + hasher.update(data); + hasher.finalize().into() + } +} + +fn hash_block_2000(block: &[u8]) -> [u8; 20] { + let mut hash = Sha1::digest(block).into(); + for _ in 1..2000 { + hash = Sha1::digest(hash).into(); + } + hash +} + +/// Legacy key derivation: split password into 16-byte blocks, hash each 2000 +/// times with SHA1 +pub fn derive_key_legacy(password: &[u8]) -> Vec { + if password.is_empty() { + return hash_block_2000(&[]).to_vec(); + } + + let blocks: Vec<&[u8]> = password.chunks(16).collect(); + let hashed_blocks: Vec<[u8; 20]> = blocks.iter().map(|b| hash_block_2000(b)).collect(); + + match password.len() { + 0..=16 => hashed_blocks[0].to_vec(), + 17..=32 => { + let mut key = Vec::with_capacity(40); + key.extend_from_slice(&hashed_blocks[0]); + key.extend_from_slice(&hashed_blocks[1]); + key + } + 33..=48 => { + let mut key = Vec::with_capacity(56); + key.extend_from_slice(&hashed_blocks[0]); + key.extend_from_slice(&hashed_blocks[1]); + key.extend_from_slice(&hashed_blocks[2][..16]); + key + } + _ => { + let mut key = Vec::with_capacity(56); + for block in &hashed_blocks[..4] { + key.extend_from_slice(&block[..14]); + } + key + } + } +} + +/// Switch endianness (swap every 4 bytes) for legacy Blowfish-ECB +pub fn switch_endianness(data: &[u8]) -> Result> { + if !data.len().is_multiple_of(4) { + return Err(Error::InvalidDataStructure); + } + + Ok(data + .chunks_exact(4) + .flat_map(|chunk| chunk.iter().rev().copied()) + .collect()) +} + +/// PBKDF2-SHA512 key derivation with 50000 iterations +pub fn derive_key_pbkdf2_sha512( + password: &[u8], + salt: &[u8], +) -> Zeroizing<[u8; PBKDF2_SHA512_KEYSIZE]> { + let mut key = Zeroizing::new([0u8; PBKDF2_SHA512_KEYSIZE]); + pbkdf2_hmac::(password, salt, PBKDF2_SHA512_ITERATIONS, &mut *key); + key +} + +/// Decrypt with Blowfish-CBC (zero IV) +pub fn decrypt_blowfish_cbc(key: &[u8], encrypted: &[u8]) -> Result> { + use cipher::{BlockDecryptMut, KeyIvInit}; + + type BlowfishCbc = cbc::Decryptor; + + let cipher = BlowfishCbc::new_from_slices(key, &[0u8; BLOWFISH_BLOCK_SIZE]) + .map_err(|_| Error::DecryptionFailed)?; + + let mut decrypted = encrypted.to_vec(); + cipher + .decrypt_padded_mut::(&mut decrypted) + .map_err(|_| Error::DecryptionFailed)?; + + Ok(decrypted) +} + +/// Decrypt with Blowfish-ECB (legacy format) +pub fn decrypt_blowfish_ecb(key: &[u8], encrypted: &[u8]) -> Result> { + use cipher::{BlockDecryptMut, KeyInit}; + + type BlowfishEcb = ecb::Decryptor; + + let cipher = BlowfishEcb::new_from_slice(key).map_err(|_| Error::DecryptionFailed)?; + + let mut decrypted = encrypted.to_vec(); + cipher + .decrypt_padded_mut::(&mut decrypted) + .map_err(|_| Error::DecryptionFailed)?; + + Ok(decrypted) +} + +/// Validate SHA-1 hash using KWallet's buggy implementation +fn validate_sha1(data: &[u8], expected_hash: &[u8]) -> Result<()> { + let computed_hash = kwallet_sha1(data); + + if computed_hash.as_slice() != expected_hash { + return Err(Error::HashValidationFailed); + } + + Ok(()) +} + +/// Compute MD5 hash +pub fn compute_md5(data: &[u8]) -> [u8; 16] { + md5::compute(data).into() +} + +/// Extract wallet data from decrypted payload and validate SHA-1 hash +pub fn extract_wallet_data(decrypted: &[u8]) -> Result> { + if decrypted.len() < BLOWFISH_BLOCK_SIZE + 4 + 20 { + return Err(Error::InvalidDataStructure); + } + + let mut offset = BLOWFISH_BLOCK_SIZE; + + let size_bytes: [u8; 4] = decrypted[offset..offset + 4] + .try_into() + .map_err(|_| Error::InvalidDataStructure)?; + let data_size = u32::from_be_bytes(size_bytes) as usize; + offset += 4; + + if data_size > decrypted.len() - offset - 20 { + return Err(Error::InvalidDataStructure); + } + + let wallet_data = &decrypted[offset..offset + data_size]; + let hash_offset = decrypted.len() - 20; + let stored_hash = &decrypted[hash_offset..]; + + validate_sha1(wallet_data, stored_hash)?; + + Ok(wallet_data.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_kwallet_sha1_matches_cpp() { + let data = [ + 0x00, 0x00, 0x00, 0x12, 0x00, 0x46, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x6d, 0x00, 0x20, + 0x00, 0x44, 0x00, 0x61, 0x00, 0x74, 0x00, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x12, 0x00, 0x50, 0x00, 0x61, 0x00, 0x73, 0x00, 0x73, 0x00, 0x77, 0x00, 0x6f, + 0x00, 0x72, 0x00, 0x64, 0x00, 0x73, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, + 0x00, 0x74, 0x00, 0x65, 0x00, 0x73, 0x00, 0x74, 0x00, 0x32, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x08, 0x00, 0x68, 0x00, 0x65, 0x00, 0x6c, 0x00, 0x70, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x0e, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x74, 0x00, 0x74, + 0x00, 0x74, 0x00, 0x74, 0x00, 0x74, + ]; + + let hash = kwallet_sha1(&data); + + let expected = [ + 0x26, 0x45, 0xc3, 0x1d, 0x53, 0xa1, 0x98, 0x51, 0x00, 0x15, 0xbc, 0x12, 0x59, 0xf3, + 0xb4, 0x54, 0xcc, 0x99, 0x8d, 0xd1, + ]; + + assert_eq!(hash, expected); + } +} diff --git a/kwallet/parser/src/error.rs b/kwallet/parser/src/error.rs new file mode 100644 index 000000000..ce6b0468f --- /dev/null +++ b/kwallet/parser/src/error.rs @@ -0,0 +1,69 @@ +use std::fmt; + +use crate::format::{CipherType, HashType}; + +#[derive(Debug)] +pub enum Error { + InvalidMagic, + UnsupportedVersion(u8, u8), + UnsupportedCipher(CipherType), + UnsupportedHash(HashType), + UnknownCipher(u8), + UnknownHash(u8), + FileTooSmall(usize), + InvalidSalt, + DecryptionFailed, + HashValidationFailed, + InvalidDataStructure, + Io(std::io::Error), + Utf8(std::string::FromUtf8Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidMagic => write!(f, "Invalid magic bytes"), + Self::UnsupportedVersion(major, minor) => { + write!( + f, + "Unsupported wallet version: major={major}, minor={minor}" + ) + } + Self::UnsupportedCipher(cipher) => write!(f, "Unsupported cipher type: {cipher:?}"), + Self::UnsupportedHash(hash) => write!(f, "Unsupported hash type: {hash:?}"), + Self::UnknownCipher(value) => write!(f, "Unknown cipher type: {value}"), + Self::UnknownHash(value) => write!(f, "Unknown hash type: {value}"), + Self::FileTooSmall(size) => write!(f, "File too small: {size} bytes (minimum 60)"), + Self::InvalidSalt => write!(f, "Salt file not found or invalid"), + Self::DecryptionFailed => write!(f, "Decryption failed"), + Self::HashValidationFailed => write!(f, "Hash validation failed"), + Self::InvalidDataStructure => write!(f, "Invalid data structure"), + Self::Io(err) => write!(f, "IO error: {err}"), + Self::Utf8(err) => write!(f, "UTF-8 error: {err}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + Self::Utf8(err) => Some(err), + _ => None, + } + } +} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} + +impl From for Error { + fn from(err: std::string::FromUtf8Error) -> Self { + Self::Utf8(err) + } +} + +pub type Result = std::result::Result; diff --git a/kwallet/parser/src/format.rs b/kwallet/parser/src/format.rs new file mode 100644 index 000000000..e3f96ba3a --- /dev/null +++ b/kwallet/parser/src/format.rs @@ -0,0 +1,188 @@ +use std::io::Read; + +use crate::error::{Error, Result}; + +pub const KWMAGIC: &[u8; 12] = b"KWALLET\n\r\0\r\n"; + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CipherType { + BlowfishECB = 0, + /// Unsupported - removed from KWallet, no implementation exists + TripleDESCBC = 1, + /// Unsupported - requires GPG integration + GPG = 2, + BlowfishCBC = 3, +} + +impl std::fmt::Display for CipherType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BlowfishECB => write!(f, "Blowfish-ECB"), + Self::TripleDESCBC => write!(f, "3DES-CBC"), + Self::GPG => write!(f, "GPG"), + Self::BlowfishCBC => write!(f, "Blowfish-CBC"), + } + } +} + +impl TryFrom for CipherType { + type Error = Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::BlowfishECB), + 1 => Ok(Self::TripleDESCBC), + 2 => Ok(Self::GPG), + 3 => Ok(Self::BlowfishCBC), + _ => Err(Error::UnknownCipher(value)), + } + } +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashType { + /// Legacy SHA-1 hash (2000 iterations) + SHA1 = 0, + /// Unsupported - deprecated since KDE 4.13 (2013), no implementation exists + /// in modern KWallet + MD5 = 1, + /// Modern PBKDF2-SHA512 (50000 iterations, default since KDE 4.13) + PBKDF2SHA512 = 2, +} + +impl std::fmt::Display for HashType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SHA1 => write!(f, "SHA-1"), + Self::MD5 => write!(f, "MD5"), + Self::PBKDF2SHA512 => write!(f, "PBKDF2-SHA512"), + } + } +} + +impl TryFrom for HashType { + type Error = Error; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::SHA1), + 1 => Ok(Self::MD5), + 2 => Ok(Self::PBKDF2SHA512), + _ => Err(Error::UnknownHash(value)), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct WalletHeader { + version_major: u8, + version_minor: u8, + cipher_type: CipherType, + hash_type: HashType, +} + +impl WalletHeader { + pub fn version(&self) -> (u8, u8) { + (self.version_major, self.version_minor) + } + + pub fn cipher_type(&self) -> CipherType { + self.cipher_type + } + + pub fn hash_type(&self) -> HashType { + self.hash_type + } + + pub fn read(reader: &mut R) -> Result { + let mut magic = [0u8; 12]; + reader.read_exact(&mut magic)?; + + if &magic != KWMAGIC { + return Err(Error::InvalidMagic); + } + + let mut version = [0u8; 4]; + reader.read_exact(&mut version)?; + + let cipher_type = CipherType::try_from(version[2])?; + let hash_type = HashType::try_from(version[3])?; + + let header = WalletHeader { + version_major: version[0], + version_minor: version[1], + cipher_type, + hash_type, + }; + + header.validate()?; + Ok(header) + } + + fn validate(&self) -> Result<()> { + if self.version_major != 0 { + return Err(Error::UnsupportedVersion( + self.version_major, + self.version_minor, + )); + } + + match (self.version_minor, self.cipher_type, self.hash_type) { + (0, CipherType::BlowfishECB, HashType::SHA1) => Ok(()), + (1, CipherType::BlowfishCBC, HashType::PBKDF2SHA512) => Ok(()), + _ => { + if self.version_minor != 0 && self.version_minor != 1 { + Err(Error::UnsupportedVersion( + self.version_major, + self.version_minor, + )) + } else if self.cipher_type != CipherType::BlowfishECB + && self.cipher_type != CipherType::BlowfishCBC + { + Err(Error::UnsupportedCipher(self.cipher_type)) + } else { + Err(Error::UnsupportedHash(self.hash_type)) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn test_valid_header() { + let mut data = Vec::new(); + data.extend_from_slice(KWMAGIC); + data.extend_from_slice(&[ + 0, + 1, + CipherType::BlowfishCBC as u8, + HashType::PBKDF2SHA512 as u8, + ]); + + let header = WalletHeader::read(&mut Cursor::new(data)).unwrap(); + + assert_eq!(header.version_major, 0); + assert_eq!(header.version_minor, 1); + assert_eq!(header.cipher_type, CipherType::BlowfishCBC); + assert_eq!(header.hash_type, HashType::PBKDF2SHA512); + } + + #[test] + fn test_invalid_magic() { + let mut data = Vec::new(); + data.extend_from_slice(b"INVALID_MAG!"); + data.extend_from_slice(&[0, 1, 3, 2]); + + let result = WalletHeader::read(&mut Cursor::new(data)); + + assert!(matches!(result, Err(Error::InvalidMagic))); + } +} diff --git a/kwallet/parser/src/lib.rs b/kwallet/parser/src/lib.rs new file mode 100644 index 000000000..eaeddbfbf --- /dev/null +++ b/kwallet/parser/src/lib.rs @@ -0,0 +1,224 @@ +//! Read-only parser for KWallet file format +//! +//! This crate provides parsing support for KWallet wallet files: +//! +//! ## Modern Format (version 0.1) +//! - Blowfish-CBC encryption +//! - PBKDF2-SHA512 password hashing +//! +//! ## Legacy Format (version 0.0) +//! - Blowfish-ECB encryption +//! - SHA1-based password hashing (2000 iterations) +//! +//! # Example +//! +//! ```no_run +//! use kwallet_parser::{EntryType, KWalletFile}; +//! +//! # fn main() -> Result<(), Box> { +//! let wallet = KWalletFile::open("~/.local/share/kwalletd/kdewallet.kwl", b"password")?; +//! +//! for (folder_name, folder) in wallet.wallet() { +//! println!("{}:", folder_name); +//! +//! for (key, entry) in folder { +//! match entry.entry_type() { +//! EntryType::Password => { +//! if let Ok(password) = entry.as_password() { +//! println!(" {}: {}", key, password); +//! } +//! } +//! EntryType::Map => { +//! if let Ok(map) = entry.as_map() { +//! println!(" {} (map):", key); +//! for (k, v) in map { +//! println!(" {}: {}", k, v); +//! } +//! } +//! } +//! EntryType::Stream => { +//! println!(" {}: {} bytes", key, entry.as_stream().len()); +//! } +//! EntryType::Unknown => {} +//! } +//! } +//! } +//! # Ok(()) +//! # } +//! ``` + +mod crypto; +mod error; +mod format; +mod qdata; +pub mod secret_service; +mod wallet; + +use std::{fs, io::Cursor, path::Path}; + +pub use error::{Error, Result}; +pub use format::{CipherType, HashType}; +pub use secret_service::{SecretServiceEntry, convert_entry}; +pub use wallet::{Entry, EntryType, Folder, Wallet}; + +/// A parsed KWallet file +#[derive(Debug, Clone)] +pub struct KWalletFile { + header: format::WalletHeader, + wallet: Wallet, +} + +impl KWalletFile { + pub fn version(&self) -> (u8, u8) { + self.header.version() + } + + pub fn cipher_type(&self) -> CipherType { + self.header.cipher_type() + } + + pub fn hash_type(&self) -> HashType { + self.header.hash_type() + } + + pub fn wallet(&self) -> &Wallet { + &self.wallet + } + + /// Open and parse a KWallet file + pub fn open>(path: P, password: &[u8]) -> Result { + let path = path.as_ref(); + let wallet_data = fs::read(path)?; + + if wallet_data.len() < 60 { + return Err(Error::FileTooSmall(wallet_data.len())); + } + + let mut cursor = Cursor::new(&wallet_data); + let header = format::WalletHeader::read(&mut cursor)?; + + let key = match header.hash_type() { + HashType::PBKDF2SHA512 => { + pub const PBKDF2_SHA512_SALTSIZE: usize = 56; + let salt_path = path.with_extension("salt"); + let salt = fs::read(&salt_path).map_err(|_| Error::InvalidSalt)?; + + if salt.len() != PBKDF2_SHA512_SALTSIZE { + return Err(Error::InvalidSalt); + } + + crypto::derive_key_pbkdf2_sha512(password, &salt).to_vec() + } + HashType::SHA1 => crypto::derive_key_legacy(password), + HashType::MD5 => return Err(Error::UnsupportedHash(header.hash_type())), + }; + + let remaining_data = &wallet_data[16..]; + + let mut cursor = Cursor::new(remaining_data); + let mut hash_reader = qdata::QDataStreamReader::new(&mut cursor); + let folder_count = hash_reader.read_u32()?; + + let mut folder_hashes = Vec::new(); + for _ in 0..folder_count { + let mut folder_hash = [0u8; 16]; + hash_reader.read_raw(&mut folder_hash)?; + let entry_count = hash_reader.read_u32()?; + + let mut entry_hashes = Vec::new(); + for _ in 0..entry_count { + let mut entry_hash = [0u8; 16]; + hash_reader.read_raw(&mut entry_hash)?; + entry_hashes.push(entry_hash); + } + folder_hashes.push((folder_hash, entry_hashes)); + } + + let hash_section_size = cursor.position() as usize; + let encrypted_data = &remaining_data[hash_section_size..]; + + let wallet_data = match header.cipher_type() { + CipherType::BlowfishCBC => { + let decrypted = crypto::decrypt_blowfish_cbc(&key, encrypted_data)?; + crypto::extract_wallet_data(&decrypted)? + } + CipherType::BlowfishECB => { + let switched = crypto::switch_endianness(encrypted_data)?; + let decrypted = crypto::decrypt_blowfish_ecb(&key, &switched)?; + let restored = crypto::switch_endianness(&decrypted)?; + + if restored.len() < 12 { + return Err(Error::InvalidDataStructure); + } + let size = + u32::from_be_bytes([restored[8], restored[9], restored[10], restored[11]]) + as usize; + + if restored.len() < 12 + size { + return Err(Error::InvalidDataStructure); + } + + restored[12..12 + size].to_vec() + } + _ => return Err(Error::UnsupportedCipher(header.cipher_type())), + }; + + let wallet = Wallet::parse(&wallet_data)?; + + validate_hashes(&wallet, &folder_hashes)?; + + Ok(Self { header, wallet }) + } + + /// Get the wallet file path for a given wallet name in + /// `$XDG_DATA_HOME/kwalletd/` + pub fn get_wallet_path(wallet_name: &str) -> Option { + let data_home = std::env::var("XDG_DATA_HOME").ok().or_else(|| { + std::env::var("HOME") + .ok() + .map(|home| format!("{}/.local/share", home)) + })?; + + let wallet_dir = Path::new(&data_home).join("kwalletd"); + Some(wallet_dir.join(format!("{}.kwl", wallet_name))) + } +} + +fn validate_hashes(wallet: &Wallet, folder_hashes: &[([u8; 16], Vec<[u8; 16]>)]) -> Result<()> { + // Build a map of folder_hash -> (folder_name, entry_hashes) + let mut folder_hash_map = std::collections::HashMap::new(); + + for (folder_name, folder) in wallet.iter() { + let folder_hash = crypto::compute_md5(folder_name.as_bytes()); + + let mut entry_hashes = Vec::new(); + for (entry_key, _entry) in folder { + let entry_hash = crypto::compute_md5(entry_key.as_bytes()); + entry_hashes.push(entry_hash); + } + + folder_hash_map.insert(folder_hash, (folder_name, entry_hashes)); + } + + // Validate against expected hashes + for (expected_folder_hash, expected_entry_hashes) in folder_hashes.iter() { + let Some((_folder_name, computed_entry_hashes)) = folder_hash_map.get(expected_folder_hash) + else { + return Err(Error::HashValidationFailed); + }; + + // Entry hashes also need to be matched by hash, not by order + if computed_entry_hashes.len() != expected_entry_hashes.len() { + return Err(Error::HashValidationFailed); + } + + // Check that all expected entry hashes are present + for expected_entry_hash in expected_entry_hashes { + if !computed_entry_hashes.contains(expected_entry_hash) { + return Err(Error::HashValidationFailed); + } + } + } + + Ok(()) +} diff --git a/kwallet/parser/src/qdata.rs b/kwallet/parser/src/qdata.rs new file mode 100644 index 000000000..2d1ed7637 --- /dev/null +++ b/kwallet/parser/src/qdata.rs @@ -0,0 +1,87 @@ +use std::io::Read; + +use crate::error::{Error, Result}; + +/// Qt's QDataStream reader (big-endian) +pub struct QDataStreamReader { + reader: R, +} + +impl QDataStreamReader { + pub fn new(reader: R) -> Self { + Self { reader } + } + + fn read_u16(&mut self) -> Result { + let mut buf = [0u8; 2]; + self.reader.read_exact(&mut buf)?; + Ok(u16::from_be_bytes(buf)) + } + + pub fn read_u32(&mut self) -> Result { + let mut buf = [0u8; 4]; + self.reader.read_exact(&mut buf)?; + Ok(u32::from_be_bytes(buf)) + } + + pub fn read_i32(&mut self) -> Result { + let mut buf = [0u8; 4]; + self.reader.read_exact(&mut buf)?; + Ok(i32::from_be_bytes(buf)) + } + + /// Read QString (UTF-16 BE) + pub fn read_string(&mut self) -> Result { + let byte_length = self.read_u32()? as usize; + + if byte_length == 0xffffffff { + return Ok(String::new()); + } + + if !byte_length.is_multiple_of(2) { + return Err(Error::InvalidDataStructure); + } + + let mut utf16_buf = vec![0u16; byte_length / 2]; + for item in &mut utf16_buf { + *item = self.read_u16()?; + } + + String::from_utf16(&utf16_buf).map_err(|_| Error::InvalidDataStructure) + } + + /// Read QByteArray + pub fn read_byte_array(&mut self) -> Result> { + let byte_length = self.read_u32()? as usize; + + if byte_length == 0xffffffff { + return Ok(Vec::new()); + } + + let mut buf = vec![0u8; byte_length]; + self.reader.read_exact(&mut buf)?; + Ok(buf) + } + + pub fn read_raw(&mut self, buf: &mut [u8]) -> Result<()> { + self.reader.read_exact(buf)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn test_read_string() { + let mut data = Vec::new(); + data.extend_from_slice(&10u32.to_be_bytes()); + data.extend_from_slice(&[0x00, 0x68, 0x00, 0x65, 0x00, 0x6C, 0x00, 0x6C, 0x00, 0x6F]); + + let mut reader = QDataStreamReader::new(Cursor::new(data)); + assert_eq!(reader.read_string().unwrap(), "hello"); + } +} diff --git a/kwallet/parser/src/secret_service.rs b/kwallet/parser/src/secret_service.rs new file mode 100644 index 000000000..a12b6b9d8 --- /dev/null +++ b/kwallet/parser/src/secret_service.rs @@ -0,0 +1,89 @@ +//! Helper functions for migrating KWallet entries to Secret Service format +//! matching the behavior of KWallet's own migration code. + +use std::collections::HashMap; + +use base64::Engine; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::{Entry, EntryType}; + +/// Result of converting a KWallet entry to Secret Service format +#[derive(Debug, Clone, Zeroize, ZeroizeOnDrop)] +pub struct SecretServiceEntry { + #[zeroize(skip)] + label: String, + #[zeroize(skip)] + attributes: HashMap, + secret: Vec, +} + +impl SecretServiceEntry { + /// The Secret Service label (format: "folder/key") + pub fn label(&self) -> &str { + &self.label + } + + /// Attributes that should be set on the Secret Service item + pub fn attributes(&self) -> &HashMap { + &self.attributes + } + + /// The secret value (as bytes) + pub fn secret(&self) -> &[u8] { + &self.secret + } +} + +/// Convert a KWallet entry to Secret Service format +/// +/// This follows KWallet's migration behavior: +/// - Attributes: `user` (key), `server` (folder), `type` (password/map/base64) +/// - Label: "folder/key" +/// - Secret: +/// - Password: UTF-8 text +/// - Map: JSON object +/// - Stream: Base64-encoded binary data +pub fn convert_entry( + folder: &str, + key: &str, + entry: &Entry, +) -> Result> { + let label = format!("{}/{}", folder, key); + let mut attributes = HashMap::new(); + + // Standard Secret Service attributes used by KWallet + attributes.insert("user".to_string(), key.to_string()); + attributes.insert("server".to_string(), folder.to_string()); + + let (type_str, secret) = match entry.entry_type() { + EntryType::Password => { + let password = entry.as_password()?; + ("password".to_string(), password.into_bytes()) + } + EntryType::Map => { + let map = entry.as_map()?; + // Convert map to JSON like KWallet does + let json_value = serde_json::to_value(map)?; + let json_bytes = serde_json::to_vec(&json_value)?; + ("map".to_string(), json_bytes) + } + EntryType::Stream => { + // KWallet stores streams as base64 + let stream_data = entry.as_stream(); + let base64_data = base64::engine::general_purpose::STANDARD.encode(stream_data); + ("base64".to_string(), base64_data.into_bytes()) + } + EntryType::Unknown => { + return Err("Cannot convert unknown entry type".into()); + } + }; + + attributes.insert("type".to_string(), type_str); + + Ok(SecretServiceEntry { + label, + attributes, + secret, + }) +} diff --git a/kwallet/parser/src/wallet.rs b/kwallet/parser/src/wallet.rs new file mode 100644 index 000000000..71811f35c --- /dev/null +++ b/kwallet/parser/src/wallet.rs @@ -0,0 +1,214 @@ +use std::{collections::HashMap, io::Cursor, ops::Index}; + +use zeroize::Zeroizing; + +use crate::{ + error::{Error, Result}, + qdata::QDataStreamReader, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +#[repr(i32)] +pub enum EntryType { + Unknown = 0, + Password = 1, + Stream = 2, + Map = 3, +} + +impl TryFrom for EntryType { + type Error = Error; + + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(EntryType::Unknown), + 1 => Ok(EntryType::Password), + 2 => Ok(EntryType::Stream), + 3 => Ok(EntryType::Map), + _ => Err(Error::InvalidDataStructure), + } + } +} + +#[derive(Debug, Clone)] +pub struct Entry { + key: String, + entry_type: EntryType, + value: Zeroizing>, +} + +impl Entry { + pub fn key(&self) -> &str { + &self.key + } + + pub fn entry_type(&self) -> EntryType { + self.entry_type + } + + /// Parse as password + pub fn as_password(&self) -> Result { + if self.entry_type != EntryType::Password { + return Err(Error::InvalidDataStructure); + } + + let mut reader = QDataStreamReader::new(Cursor::new(&**self.value)); + reader.read_string() + } + + /// Parse as map of strings + pub fn as_map(&self) -> Result> { + if self.entry_type != EntryType::Map { + return Err(Error::InvalidDataStructure); + } + + let mut reader = QDataStreamReader::new(Cursor::new(&**self.value)); + let count = reader.read_u32()?; + + let mut map = HashMap::new(); + for _ in 0..count { + let key = reader.read_string()?; + let value = reader.read_string()?; + map.insert(key, value); + } + + Ok(map) + } + + /// Get raw bytes + pub fn as_stream(&self) -> &[u8] { + &self.value + } +} + +impl AsRef<[u8]> for Entry { + fn as_ref(&self) -> &[u8] { + &self.value + } +} + +#[derive(Debug, Clone)] +pub struct Folder { + name: String, + entries: HashMap, +} + +impl Folder { + pub fn name(&self) -> &str { + &self.name + } + + pub fn entries(&self) -> &HashMap { + &self.entries + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } +} + +impl<'a> IntoIterator for &'a Folder { + type Item = (&'a String, &'a Entry); + type IntoIter = std::collections::hash_map::Iter<'a, String, Entry>; + + fn into_iter(self) -> Self::IntoIter { + self.entries.iter() + } +} + +impl Index<&str> for Folder { + type Output = Entry; + + fn index(&self, key: &str) -> &Self::Output { + &self.entries[key] + } +} + +#[derive(Debug, Clone)] +pub struct Wallet { + folders: HashMap, +} + +impl Wallet { + pub fn folders(&self) -> &HashMap { + &self.folders + } + + pub fn iter(&self) -> impl Iterator { + self.folders.iter() + } + + /// Parse wallet from decrypted data + pub fn parse(data: &[u8]) -> Result { + let mut reader = QDataStreamReader::new(Cursor::new(data)); + let mut folders = HashMap::new(); + + loop { + let folder_name = match reader.read_string() { + Ok(name) if !name.is_empty() => name, + Ok(_) => break, + Err(_) => break, + }; + let entry_count = reader.read_u32()?; + let mut entries = HashMap::new(); + + for _ in 0..entry_count { + let key = reader.read_string()?; + let entry_type_raw = reader.read_i32()?; + let entry_type = EntryType::try_from(entry_type_raw)?; + let value = reader.read_byte_array()?; + + if entry_type == EntryType::Unknown { + continue; + } + + let entry = Entry { + key: key.clone(), + entry_type, + value: Zeroizing::new(value), + }; + + entries.insert(key, entry); + } + + let folder = Folder { + name: folder_name.clone(), + entries, + }; + + folders.insert(folder_name, folder); + } + + Ok(Self { folders }) + } + + pub fn get_folder(&self, name: &str) -> Option<&Folder> { + self.folders.get(name) + } + + pub fn get_entry(&self, folder: &str, key: &str) -> Option<&Entry> { + self.folders.get(folder)?.entries.get(key) + } + + pub fn folder_names(&self) -> Vec<&str> { + self.folders.keys().map(|s| s.as_str()).collect() + } +} + +impl<'a> IntoIterator for &'a Wallet { + type Item = (&'a String, &'a Folder); + type IntoIter = std::collections::hash_map::Iter<'a, String, Folder>; + + fn into_iter(self) -> Self::IntoIter { + self.folders.iter() + } +} + +impl Index<&str> for Wallet { + type Output = Folder; + + fn index(&self, name: &str) -> &Self::Output { + &self.folders[name] + } +} diff --git a/kwallet/parser/tests/README.md b/kwallet/parser/tests/README.md new file mode 100644 index 000000000..f3e2a5858 --- /dev/null +++ b/kwallet/parser/tests/README.md @@ -0,0 +1,22 @@ +# Test Files + +## Test Wallet Files + +### Legacy Format (version 0.0, Blowfish-ECB + SHA1) + +- `blowfish_ecb_sha1_empty_password.kwl` - Password: `""` (empty string) +- `blowfish_ecb_sha1_long_password.kwl` - Password: `"pythonpythonpythonpythonpython"` + +**Source**: [gaganpreet/kwallet-dump](https://github.com/gaganpreet/kwallet-dump/tree/master/tests/wallets) +Originally named `blank_pass.kwl` and `python5.kwl`. Credit to @gaganpreet for creating these test files. + +### Modern Format (version 0.1, Blowfish-CBC + PBKDF2-SHA512) + +- `blowfish_cbc_pbkdf2_sha512_manual.kwl` + `.salt` - Password: `"password"` + +**Source**: Created manually for testing modern KWallet format. + +## Test Code + +- `blowfish_ecb_sha1.rs` - Integration tests for legacy format (version 0.0) +- `blowfish_cbc_pbkdf2_sha512.rs` - Integration tests for modern format (version 0.1) diff --git a/kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512.rs b/kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512.rs new file mode 100644 index 000000000..2ecc77385 --- /dev/null +++ b/kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512.rs @@ -0,0 +1,47 @@ +use std::path::Path; + +use kwallet_parser::{CipherType, EntryType, HashType, KWalletFile}; + +#[test] +fn test_blowfish_cbc_pbkdf2_wallet_with_password_entry() { + let password = "password"; + let wallet_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blowfish_cbc_pbkdf2_sha512_manual.kwl"); + + let wallet_file = + KWalletFile::open(&wallet_path, password.as_bytes()).expect("Failed to open wallet"); + + assert_eq!(wallet_file.version(), (0, 1)); + assert_eq!(wallet_file.cipher_type(), CipherType::BlowfishCBC); + assert_eq!(wallet_file.hash_type(), HashType::PBKDF2SHA512); + + let folder_names = wallet_file.wallet().folder_names(); + assert_eq!(folder_names.len(), 3); + assert!(folder_names.contains(&"Form Data")); + assert!(folder_names.contains(&"Passwords")); + assert!(folder_names.contains(&"test2")); + + let test2_folder = &wallet_file.wallet()["test2"]; + assert_eq!(test2_folder.entries().len(), 1); + + let help_entry = &test2_folder.entries()["help"]; + assert_eq!(help_entry.entry_type(), EntryType::Password); + + let password_value = help_entry + .as_password() + .expect("help entry should be a valid password"); + assert_eq!(password_value, "ttttt"); + + assert_eq!(wallet_file.wallet()["Form Data"].entries().len(), 0); + assert_eq!(wallet_file.wallet()["Passwords"].entries().len(), 0); +} + +#[test] +fn test_decryption_fails_with_invalid_password() { + let wallet_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blowfish_cbc_pbkdf2_sha512_manual.kwl"); + let wrong_password = b"wrongpassword"; + + let result = KWalletFile::open(&wallet_path, wrong_password); + assert!(result.is_err(), "Should fail with wrong password"); +} diff --git a/kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512_manual.kwl b/kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512_manual.kwl new file mode 100644 index 000000000..5ad4b69fa Binary files /dev/null and b/kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512_manual.kwl differ diff --git a/kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512_manual.salt b/kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512_manual.salt new file mode 100644 index 000000000..25d6503b7 --- /dev/null +++ b/kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512_manual.salt @@ -0,0 +1 @@ +*I(agw!9jl3iͲx bn0VATE(s9r \ No newline at end of file diff --git a/kwallet/parser/tests/blowfish_ecb_sha1.rs b/kwallet/parser/tests/blowfish_ecb_sha1.rs new file mode 100644 index 000000000..adf9839d0 --- /dev/null +++ b/kwallet/parser/tests/blowfish_ecb_sha1.rs @@ -0,0 +1,90 @@ +use std::path::Path; + +use kwallet_parser::{CipherType, EntryType, HashType, KWalletFile}; + +#[test] +fn test_blowfish_ecb_sha1_empty_password() { + let password = ""; + let wallet_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blowfish_ecb_sha1_empty_password.kwl"); + + let wallet_file = + KWalletFile::open(&wallet_path, password.as_bytes()).expect("Failed to open wallet"); + + // Validate header + assert_eq!(wallet_file.version(), (0, 0)); + assert_eq!(wallet_file.cipher_type(), CipherType::BlowfishECB); + assert_eq!(wallet_file.hash_type(), HashType::SHA1); + + let passwords_folder = &wallet_file.wallet()["Passwords"]; + assert_eq!(passwords_folder.entries().len(), 3); + + // Validate Password entry: 'abcdef' -> 'qwerty' + let abcdef_entry = &passwords_folder.entries()["abcdef"]; + assert_eq!(abcdef_entry.entry_type(), EntryType::Password); + let password_value = abcdef_entry + .as_password() + .expect("abcdef should be a valid password"); + assert_eq!(password_value, "qwerty"); + + // Validate Stream/Binary entry: 'bindata' -> '' + let bindata_entry = &passwords_folder.entries()["bindata"]; + assert_eq!(bindata_entry.entry_type(), EntryType::Stream); + assert_eq!(bindata_entry.as_stream().len(), 0); + + // Validate Map entry: 'kde.org' -> {key1: value1, key2: value2} + let kde_entry = &passwords_folder.entries()["kde.org"]; + assert_eq!(kde_entry.entry_type(), EntryType::Map); + let map_value = kde_entry.as_map().expect("kde.org should be a valid map"); + assert_eq!(map_value.len(), 2); + assert_eq!(map_value.get("key1"), Some(&"value1".to_string())); + assert_eq!(map_value.get("key2"), Some(&"value2".to_string())); +} + +#[test] +fn test_blowfish_ecb_sha1_long_password() { + let password = "pythonpythonpythonpythonpython"; + let wallet_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blowfish_ecb_sha1_long_password.kwl"); + + let wallet_file = + KWalletFile::open(&wallet_path, password.as_bytes()).expect("Failed to open wallet"); + + // Validate header + assert_eq!(wallet_file.version(), (0, 0)); + assert_eq!(wallet_file.cipher_type(), CipherType::BlowfishECB); + assert_eq!(wallet_file.hash_type(), HashType::SHA1); + let passwords_folder = &wallet_file.wallet()["Passwords"]; + assert_eq!(passwords_folder.entries().len(), 3); + + // Validate Password entry: 'abcdef' -> 'qwerty' + let abcdef_entry = &passwords_folder.entries()["abcdef"]; + assert_eq!(abcdef_entry.entry_type(), EntryType::Password); + let password_value = abcdef_entry + .as_password() + .expect("abcdef should be a valid password"); + assert_eq!(password_value, "qwerty"); + + // Validate Stream/Binary entry: 'bindata' -> '' + let bindata_entry = &passwords_folder.entries()["bindata"]; + assert_eq!(bindata_entry.entry_type(), EntryType::Stream); + assert_eq!(bindata_entry.as_stream().len(), 0); + + // Validate Map entry: 'kde.org' -> {key1: value1, key2: value2} + let kde_entry = &passwords_folder.entries()["kde.org"]; + assert_eq!(kde_entry.entry_type(), EntryType::Map); + let map_value = kde_entry.as_map().expect("kde.org should be a valid map"); + assert_eq!(map_value.len(), 2); + assert_eq!(map_value.get("key1"), Some(&"value1".to_string())); + assert_eq!(map_value.get("key2"), Some(&"value2".to_string())); +} + +#[test] +fn test_legacy_decryption_fails_with_invalid_password() { + let wallet_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/blowfish_ecb_sha1_empty_password.kwl"); + let wrong_password = b"wrongpassword"; + + let result = KWalletFile::open(&wallet_path, wrong_password); + assert!(result.is_err(), "Should fail with wrong password"); +} diff --git a/kwallet/parser/tests/blowfish_ecb_sha1_empty_password.kwl b/kwallet/parser/tests/blowfish_ecb_sha1_empty_password.kwl new file mode 100644 index 000000000..38e248a30 Binary files /dev/null and b/kwallet/parser/tests/blowfish_ecb_sha1_empty_password.kwl differ diff --git a/kwallet/parser/tests/blowfish_ecb_sha1_long_password.kwl b/kwallet/parser/tests/blowfish_ecb_sha1_long_password.kwl new file mode 100644 index 000000000..3cfb3da22 Binary files /dev/null and b/kwallet/parser/tests/blowfish_ecb_sha1_long_password.kwl differ diff --git a/server/Cargo.toml b/server/Cargo.toml index 8220be302..3e6ec8094 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -27,6 +27,7 @@ enumflags2 = "0.7" formatx = "0.2" gettext-rs = {version = "0.7", features = ["gettext-system"]} hkdf = { version = "0.12", optional = true } +kwallet-parser = { version = "0.6.0-alpha", path = "../kwallet/parser", optional = true } libc = "0.2" rustix = { version = "1.1", default-features = false, features = ["process", "std", "thread", "mm"] } num = "0.4.0" @@ -47,9 +48,10 @@ tempfile = { workspace = true, optional = true } [features] test-util = ["dep:tempfile"] -default = ["native_crypto"] +default = ["native_crypto", "kwallet_migration"] native_crypto = ["gnome_native_crypto", "plasma_native_crypto"] openssl_crypto = ["gnome_openssl_crypto", "plasma_openssl_crypto"] +kwallet_migration = ["dep:kwallet-parser"] gnome_native_crypto = [ "dep:base64", "dep:hkdf", diff --git a/server/src/collection/mod.rs b/server/src/collection/mod.rs index 61fd5d0a7..936e7a32e 100644 --- a/server/src/collection/mod.rs +++ b/server/src/collection/mod.rs @@ -32,6 +32,7 @@ pub struct Collection { created: Duration, modified: Arc>, // Other attributes + name: String, alias: Arc>, pub(crate) keyring: Arc>>, service: Service, @@ -386,7 +387,7 @@ impl Collection { ) -> zbus::Result<()>; } -fn collection_path(label: &str) -> Result { +pub(crate) fn collection_path(label: &str) -> Result { let sanitized_label = label.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "_"); OwnedObjectPath::try_from(format!( @@ -395,7 +396,13 @@ fn collection_path(label: &str) -> Result { } impl Collection { - pub async fn new(label: &str, alias: &str, service: Service, keyring: Keyring) -> Self { + pub async fn new( + name: &str, + label: &str, + alias: &str, + service: Service, + keyring: Keyring, + ) -> Self { let modified = keyring.modified_time().await; let created = keyring.created_time().await.unwrap_or(modified); @@ -403,9 +410,11 @@ impl Collection { items: Default::default(), label: Arc::new(Mutex::new(label.to_owned())), modified: Arc::new(Mutex::new(modified)), + name: name.to_owned(), alias: Arc::new(Mutex::new(alias.to_owned())), item_index: Arc::new(RwLock::new(0)), - path: collection_path(label).expect("Label should produce a valid object path"), + path: collection_path(label) + .expect("Label should already be sanitized and produce valid object path"), created, service, keyring: Arc::new(RwLock::new(Some(keyring))), @@ -416,6 +425,10 @@ impl Collection { &self.path } + pub fn name(&self) -> &str { + &self.name + } + pub async fn set_alias(&self, alias: &str) { *self.alias.lock().await = alias.to_owned(); } diff --git a/server/src/error.rs b/server/src/error.rs index d935b4205..311064eda 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -14,6 +14,9 @@ pub enum Error { EmptyPassword, // Capability error Capability(rustix::io::Errno), + // KWallet parser error + #[cfg(feature = "kwallet_migration")] + KWallet(kwallet_parser::Error), } impl std::error::Error for Error {} @@ -42,6 +45,13 @@ impl From for Error { } } +#[cfg(feature = "kwallet_migration")] +impl From for Error { + fn from(err: kwallet_parser::Error) -> Self { + Self::KWallet(err) + } +} + impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -50,6 +60,8 @@ impl fmt::Display for Error { Self::IO(err) => write!(f, "IO error {err}"), Self::EmptyPassword => write!(f, "Login password can't be empty"), Self::Capability(err) => write!(f, "Capability error {err}"), + #[cfg(feature = "kwallet_migration")] + Self::KWallet(err) => write!(f, "KWallet error {err}"), } } } diff --git a/server/src/gnome/internal.rs b/server/src/gnome/internal.rs index f581cbc6c..5c4d26d52 100644 --- a/server/src/gnome/internal.rs +++ b/server/src/gnome/internal.rs @@ -292,7 +292,6 @@ mod tests { label, "TestCollection", "Collection should have the correct label" ); - Ok(()) } diff --git a/server/src/lib.rs b/server/src/lib.rs index 9c7c5e1c3..43c857042 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -7,6 +7,7 @@ pub(crate) mod error; #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))] pub(crate) mod gnome; pub(crate) mod item; +pub(crate) mod migration; pub(crate) mod pam_listener; #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))] pub(crate) mod plasma; diff --git a/server/src/main.rs b/server/src/main.rs index f904f4775..9d6fec7eb 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -4,6 +4,7 @@ mod error; #[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))] mod gnome; mod item; +mod migration; mod pam_listener; #[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))] mod plasma; diff --git a/server/src/migration.rs b/server/src/migration.rs new file mode 100644 index 000000000..cbb933186 --- /dev/null +++ b/server/src/migration.rs @@ -0,0 +1,162 @@ +//! Keyring migration support for legacy formats + +use std::path::PathBuf; + +use oo7::{Secret, file::UnlockedKeyring}; + +use crate::error::Error; + +/// Pending keyring migration +#[derive(Clone, Debug)] +pub enum PendingMigration { + /// Legacy v0 keyring format + V0 { + name: String, + path: PathBuf, + label: String, + alias: String, + }, + /// KWallet keyring format + #[cfg(feature = "kwallet_migration")] + KWallet { + name: String, + path: PathBuf, + label: String, + alias: String, + }, +} + +impl PendingMigration { + /// Attempt to migrate this keyring with the provided secret + pub async fn migrate( + &self, + data_dir: &PathBuf, + secret: &Secret, + ) -> Result { + match self { + Self::V0 { path, name, .. } => { + tracing::debug!("Migrating v0 keyring: {}", name); + + let unlocked = UnlockedKeyring::open_at(data_dir, name, secret.clone()).await?; + + // Write migrated keyring + unlocked.write().await?; + tracing::info!("Wrote migrated keyring '{}' to disk", name); + + // Cleanup old file + if let Err(e) = tokio::fs::remove_file(path).await { + tracing::warn!("Failed to remove v0 keyring at {:?}: {}", path, e); + } else { + tracing::info!("Removed v0 keyring file at {:?}", path); + } + + tracing::info!("Successfully migrated v0 keyring '{}'", name); + Ok(unlocked) + } + #[cfg(feature = "kwallet_migration")] + Self::KWallet { path, name, .. } => { + tracing::debug!("Migrating KWallet keyring: {}", name); + + // Parse KWallet file in blocking task + let path_clone = path.clone(); + let password = secret.to_vec(); + let wallet = tokio::task::spawn_blocking(move || { + kwallet_parser::KWalletFile::open(&path_clone, &password) + }) + .await + .map_err(|e| { + Error::IO(std::io::Error::other(format!("Task join error: {}", e))) + })??; + + tracing::info!("Parsed KWallet file '{}'", name); + + // Create new oo7 keyring + let unlocked = UnlockedKeyring::open_at(data_dir, name, secret.clone()).await?; + + // Convert KWallet entries to oo7 items + let mut items = Vec::new(); + for (folder_name, folder) in wallet.wallet() { + for (entry_key, entry) in folder { + match kwallet_parser::convert_entry(folder_name, entry_key, entry) { + Ok(ss_entry) => { + items.push(( + ss_entry.label().to_owned(), + ss_entry.attributes().to_owned(), + Secret::blob(ss_entry.secret()), + true, + )); + } + Err(e) => { + tracing::warn!( + "Skipping entry {}/{}: {}", + folder_name, + entry_key, + e + ); + } + } + } + } + unlocked.create_items(items).await?; + + tracing::info!("Migrated KWallet entries to oo7 format for '{}'", name); + + // Cleanup old files + if let Err(e) = tokio::fs::remove_file(path).await { + tracing::warn!("Failed to remove KWallet file at {:?}: {}", path, e); + } else { + tracing::info!("Removed KWallet file at {:?}", path); + } + + // Try to remove salt file if it exists + let salt_path = path.with_extension("salt"); + if salt_path.exists() { + if let Err(e) = tokio::fs::remove_file(&salt_path).await { + tracing::warn!( + "Failed to remove KWallet salt file at {:?}: {}", + salt_path, + e + ); + } else { + tracing::info!("Removed KWallet salt file at {:?}", salt_path); + } + } + + tracing::info!("Successfully migrated KWallet keyring '{}'", name); + Ok(unlocked) + } + } + } + + pub fn name(&self) -> &str { + match self { + Self::V0 { name, .. } => name, + #[cfg(feature = "kwallet_migration")] + Self::KWallet { name, .. } => name, + } + } + + pub fn label(&self) -> &str { + match self { + Self::V0 { label, .. } => label, + #[cfg(feature = "kwallet_migration")] + Self::KWallet { label, .. } => label, + } + } + + pub fn alias(&self) -> &str { + match self { + Self::V0 { alias, .. } => alias, + #[cfg(feature = "kwallet_migration")] + Self::KWallet { alias, .. } => alias, + } + } + + pub fn path(&self) -> &PathBuf { + match self { + Self::V0 { path, .. } => path, + #[cfg(feature = "kwallet_migration")] + Self::KWallet { path, .. } => path, + } + } +} diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 5275397b4..08de7aa21 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -29,6 +29,7 @@ use crate::plasma::prompter::in_plasma_environment; use crate::{ collection::Collection, error::{Error, custom_service_error}, + migration::PendingMigration, prompt::{Prompt, PromptAction, PromptRole}, session::Session, }; @@ -58,10 +59,8 @@ pub struct Service { prompt_index: Arc>, // pending collection creations: prompt_path -> (label, alias) pending_collections: Arc>>, - // pending v0 keyring migrations: name -> (path, label, alias) - #[allow(clippy::type_complexity)] - pub(crate) pending_migrations: - Arc>>, + // pending keyring migrations: name -> migration + pub(crate) pending_migrations: Arc>>, // Data directory for keyrings (e.g., ~/.local/share or test temp dir) data_dir: std::path::PathBuf, // PAM socket path (None for tests that don't need PAM listener) @@ -241,29 +240,98 @@ impl Service { let action = PromptAction::new(move |secret: Secret| async move { // The prompter will handle secret validation // Here we just perform the unlock operation - let collections = service.collections.lock().await; + + // First, check for pending migrations (without holding collections lock) for object in ¬_unlocked { - // Try to find as collection first - if let Some(collection) = collections.get(object) { - let _ = collection.set_locked(false, Some(secret.clone())).await; + let collection = { + let collections = service.collections.lock().await; + collections.get(object).cloned() + }; + + if let Some(collection) = collection { + // Check if this collection has a pending migration by name + let migration_opt = { + let pending = service.pending_migrations.lock().await; + pending.get(collection.name()).cloned() + }; + + if let Some(migration) = migration_opt { + let migration_name = migration.name(); + tracing::debug!( + "Attempting migration for '{}' during unlock", + migration_name + ); + + // Attempt migration with the provided secret (no locks held) + match migration.migrate(&service.data_dir, &secret).await { + Ok(unlocked_keyring) => { + tracing::info!( + "Successfully migrated '{}' during unlock", + migration_name + ); + + // Replace the keyring in the collection + let mut keyring_guard = collection.keyring.write().await; + *keyring_guard = Some(Keyring::Unlocked(unlocked_keyring)); + drop(keyring_guard); + + // Dispatch items from the migrated keyring + if let Err(e) = collection.dispatch_items().await { + tracing::error!( + "Failed to dispatch items after migration: {}", + e + ); + } + + // Remove from pending migrations + service + .pending_migrations + .lock() + .await + .remove(migration_name); + } + Err(e) => { + tracing::warn!( + "Failed to migrate '{}' during unlock: {}", + migration_name, + e + ); + // Leave in pending_migrations, try normal unlock + let _ = + collection.set_locked(false, Some(secret.clone())).await; + } + } + } else { + // Normal unlock + let _ = collection.set_locked(false, Some(secret.clone())).await; + } } else { // Try to find as item within collections + let collections = service.collections.lock().await; + let mut found_collection = None; for (_path, collection) in collections.iter() { if let Some(item) = collection.item_from_path(object).await { - // If the collection is locked, unlock it - if collection.is_locked().await { - let _ = - collection.set_locked(false, Some(secret.clone())).await; - } else { - // Collection is already unlocked, just unlock the item - let keyring = collection.keyring.read().await; - let _ = item - .set_locked(false, keyring.as_ref().unwrap().as_unlocked()) - .await; - } + found_collection = Some(( + collection.clone(), + item.clone(), + collection.is_locked().await, + )); break; } } + drop(collections); + + if let Some((collection, item, is_locked)) = found_collection { + if is_locked { + let _ = collection.set_locked(false, Some(secret.clone())).await; + } else { + // Collection is already unlocked, just unlock the item + let keyring = collection.keyring.read().await; + let _ = item + .set_locked(false, keyring.as_ref().unwrap().as_unlocked()) + .await; + } + } } } Ok(Value::new(not_unlocked).try_into_owned().unwrap()) @@ -540,6 +608,7 @@ impl Service { let default_keyring = if let Some(secret) = secret.clone() { vec![( + "default".to_owned(), "Login".to_owned(), oo7::dbus::Service::DEFAULT_COLLECTION.to_owned(), Keyring::Unlocked(UnlockedKeyring::temporary(secret).await?), @@ -554,12 +623,42 @@ impl Service { Ok(service) } + /// Generate a unique label and alias by checking registered + /// collections and appending a counter if needed. Returns a tuple of + /// (label, alias). + fn make_unique_label_and_alias( + collections: &HashMap, + label: &str, + alias: &str, + ) -> (String, String) { + // Sanitize the label to create the path (for checking uniqueness) + let base_path = crate::collection::collection_path(label) + .expect("Sanitized label should always produce valid object path"); + if !collections.contains_key(&base_path) { + return (label.to_owned(), alias.to_owned()); + } + + // Append counter until we find a unique one + let mut counter = 2; + loop { + let path = crate::collection::collection_path(&format!("{label}{counter}")) + .expect("Sanitized label should always produce valid object path"); + let new_label = format!("{}{}", label, counter); + let new_alias = format!("{}{}", alias, counter); + + if !collections.contains_key(&path) { + return (new_label, new_alias); + } + counter += 1; + } + } + /// Discover existing keyrings in the data directory - /// Returns a vector of (label, alias, keyring) tuples + /// Returns a vector of (name, label, alias, keyring) tuples pub(crate) async fn discover_keyrings( &self, secret: Option, - ) -> Result, Error> { + ) -> Result, Error> { let mut discovered = Vec::new(); let keyrings_dir = self.data_dir.join("keyrings"); @@ -582,7 +681,9 @@ impl Service { // Try to load the keyring match self.load_keyring(&path, name, secret.as_ref()).await { - Ok((label, alias, keyring)) => discovered.push((label, alias, keyring)), + Ok((name, label, alias, keyring)) => { + discovered.push((name, label, alias, keyring)) + } Err(e) => tracing::warn!("Failed to load keyring {:?}: {}", path, e), } } @@ -607,7 +708,9 @@ impl Service { // Try to load the keyring match self.load_keyring(&path, name, secret.as_ref()).await { - Ok((label, alias, keyring)) => discovered.push((label, alias, keyring)), + Ok((name, label, alias, keyring)) => { + discovered.push((name, label, alias, keyring)) + } Err(e) => tracing::warn!("Failed to load keyring {:?}: {}", path, e), } } @@ -615,29 +718,138 @@ impl Service { } } + // Discover KWallet keyrings for migration + #[cfg(feature = "kwallet_migration")] + self.discover_kwallet_keyrings(&self.data_dir, secret.as_ref(), &mut discovered) + .await; + let pending_count = self.pending_migrations.lock().await.len(); if discovered.is_empty() && pending_count == 0 { tracing::info!("No keyrings discovered in data directory"); } else { tracing::info!( - "Discovered {} keyring(s), {} pending v0 migration(s)", + "Discovered {} keyring(s), {pending_count} pending migration(s)", discovered.len(), - pending_count ); } Ok(discovered) } + /// Discover KWallet keyrings for migration + #[cfg(feature = "kwallet_migration")] + async fn discover_kwallet_keyrings( + &self, + data_dir: &std::path::Path, + secret: Option<&Secret>, + discovered: &mut Vec<(String, String, String, Keyring)>, + ) { + let kwallet_dir = data_dir.join("kwalletd"); + + if !kwallet_dir.exists() { + tracing::debug!("No kwalletd directory found, skipping KWallet discovery"); + return; + } + + tracing::debug!("Scanning for KWallet files in {}", kwallet_dir.display()); + + let Ok(mut entries) = tokio::fs::read_dir(&kwallet_dir).await else { + tracing::warn!("Failed to read kwalletd directory"); + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + + // Only process .kwl files + if path.extension().is_none_or(|ext| ext != "kwl") { + continue; + } + + let Some(name) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + + tracing::debug!("Found KWallet file: {name}"); + + // Use lowercased name as alias + let alias = name.to_lowercase(); + + let label = { + let mut chars = name.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + } + }; + + let migration = PendingMigration::KWallet { + name: name.to_owned(), + path: path.clone(), + label: label.clone(), + alias: alias.clone(), + }; + + if let Some(secret) = secret { + tracing::debug!("Attempting immediate migration of KWallet keyring '{name}'",); + match migration.migrate(&self.data_dir, secret).await { + Ok(unlocked) => { + tracing::info!("Successfully migrated KWallet keyring '{name}' to oo7",); + discovered.push(( + name.to_owned(), + label, + alias, + Keyring::Unlocked(unlocked), + )); + continue; + } + Err(e) => { + tracing::warn!( + "Failed to migrate KWallet keyring '{name}' at {}: {e}. Creating locked placeholder collection.", + migration.path().display() + ); + } + } + } + + // Migration failed or no secret - create locked placeholder and register for + // pending migration + tracing::debug!( + "Creating locked placeholder for KWallet keyring '{name}', will migrate on unlock", + ); + + match LockedKeyring::open_at(&self.data_dir, name).await { + Ok(locked) => { + tracing::debug!( + "Created locked placeholder for '{name}', adding to pending migrations", + ); + discovered.push(( + name.to_owned(), + label.clone(), + alias.clone(), + Keyring::Locked(locked), + )); + self.pending_migrations + .lock() + .await + .insert(name.to_owned(), migration); + } + Err(e) => { + tracing::error!("Failed to create placeholder keyring for '{name}': {e}"); + } + } + } + } + /// Load a single keyring from a file path - /// Returns (label, alias, keyring) + /// Returns (name, label, alias, keyring) async fn load_keyring( &self, path: &std::path::Path, name: &str, secret: Option<&Secret>, - ) -> Result<(String, String, Keyring), Error> { + ) -> Result<(String, String, String, Keyring), Error> { let alias = if name.eq_ignore_ascii_case(Self::LOGIN_ALIAS) { oo7::dbus::Service::DEFAULT_COLLECTION.to_owned() } else { @@ -688,6 +900,13 @@ impl Service { path.display() ); + let migration = PendingMigration::V0 { + name: name.to_owned(), + path: path.to_path_buf(), + label: label.clone(), + alias: alias.clone(), + }; + if let Some(secret) = secret { tracing::debug!("Attempting immediate migration of v0 keyring '{name}'",); match UnlockedKeyring::open_at(&self.data_dir, name, secret.clone()).await { @@ -708,39 +927,42 @@ impl Service { tracing::info!("Removed v0 keyring file at {}", path.display()); } - Keyring::Unlocked(unlocked) + return Ok(( + name.to_owned(), + label, + alias, + Keyring::Unlocked(unlocked), + )); } Err(e) => { tracing::warn!( - "Failed to migrate v0 keyring '{name}': {e}. Will retry when secret is available.", + "Failed to migrate v0 keyring '{name}': {e}. Creating locked placeholder collection.", ); - self.pending_migrations.lock().await.insert( - name.to_owned(), - (path.to_path_buf(), label.clone(), alias.clone()), - ); - return Err(e.into()); } } - } else { - tracing::debug!( - "No secret available for v0 keyring '{}', registering for pending migration", - name - ); - self.pending_migrations.lock().await.insert( - name.to_owned(), - (path.to_path_buf(), label.clone(), alias.clone()), - ); - return Err(Error::IO(std::io::Error::other( - "v0 keyring requires migration, no secret available", - ))); } + + // Migration failed or no secret - create locked placeholder and register for + // pending migration + tracing::debug!( + "Creating locked placeholder for v0 keyring '{}', will migrate on unlock", + name + ); + + let locked = LockedKeyring::open(name).await?; + self.pending_migrations + .lock() + .await + .insert(name.to_owned(), migration); + + Keyring::Locked(locked) } Err(e) => { return Err(e.into()); } }; - Ok((label, alias, keyring)) + Ok((name.to_owned(), label, alias, keyring)) } /// Initialize the service with collections and start client disconnect @@ -748,7 +970,8 @@ impl Service { pub(crate) async fn initialize( &self, connection: zbus::Connection, - mut discovered_keyrings: Vec<(String, String, Keyring)>, // (name, alias, keyring) + mut discovered_keyrings: Vec<(String, String, String, Keyring)>, /* (name, label, alias, + * keyring) */ secret: Option, auto_create_default: bool, ) -> Result<(), Error> { @@ -758,7 +981,7 @@ impl Service { let mut collections = self.collections.lock().await; // Check if we have a default collection - let has_default = discovered_keyrings.iter().any(|(_, alias, _)| { + let has_default = discovered_keyrings.iter().any(|(_, _, alias, _)| { alias == oo7::dbus::Service::DEFAULT_COLLECTION || alias == Self::LOGIN_ALIAS }); @@ -785,6 +1008,7 @@ impl Service { "unlocked" }; discovered_keyrings.push(( + Self::LOGIN_ALIAS.to_owned(), "Login".to_owned(), oo7::dbus::Service::DEFAULT_COLLECTION.to_owned(), keyring, @@ -794,8 +1018,11 @@ impl Service { } // Set up discovered collections - for (label, alias, keyring) in discovered_keyrings { - let collection = Collection::new(&label, &alias, self.clone(), keyring).await; + for (name, label, alias, keyring) in discovered_keyrings { + let (unique_label, unique_alias) = + Self::make_unique_label_and_alias(&collections, &label, &alias); + let collection = + Collection::new(&name, &unique_label, &unique_alias, self.clone(), keyring).await; collections.insert(collection.path().to_owned().into(), collection.clone()); collection.dispatch_items().await?; object_server @@ -803,7 +1030,7 @@ impl Service { .await?; // If this is the default collection, also register it at the alias path - if alias == oo7::dbus::Service::DEFAULT_COLLECTION { + if unique_alias == oo7::dbus::Service::DEFAULT_COLLECTION { object_server .at(DEFAULT_COLLECTION_ALIAS_PATH, collection) .await?; @@ -812,6 +1039,7 @@ impl Service { // Always create session collection (always temporary) let collection = Collection::new( + "session", "session", oo7::dbus::Service::SESSION_COLLECTION, self.clone(), @@ -1018,9 +1246,15 @@ impl Service { .map_err(|err| custom_service_error(&format!("Failed to write keyring file: {err}")))?; let keyring = Keyring::Unlocked(keyring); + let name = label.to_lowercase(); - // Create the collection - let collection = Collection::new(label, alias, self.clone(), keyring).await; + // Create the collection with unique label and alias + let (unique_label, unique_alias) = { + let collections = self.collections.lock().await; + Self::make_unique_label_and_alias(&collections, label, alias) + }; + let collection = + Collection::new(&name, &unique_label, &unique_alias, self.clone(), keyring).await; let collection_path: OwnedObjectPath = collection.path().to_owned().into(); // Register with object server @@ -1141,53 +1375,36 @@ impl Service { .await } - /// Attempt to migrate pending v0 keyrings with the provided secret + /// Attempt to migrate pending keyrings with the provided secret /// Returns a list of successfully migrated keyring names pub async fn migrate_pending_keyrings(&self, secret: &Secret) -> Vec { let mut migrated = Vec::new(); let mut pending = self.pending_migrations.lock().await; let mut to_remove = Vec::new(); - for (name, (path, label, alias)) in pending.iter() { - tracing::debug!("Attempting to migrate pending v0 keyring: {}", name); + for (name, migration) in pending.iter() { + tracing::debug!("Attempting to migrate pending keyring: {name}"); - match UnlockedKeyring::open_at(&self.data_dir, name, secret.clone()).await { + match migration.migrate(&self.data_dir, secret).await { Ok(unlocked) => { - tracing::info!("Successfully migrated v0 keyring '{}' to v1", name); - - // Write the migrated keyring to disk - match unlocked.write().await { - Ok(_) => { - tracing::info!("Wrote migrated keyring '{}' to disk", name); - - // Remove the v0 keyring file after successful migration - if let Err(e) = tokio::fs::remove_file(path).await { - tracing::warn!("Failed to remove v0 keyring at {:?}: {}", path, e); - } else { - tracing::info!("Removed v0 keyring file at {:?}", path); - } - } - Err(e) => { - tracing::error!( - "Failed to write migrated keyring '{}' to disk: {}", - name, - e - ); - continue; - } - } + let label = migration.label(); + let alias = migration.alias(); - // Create a collection for this migrated keyring + // Create a collection for this migrated keyring with unique label and alias + let (unique_label, unique_alias) = { + let collections = self.collections.lock().await; + Self::make_unique_label_and_alias(&collections, label, alias) + }; let keyring = Keyring::Unlocked(unlocked); - let collection = Collection::new(label, alias, self.clone(), keyring).await; + let collection = + Collection::new(name, &unique_label, &unique_alias, self.clone(), keyring) + .await; let collection_path: OwnedObjectPath = collection.path().to_owned().into(); // Dispatch items if let Err(e) = collection.dispatch_items().await { tracing::error!( - "Failed to dispatch items for migrated keyring '{}': {}", - name, - e + "Failed to dispatch items for migrated keyring '{name}': {e}", ); continue; } @@ -1198,9 +1415,7 @@ impl Service { .await { tracing::error!( - "Failed to register migrated collection '{}' with object server: {}", - name, - e + "Failed to register migrated collection '{name}' with object server: {e}", ); continue; } @@ -1217,9 +1432,7 @@ impl Service { .await { tracing::error!( - "Failed to register default alias for migrated collection '{}': {}", - name, - e + "Failed to register default alias for migrated collection '{name}': {e}", ); } @@ -1231,15 +1444,14 @@ impl Service { let _ = self.collections_changed(&signal_emitter).await; } - tracing::info!("Migrated keyring '{}' added as collection", name); + tracing::info!("Migrated keyring '{name}' added as collection",); migrated.push(name.clone()); to_remove.push(name.clone()); } Err(e) => { tracing::debug!( - "Failed to migrate v0 keyring '{}' with provided secret: {}", - name, - e + "Failed to migrate keyring '{name}' found at {} with provided secret: {e}", + migration.path().display() ); } } diff --git a/server/src/service/tests.rs b/server/src/service/tests.rs index 495b8b70c..038fbfc70 100644 --- a/server/src/service/tests.rs +++ b/server/src/service/tests.rs @@ -868,7 +868,6 @@ async fn create_collection_basic_impl( ); tokio::fs::remove_file(keyring_path).await?; - Ok(()) } @@ -998,7 +997,6 @@ async fn create_collection_and_add_items_impl( let keyring_guard = server_collection.keyring.read().await; let keyring_path = keyring_guard.as_ref().unwrap().path().unwrap(); tokio::fs::remove_file(&keyring_path).await?; - Ok(()) } @@ -1148,7 +1146,7 @@ async fn discover_v1_keyrings() -> Result<(), Box> { // Test 2: Discover without any password, all should be locked let discovered = service.discover_keyrings(None).await?; assert_eq!(discovered.len(), 3, "Should discover 3 keyrings"); - for (_, _, keyring) in &discovered { + for (_, _, _, keyring) in &discovered { assert!( keyring.is_locked(), "All keyrings should be locked without secret" @@ -1161,41 +1159,41 @@ async fn discover_v1_keyrings() -> Result<(), Box> { let work_keyring = discovered .iter() - .find(|(label, _, _)| label == "Work") + .find(|(_, label, _, _)| label == "Work") .unwrap(); assert!( - !work_keyring.2.is_locked(), + !work_keyring.3.is_locked(), "Work keyring should be unlocked with correct password" ); let personal_keyring = discovered .iter() - .find(|(label, _, _)| label == "Personal") + .find(|(_, label, _, _)| label == "Personal") .unwrap(); assert!( - personal_keyring.2.is_locked(), + personal_keyring.3.is_locked(), "Personal keyring should be locked with wrong password" ); // Test 4: Verify login keyring gets default alias let login_keyring = discovered .iter() - .find(|(label, _, _)| label == "Login") + .find(|(_, label, _, _)| label == "Login") .unwrap(); assert_eq!( - login_keyring.1, + login_keyring.2, oo7::dbus::Service::DEFAULT_COLLECTION, "Login keyring should have default alias" ); assert!( - login_keyring.2.is_locked(), + login_keyring.3.is_locked(), "Login keyring should be locked with wrong password" ); // Test 5: Verify labels are properly capitalized let labels: Vec<_> = discovered .iter() - .map(|(label, _, _)| label.as_str()) + .map(|(_, label, _, _)| label.as_str()) .collect(); assert!(labels.contains(&"Work"), "Should have Work with capital W"); assert!( @@ -1210,6 +1208,103 @@ async fn discover_v1_keyrings() -> Result<(), Box> { Ok(()) } +#[cfg(any(feature = "gnome_native_crypto", feature = "gnome_openssl_crypto"))] +#[tokio::test] +async fn unlock_pending_v0_migration_gnome() -> Result<(), Box> { + unlock_pending_v0_migration_impl(PrompterType::GNOME).await +} + +#[cfg(any(feature = "plasma_native_crypto", feature = "plasma_openssl_crypto"))] +#[tokio::test] +async fn unlock_pending_v0_migration_plasma() -> Result<(), Box> { + unlock_pending_v0_migration_impl(PrompterType::Plasma).await +} + +async fn unlock_pending_v0_migration_impl( + prompter_type: PrompterType, +) -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + + let keyrings_dir = temp_dir.path().join("keyrings"); + let v1_dir = keyrings_dir.join("v1"); + tokio::fs::create_dir_all(&v1_dir).await?; + + // Copy the v0 keyring fixture + let v0_secret = Secret::from("test"); + let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("client/fixtures/legacy.keyring"); + let v0_path = keyrings_dir.join("legacy.keyring"); + tokio::fs::copy(&fixture_path, &v0_path).await?; + + // Create test service that discovers keyrings WITHOUT the password + let setup = crate::tests::TestServiceSetup::with_disk_keyrings( + temp_dir.path().to_path_buf(), + None, + None, + ) + .await?; + setup.server.set_prompter_type(prompter_type).await; + + // Verify v0 is pending migration + assert_eq!( + setup.server.pending_migrations.lock().await.len(), + 1, + "V0 keyring should be pending migration" + ); + + // Find the placeholder collection + let collections = setup.service_api.collections().await?; + let mut legacy_collection = None; + for collection in &collections { + if collection.label().await? == "Legacy" { + legacy_collection = Some(collection); + break; + } + } + let legacy_collection = legacy_collection.expect("Should have Legacy placeholder collection"); + + // Verify it's locked + assert!( + legacy_collection.is_locked().await?, + "Placeholder should be locked" + ); + + // Set up mock prompter to provide the correct password + setup.set_password_queue(vec![v0_secret.clone()]).await; + + // Unlock via D-Bus API (should trigger migration) + let unlocked = setup + .service_api + .unlock(&[legacy_collection.inner().path()], None) + .await?; + + assert_eq!(unlocked.len(), 1, "Should have unlocked the collection"); + assert!( + !legacy_collection.is_locked().await?, + "Collection should be unlocked" + ); + + // Verify migration happened + assert_eq!( + setup.server.pending_migrations.lock().await.len(), + 0, + "Should have no pending migrations after unlock" + ); + + // Verify v1 file was created + let v1_migrated = v1_dir.join("legacy.keyring"); + assert!(v1_migrated.exists(), "V1 file should exist after migration"); + + // Verify v0 file was removed + assert!( + !v0_path.exists(), + "V0 file should be removed after migration" + ); + Ok(()) +} + #[tokio::test] async fn discover_v0_keyrings() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -1244,8 +1339,11 @@ async fn discover_v0_keyrings() -> Result<(), Box> { // Test 1: Discover without secret, v0 marked for migration, v1 locked let discovered = service.discover_keyrings(None).await?; - assert_eq!(discovered.len(), 1, "Should discover v1 keyring only"); - assert!(discovered[0].2.is_locked(), "V1 should be locked"); + assert_eq!( + discovered.len(), + 2, + "Should discover v1 keyring + v0 placeholder" + ); let pending = service.pending_migrations.lock().await; assert_eq!(pending.len(), 1, "V0 should be pending migration"); @@ -1257,8 +1355,11 @@ async fn discover_v0_keyrings() -> Result<(), Box> { let discovered = service.discover_keyrings(Some(v0_secret.clone())).await?; assert_eq!(discovered.len(), 2, "Should discover both keyrings"); - let legacy = discovered.iter().find(|(l, _, _)| l == "Legacy").unwrap(); - assert!(!legacy.2.is_locked(), "V0 should be migrated and unlocked"); + let legacy = discovered + .iter() + .find(|(_, l, _, _)| l == "Legacy") + .unwrap(); + assert!(!legacy.3.is_locked(), "V0 should be migrated and unlocked"); assert_eq!( service.pending_migrations.lock().await.len(), 0, @@ -1280,8 +1381,8 @@ async fn discover_v0_keyrings() -> Result<(), Box> { let discovered = service.discover_keyrings(Some(wrong_secret)).await?; assert_eq!( discovered.len(), - 1, - "Only v1 should be discovered with wrong v0 password" + 2, + "V1 + v0 placeholder should be discovered with wrong v0 password" ); assert_eq!( service.pending_migrations.lock().await.len(), @@ -1291,3 +1392,119 @@ async fn discover_v0_keyrings() -> Result<(), Box> { Ok(()) } + +#[cfg(feature = "kwallet_migration")] +#[tokio::test] +async fn discover_kwallet_keyrings() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let service = Service::new(temp_dir.path().to_path_buf(), None); + + let kwallet_dir = temp_dir.path().join("kwalletd"); + let v1_dir = temp_dir.path().join("keyrings/v1"); + tokio::fs::create_dir_all(&kwallet_dir).await?; + tokio::fs::create_dir_all(&v1_dir).await?; + + // Copy KWallet test fixture + let kwallet_secret = Secret::from("password"); + let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("kwallet/parser/tests/blowfish_cbc_pbkdf2_sha512_manual.kwl"); + let kwallet_path = kwallet_dir.join("kdewallet.kwl"); + tokio::fs::copy(&fixture_path, &kwallet_path).await?; + + // Copy the salt file too (required for PBKDF2-SHA512) + let salt_fixture = fixture_path.with_extension("salt"); + let salt_path = kwallet_path.with_extension("salt"); + tokio::fs::copy(&salt_fixture, &salt_path).await?; + + // Create a v1 keyring for mixed scenario + let v1_secret = Secret::from("v1-password"); + let v1_keyring = UnlockedKeyring::open_at(temp_dir.path(), "modern", v1_secret.clone()).await?; + v1_keyring + .create_item( + "V1 Item", + &[("type", "v1")], + Secret::text("v1-secret"), + false, + ) + .await?; + v1_keyring.write().await?; + + // Test 1: Discover without secret, KWallet marked for migration, v1 locked + let discovered = service.discover_keyrings(None).await?; + assert_eq!( + discovered.len(), + 2, + "Should discover v1 keyring + kwallet placeholder" + ); + + let pending = service.pending_migrations.lock().await; + assert_eq!(pending.len(), 1, "KWallet should be pending migration"); + assert!(pending.contains_key("kdewallet")); + drop(pending); + + // Test 2: Discover with KWallet secret, KWallet migrated, v1 locked + service.pending_migrations.lock().await.clear(); + let discovered = service + .discover_keyrings(Some(kwallet_secret.clone())) + .await?; + assert_eq!(discovered.len(), 2, "Should discover both keyrings"); + + let kdewallet = discovered + .iter() + .find(|(_, l, _, _)| l == "Kdewallet") + .unwrap(); + assert!( + !kdewallet.3.is_locked(), + "KWallet should be migrated and unlocked" + ); + assert_eq!( + kdewallet.2, "kdewallet", + "kdewallet should have kdewallet alias (not default)" + ); + assert_eq!( + service.pending_migrations.lock().await.len(), + 0, + "No pending after successful migration" + ); + + // Verify v1 file was created + let v1_migrated = temp_dir.path().join("keyrings/v1/kdewallet.keyring"); + assert!(v1_migrated.exists(), "V1 file should exist after migration"); + + // Verify old KWallet files were removed + assert!( + !kwallet_path.exists(), + "Original .kwl file should be removed" + ); + assert!(!salt_path.exists(), "Original .salt file should be removed"); + + // Test 3: Discover with wrong KWallet secret, marked for pending migration + tokio::fs::remove_file(&v1_migrated).await?; + service.pending_migrations.lock().await.clear(); + + // Restore the KWallet files for this test + tokio::fs::copy(&fixture_path, &kwallet_path).await?; + tokio::fs::copy(&salt_fixture, &salt_path).await?; + + let wrong_secret = Secret::from("wrong-password"); + let discovered = service.discover_keyrings(Some(wrong_secret)).await?; + assert_eq!( + discovered.len(), + 2, + "V1 + kwallet placeholder should be discovered with wrong KWallet password" + ); + assert_eq!( + service.pending_migrations.lock().await.len(), + 1, + "KWallet should be pending with wrong password" + ); + + // Verify the pending migration has the correct type + let pending = service.pending_migrations.lock().await; + let migration = pending.get("kdewallet").unwrap(); + assert_eq!(migration.label(), "Kdewallet"); + assert_eq!(migration.alias(), "kdewallet"); + Ok(()) +}