--- a/crates/xodus/src/secrets.rs +++ b/crates/xodus/src/secrets.rs @@ -1,73 +1,14 @@ -pub static SERVICE_NAME: &str = "Xodus Service"; +use crate::tokens::{backend::KeychainBackend, store::{TokenBackend, TokenStoreError}}; pub fn init_secrets() -> Result<(), keyring_core::Error> { - #[cfg(feature = "key-chain-file")] - { - let store = keyring_core::sample::Store::new_with_backing( - secrets_backing_file() - .to_str() - .expect("Invalid secrets backing path"), - )?; - keyring_core::set_default_store(store); - } - - #[cfg(not(feature = "key-chain-file"))] - { - #[cfg(target_os = "linux")] - { - activate_desktop_keyring(); - keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?); - } - - #[cfg(target_os = "macos")] - { - keyring_core::set_default_store(apple_native_keyring_store::keychain::Store::new()?); - } - - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { - let store = keyring_core::sample::Store::new_with_configuration( - &std::collections::HashMap::from([("persist", "true")]), - )?; - keyring_core::set_default_store(store); - } - } - + if std::env::var_os("DROIDDECK_XBOX_CREDENTIAL_SOCKET").is_none() { return Err(keyring_core::Error::NoEntry); } Ok(()) } - -// Some KDE installations advertise ksecretd only under its compatibility -// activation name. Respect an existing/default Secret Service provider first. -// This starts a packaged user service, never installs or configures a keyring. -#[cfg(all(target_os = "linux", not(feature = "key-chain-file")))] -fn activate_desktop_keyring() { - use dbus::blocking::Connection; - use std::time::Duration; - let Ok(connection) = Connection::new_session() else { return; }; - let bus = connection.with_proxy("org.freedesktop.DBus", "/org/freedesktop/DBus", Duration::from_secs(3)); - let Ok((owned,)): Result<(bool,), _> = bus.method_call("org.freedesktop.DBus", "NameHasOwner", ("org.freedesktop.secrets",)) else { return; }; - if owned { return; } - let Ok((names,)): Result<(Vec,), _> = bus.method_call("org.freedesktop.DBus", "ListActivatableNames", ()) else { return; }; - if names.iter().any(|name| name == "org.freedesktop.secrets") { return; } - if names.iter().any(|name| name == "org.kde.secretservicecompat") { - // On failure, Store::new below still returns the genuine keyring error. - let _: Result<(u32,), _> = bus.method_call("org.freedesktop.DBus", "StartServiceByName", ("org.kde.secretservicecompat", 0u32)); - } +pub struct Entry(String); +pub fn get_entry(user: &str) -> Result { Ok(Entry(user.into())) } +impl Entry { + pub fn get_secret(&self) -> Result, TokenStoreError> { KeychainBackend.get(&self.0)?.ok_or(TokenStoreError::NotFound) } + pub fn set_secret(&self, value: &[u8]) -> Result<(), TokenStoreError> { KeychainBackend.set(&self.0, value) } + pub fn delete_credential(&self) -> Result<(), TokenStoreError> { KeychainBackend.remove(&self.0) } } - -pub fn get_entry(user: &str) -> Result { - keyring_core::Entry::new(SERVICE_NAME, user) -} - -pub fn destroy_secrets() { - keyring_core::unset_default_store(); -} - -#[cfg(feature = "key-chain-file")] -fn secrets_backing_file() -> std::path::PathBuf { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(std::path::PathBuf::from) - .unwrap_or_else(std::env::temp_dir) - .join(".xodus-keyring.ron") -} +pub fn destroy_secrets() {} --- a/crates/xodus/src/tokens/backend/keychain.rs +++ b/crates/xodus/src/tokens/backend/keychain.rs @@ -1,131 +1,43 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; - use crate::tokens::store::{TokenBackend, TokenStoreError}; - -// GNOME Keyring's passwordless textual format writes secrets with -// g_key_file_set_value but reads them with g_key_file_get_string. Backslash -// escapes in JSON (notably XML token attributes) can therefore be rejected or -// changed on reload. ASCII base64 avoids those escapes without changing the -// selected keyring or its security policy. This is encoding, not encryption. -const ENVELOPE: &[u8] = b"XODUS_KEYRING_BASE64_V1:"; - -fn encode_secret(value: &[u8]) -> Vec { - let mut encoded = ENVELOPE.to_vec(); - encoded.extend_from_slice(STANDARD.encode(value).as_bytes()); - encoded -} - -fn decode_secret(bytes: Vec) -> Result, TokenStoreError> { - match bytes.strip_prefix(ENVELOPE) { - Some(encoded) => STANDARD.decode(encoded).map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - "Invalid encoded Xodus credential", - ) - .into() - }), - // Existing credentials are raw JSON. Do not rewrite or delete on reads. - None => Ok(bytes), - } -} - -fn deletion_result(result: Result<(), keyring_core::Error>) -> Result<(), TokenStoreError> { - match result { - Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), - Err(error) => Err(error.into()), - } -} +use serde_json::{Value, json}; +use std::io::{BufRead, Read, Write}; +use std::os::linux::net::SocketAddrExt; +use std::os::unix::net::{SocketAddr, UnixStream}; +use std::time::Duration; pub struct KeychainBackend; +fn request(value: Value) -> Result { + let name = std::env::var("DROIDDECK_XBOX_CREDENTIAL_SOCKET") + .map_err(|_| std::io::Error::other("Android credential host is unavailable"))?; + let address = SocketAddr::from_abstract_name(name)?; + let mut stream = UnixStream::connect_addr(&address)?; + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + stream.set_write_timeout(Some(Duration::from_secs(10)))?; + serde_json::to_writer(&mut stream, &value)?; + stream.write_all(b"\n")?; + let mut bytes = Vec::new(); + std::io::BufReader::new(stream).take(2_097_153).read_until(b'\n', &mut bytes)?; + if bytes.len() > 2_097_152 || bytes.last() != Some(&b'\n') { + return Err(std::io::Error::other("Invalid Android credential response").into()); + } + let response: Value = serde_json::from_slice(&bytes)?; + if response["ok"] != true { return Err(std::io::Error::other("Android credential storage unavailable").into()); } + Ok(response) +} + impl TokenBackend for KeychainBackend { fn get(&self, key: &str) -> Result>, TokenStoreError> { - let entry = crate::secrets::get_entry(key)?; - match entry.get_secret() { - Ok(bytes) => Ok(Some(decode_secret(bytes)?)), - Err(keyring_core::Error::NoEntry) => Ok(None), - Err(e) => Err(e.into()), - } + let response = request(json!({"op":"get", "key":key}))?; + if response["value"].is_null() { return Ok(None); } + Ok(Some(serde_json::from_value(response["value"].clone())?)) } - fn set(&self, key: &str, value: &[u8]) -> Result<(), TokenStoreError> { - crate::secrets::get_entry(key)?.set_secret(&encode_secret(value))?; - #[cfg(all(target_os = "linux", not(feature = "key-chain-file")))] - crate::keyring_persistence::sync_after_write(); + request(json!({"op":"set", "key":key, "value":value}))?; Ok(()) } - fn remove(&self, key: &str) -> Result<(), TokenStoreError> { - deletion_result(crate::secrets::get_entry(key)?.delete_credential())?; - #[cfg(all(target_os = "linux", not(feature = "key-chain-file")))] - crate::keyring_persistence::sync_after_write(); + request(json!({"op":"remove", "key":key}))?; Ok(()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn credential_encoding_preserves_xml_json_and_binary() { - let xml_json = br#"{"token":"dummy","escapes":"a\\b\n\t\r"}"#; - let binary: Vec = (0..=255).collect(); - for value in [ - xml_json.as_slice(), - b"".as_slice(), - binary.as_slice(), - "synthetic-\u{00e9}".as_bytes(), - ] { - let encoded = encode_secret(value); - assert!(encoded.starts_with(ENVELOPE)); - // No GKeyFile escapes or physical line breaks can be introduced. - assert!(encoded.is_ascii()); - assert!( - !encoded - .iter() - .any(|byte| matches!(byte, b'\\' | b'\n' | b'\r' | 0)) - ); - assert_eq!(decode_secret(encoded).unwrap(), value); - } - } - - #[test] - fn credential_encoding_accepts_legacy_values_without_rewriting() { - for value in [ - br#"{"username":"synthetic"}"#.as_slice(), - b"".as_slice(), - b"invalid legacy JSON".as_slice(), - ] { - // Validation of legacy JSON remains the TokenManager's job. - assert_eq!(decode_secret(value.to_vec()).unwrap(), value); - } - } - - #[test] - fn credential_encoding_rejects_malformed_envelopes_without_leaking_values() { - for bad in [ - b"%%%synthetic-secret-never-print".as_slice(), - b"YQ".as_slice(), - ] { - let mut encoded = ENVELOPE.to_vec(); - encoded.extend_from_slice(bad); - let error = decode_secret(encoded).unwrap_err(); - assert!(matches!(error, TokenStoreError::Io(_))); - assert!(!error.to_string().contains("synthetic-secret-never-print")); - } - } - - #[test] - fn credential_deletion_is_idempotent_but_keeps_other_errors() { - assert!(deletion_result(Ok(())).is_ok()); - assert!(deletion_result(Err(keyring_core::Error::NoEntry)).is_ok()); - assert!(matches!( - deletion_result(Err(keyring_core::Error::Invalid( - "synthetic".into(), - "failure".into() - ))), - Err(TokenStoreError::Keychain(_)) - )); - } -} --- a/crates/xodus/Cargo.toml +++ b/crates/xodus/Cargo.toml @@ -43,9 +43,9 @@ smbios-lib = "0.9.4" [target.'cfg(target_os = "linux")'.dependencies] -dbus = "=0.9.12" -x11-dl = "=2.21.0" -dbus-secret-service-keyring-store = { version = "1.0.1", features = ["crypto-openssl"]} +dbus = { version = "=0.9.12", optional = true } +x11-dl = { version = "=2.21.0", optional = true } +dbus-secret-service-keyring-store = { version = "1.0.1", features = ["crypto-openssl"], optional = true} [target.'cfg(target_os = "macos")'.dependencies] apple-native-keyring-store = { version = "1.0.2", features = ["keychain"]} --- a/crates/xodus/src/lib.rs +++ b/crates/xodus/src/lib.rs @@ -2,12 +2,6 @@ pub mod auth; pub mod clep; pub mod hardware; -#[cfg(target_os = "linux")] -mod keyring_persistence; -#[cfg(target_os = "linux")] -pub mod keyring_session; -#[cfg(target_os = "linux")] -pub mod keyring_unlock; pub mod licensing; pub mod models; pub mod secrets; --- a/crates/xodus-cli/src/main.rs +++ b/crates/xodus-cli/src/main.rs @@ -18,24 +18,6 @@ #[derive(Subcommand)] enum SubCommand { - #[cfg(target_os = "linux")] - #[command(about = "Gracefully stop only a Nexus-owned session keyring without accessing credentials")] - KeyringSessionStop { - #[arg(long)] - provider_pid: u32, - }, - #[cfg(target_os = "linux")] - #[command(about = "Create the first default wallet using the provider's password dialog")] - KeyringCreate { - #[arg(long, default_value = "")] - window_id: String, - }, - #[cfg(target_os = "linux")] - #[command(about = "Unlock the default system wallet without reading credentials or signing in")] - KeyringUnlock { - #[arg(long, default_value = "")] - window_id: String, - }, #[command(about = "Resolve public Store packages without sign-in or downloading")] ResolvePackage { product: String, @@ -238,29 +220,6 @@ .build() .unwrap(); let args = CliArgs::parse(); - #[cfg(target_os = "linux")] - if let SubCommand::KeyringSessionStop { provider_pid } = &args.command { - let state = xodus::keyring_session::stop_session_provider(*provider_pid); - println!("{}", serde_json::json!({"schema": 1, "state": state.as_str()})); - return if state == xodus::keyring_session::StopState::Unavailable { ExitCode::FAILURE } else { ExitCode::SUCCESS }; - } - #[cfg(target_os = "linux")] - if let SubCommand::KeyringUnlock { window_id } | SubCommand::KeyringCreate { window_id } = &args.command { - use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; - let cancelled = Arc::new(AtomicBool::new(false)); - let signal = cancelled.clone(); - let state = if ctrlc::set_handler(move || { signal.store(true, Ordering::SeqCst); }).is_err() { - xodus::keyring_unlock::UnlockState::Unavailable - } else { - if matches!(&args.command, SubCommand::KeyringCreate { .. }) { - xodus::keyring_unlock::create_default(window_id, &cancelled) - } else { - xodus::keyring_unlock::unlock_default(window_id, &cancelled) - } - }; - println!("{}", serde_json::json!({"schema": 1, "state": state.as_str()})); - return ExitCode::SUCCESS; - } #[cfg(not(feature = "native-webview"))] if args.external_webview_stdio { webview::enable_stdio(); @@ -308,12 +267,6 @@ } let code = match args.command { - #[cfg(target_os = "linux")] - SubCommand::KeyringSessionStop { .. } => unreachable!(), - #[cfg(target_os = "linux")] - SubCommand::KeyringUnlock { .. } => unreachable!(), - #[cfg(target_os = "linux")] - SubCommand::KeyringCreate { .. } => unreachable!(), SubCommand::ResolvePackage { .. } => unreachable!(), SubCommand::Download { product,