diff --git a/Cargo.lock b/Cargo.lock index 2f978ec2..8cf8a346 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3512,6 +3512,7 @@ name = "pebble-crypto" version = "0.1.0" dependencies = [ "aes-gcm", + "hex", "keyring", "pebble-core", "rand 0.8.5", diff --git a/Cargo.toml b/Cargo.toml index 438d19dd..6de5b0d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ tokio-socks = "0.5" aes-gcm = "0.10" rand = "0.8" keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] } +hex = "0.4" semver = "1" [profile.release] diff --git a/crates/pebble-crypto/Cargo.toml b/crates/pebble-crypto/Cargo.toml index d21eb6dd..a9777681 100644 --- a/crates/pebble-crypto/Cargo.toml +++ b/crates/pebble-crypto/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" pebble-core = { path = "../pebble-core" } aes-gcm = { workspace = true } rand = { workspace = true } +hex = { workspace = true } keyring = { workspace = true } tracing = { workspace = true } zeroize = { version = "1", features = ["zeroize_derive"] } diff --git a/crates/pebble-crypto/src/keystore.rs b/crates/pebble-crypto/src/keystore.rs index cb8471a6..47e80c5c 100644 --- a/crates/pebble-crypto/src/keystore.rs +++ b/crates/pebble-crypto/src/keystore.rs @@ -1,43 +1,62 @@ use pebble_core::{PebbleError, Result}; use rand::RngCore; -use tracing::info; +use tracing::{info, warn}; use zeroize::Zeroizing; const SERVICE_NAME: &str = "com.pebble.email"; const KEY_ENTRY: &str = "master-dek"; +const DEK_LEN: usize = 32; pub struct KeyStore; impl KeyStore { /// Get or create the Data Encryption Key from the OS credential store. - pub fn get_or_create_dek() -> Result> { + /// + /// The raw 32-byte key is hex-encoded before storing so it can round-trip + /// safely through string-based keychain backends and survive kernel-keyring + /// serialisation. + pub fn get_or_create_dek() -> Result> { let entry = keyring::Entry::new(SERVICE_NAME, KEY_ENTRY) .map_err(|e| PebbleError::Auth(format!("Keyring entry error: {e}")))?; match entry.get_secret() { - Ok(secret) => { + Ok(secret) if secret.len() == DEK_LEN => { + // Legacy raw 32-byte DEK — migrate to hex encoding so the + // key can round-trip through string-based keychain backends. let secret = Zeroizing::new(secret); - if secret.len() != 32 { - return Err(PebbleError::Auth(format!( - "Invalid DEK length: expected 32, got {}", - secret.len() - ))); - } - let mut key = Zeroizing::new([0u8; 32]); + let mut key = Zeroizing::new([0u8; DEK_LEN]); key.copy_from_slice(&secret); - Ok(key) + let hex_key = Zeroizing::new(hex::encode(&key[..])); + if let Err(e) = entry.set_secret(hex_key.as_bytes()) { + warn!("Failed to migrate legacy DEK to hex encoding: {e}"); + } + return Ok(key); + } + Ok(hex_secret) => { + let hex_secret = Zeroizing::new(hex_secret); + if let Ok(key) = decode_hex(&hex_secret) { + return Ok(key); + } + warn!( + "DEK stored with unexpected format (len={}), regenerating", + hex_secret.len() + ); + let _ = entry.delete_credential(); } Err(keyring::Error::NoEntry) => { - info!("No DEK found, generating new one"); - let mut key = Zeroizing::new([0u8; 32]); - rand::thread_rng().fill_bytes(&mut *key); - entry - .set_secret(&*key) - .map_err(|e| PebbleError::Auth(format!("Failed to store DEK: {e}")))?; - Ok(key) + // expected first-run path } - Err(e) => Err(PebbleError::Auth(format!("Keyring read error: {e}"))), + Err(e) => return Err(PebbleError::Auth(format!("Keyring read error: {e}"))), } + + info!("No usable DEK found, generating new one"); + let mut key = Zeroizing::new([0u8; DEK_LEN]); + rand::thread_rng().fill_bytes(&mut *key); + let hex_key = Zeroizing::new(hex::encode(&key[..])); + entry + .set_secret(hex_key.as_bytes()) + .map_err(|e| PebbleError::Auth(format!("Failed to store DEK: {e}")))?; + Ok(key) } /// Delete the DEK from the OS credential store. @@ -51,3 +70,15 @@ impl KeyStore { } } } + +/// Decode a 32-byte key from its hex representation. +fn decode_hex(hex_data: &[u8]) -> std::result::Result, ()> { + let hex_str = std::str::from_utf8(hex_data).map_err(|_| ())?; + let bytes = Zeroizing::new(hex::decode(hex_str).map_err(|_| ())?); + if bytes.len() != DEK_LEN { + return Err(()); + } + let mut key = Zeroizing::new([0u8; DEK_LEN]); + key.copy_from_slice(&bytes); + Ok(key) +} diff --git a/package.json b/package.json index 1067896c..ec2e66b5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "node scripts/build-tauri.mjs", "build:windows": "tauri build --bundles nsis", "build:macos": "tauri build --bundles app,dmg", - "build:linux": "tauri build --bundles appimage", + "build:linux": "NO_STRIP=1 tauri build --bundles appimage", "test": "vitest run", "test:watch": "vitest" }, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a97919c0..3186d09c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -230,6 +230,26 @@ fn take_pending_mailto_urls(state: tauri::State) -> Vec