Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions crates/pebble-crypto/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
69 changes: 50 additions & 19 deletions crates/pebble-crypto/src/keystore.rs
Original file line number Diff line number Diff line change
@@ -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<Zeroizing<[u8; 32]>> {
///
/// 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<Zeroizing<[u8; DEK_LEN]>> {
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.
Expand All @@ -51,3 +70,15 @@ impl KeyStore {
}
}
}

/// Decode a 32-byte key from its hex representation.
fn decode_hex(hex_data: &[u8]) -> std::result::Result<Zeroizing<[u8; DEK_LEN]>, ()> {
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)
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
20 changes: 20 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,26 @@ fn take_pending_mailto_urls(state: tauri::State<PendingMailtoUrls>) -> Vec<Strin

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Prefer native Wayland when a Wayland compositor is available.
//
// Two cases:
// 1. No GDK_BACKEND set at all — safe to default to wayland.
// 2. AppImage: the bundled GTK plugin hardcodes GDK_BACKEND=x11 in
// its AppRun hook (see tauri#8541). Detect this via the APPDIR
// env var and override the AppImage default so the app actually
// runs on Wayland when a compositor is present.
// We do NOT override any other explicit GDK_BACKEND value (e.g. a
// user who intentionally set GDK_BACKEND=x11 outside of AppImage).
#[cfg(target_os = "linux")]
if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) {
let gdk_backend = std::env::var_os("GDK_BACKEND");
let is_appimage_x11 = std::env::var_os("APPDIR").is_some()
&& gdk_backend.as_deref() == Some(std::ffi::OsStr::new("x11"));
if gdk_backend.is_none() || is_appimage_x11 {
std::env::set_var("GDK_BACKEND", "wayland,x11");
}
}

let mut builder = tauri::Builder::default();

#[cfg(desktop)]
Expand Down