diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f7caaf2..c31fe15 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -498,6 +498,7 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" name = "cinny" version = "4.12.2" dependencies = [ + "keyring", "serde", "serde_json", "tauri", @@ -2090,6 +2091,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 34d18cd..f1f43ed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,10 @@ tauri-plugin-single-instance = "2" tauri-plugin-autostart = "2" # P6-1 launch-on-login # Update retry backoff (already in the tree via tauri; adds only the timer). tokio = { version = "1", features = ["time"] } +# cinny #105: login tokens in the OS keychain. Without a platform feature +# the crate only has its in-memory mock store (used by the tests); Windows +# turns on Credential Manager below. +keyring = "3.6" [target.'cfg(target_os = "linux")'.dependencies] # P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher @@ -50,6 +54,7 @@ zbus = "5" webkit2gtk = "2.0" [target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3.6", features = ["windows-native"] } webview2-com = "0.38" window-vibrancy = "0.6" windows = { version = "0.61", features = [ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8cad3b2..8ec64f5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ use tauri_plugin_opener::OpenerExt; pub mod gpu_workarounds; mod native; +mod secure_session; /// Bring the main window to the foreground from the tray / a hidden / /// minimized state. Shared by the tray, single-instance, and deep-link paths. @@ -952,6 +953,10 @@ pub fn run() { native::focus_assist::get_focus_assist, native::hotkeys::global_hotkeys_supported, native::hotkeys::set_global_hotkeys, + secure_session::secure_session_supported, + secure_session::secure_session_set, + secure_session::secure_session_get, + secure_session::secure_session_clear, ]) .plugin(tauri_plugin_localhost::Builder::new(port).build()) .plugin( diff --git a/src-tauri/src/secure_session.rs b/src-tauri/src/secure_session.rs new file mode 100644 index 0000000..bf50ea2 --- /dev/null +++ b/src-tauri/src/secure_session.rs @@ -0,0 +1,181 @@ +//! Login tokens in the OS keychain (cinny #105, step 1). +//! +//! Step 1 only MIRRORS the session tokens into the keychain (Windows +//! Credential Manager): the web client keeps reading its session from +//! localStorage exactly as before, so nothing about login changes and a +//! keychain problem can't log anyone out. Once the mirror has proven itself on +//! real installs, step 2 switches reads to the keychain and drops the tokens +//! from localStorage. +//! +//! Only the secrets are stored (user id + device id to match them to the +//! session, the access token and the refresh token); the rest of the session +//! stays in localStorage. Windows caps a credential at 2560 bytes, so the +//! serialized value is limited well below that. +//! +//! Other platforms: not supported yet (Linux Secret Service can prompt to +//! unlock a wallet at startup; that needs its own testing), and the commands +//! say so instead of failing. + +use serde::{Deserialize, Serialize}; + +#[cfg_attr(not(any(target_os = "windows", test)), allow(dead_code))] +pub(crate) const SERVICE: &str = "Lotus Chat"; +#[cfg_attr(not(any(target_os = "windows", test)), allow(dead_code))] +pub(crate) const ACCOUNT: &str = "session"; +/// Serialized-length cap (chars). Windows' limit is 2560 bytes; staying under +/// 1200 chars keeps us safe whether the value is stored as UTF-8 or UTF-16. +pub(crate) const MAX_LEN: usize = 1200; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionTokens { + pub user_id: String, + pub device_id: String, + pub access_token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, +} + +pub(crate) fn store(entry: &keyring::Entry, tokens: &SessionTokens) -> Result<(), String> { + if tokens.user_id.is_empty() || tokens.device_id.is_empty() || tokens.access_token.is_empty() { + return Err("incomplete session".into()); + } + let value = serde_json::to_string(tokens).map_err(|e| e.to_string())?; + if value.chars().count() > MAX_LEN { + return Err(format!( + "session too large for the keychain ({} chars)", + value.chars().count() + )); + } + entry.set_password(&value).map_err(|e| e.to_string()) +} + +pub(crate) fn load(entry: &keyring::Entry) -> Result, String> { + match entry.get_password() { + Ok(value) => serde_json::from_str(&value) + .map(Some) + .map_err(|e| format!("unreadable keychain entry: {e}")), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +pub(crate) fn clear(entry: &keyring::Entry) -> Result<(), String> { + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.to_string()), + } +} + +#[cfg(target_os = "windows")] +fn entry() -> Result { + keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| e.to_string()) +} + +#[cfg(not(target_os = "windows"))] +fn entry() -> Result { + Err("not supported on this platform".into()) +} + +/// Keychain calls can block (credential store, AV scanners): keep them off +/// the main thread. +async fn blocking( + f: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tauri::async_runtime::spawn_blocking(f) + .await + .map_err(|e| e.to_string())? +} + +/// Whether this platform stores the session in the keychain. +#[tauri::command] +pub fn secure_session_supported() -> bool { + cfg!(target_os = "windows") +} + +#[tauri::command] +pub async fn secure_session_set(tokens: SessionTokens) -> Result<(), String> { + blocking(move || store(&entry()?, &tokens)).await +} + +#[tauri::command] +pub async fn secure_session_get() -> Result, String> { + blocking(|| load(&entry()?)).await +} + +#[tauri::command] +pub async fn secure_session_clear() -> Result<(), String> { + blocking(|| clear(&entry()?)).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock_entry() -> keyring::Entry { + keyring::set_default_credential_builder(keyring::mock::default_credential_builder()); + keyring::Entry::new(SERVICE, ACCOUNT).unwrap() + } + + fn tokens() -> SessionTokens { + SessionTokens { + user_id: "@alice:lotusguild.org".into(), + device_id: "ABCDEFGHIJ".into(), + access_token: "syt_YWxpY2U_abcdefghijklmnopqrst_0AbCdE".into(), + refresh_token: None, + } + } + + #[test] + fn round_trip_and_clear() { + let e = mock_entry(); + assert_eq!(load(&e).unwrap(), None); + store(&e, &tokens()).unwrap(); + assert_eq!(load(&e).unwrap(), Some(tokens())); + let mut oidc = tokens(); + oidc.refresh_token = Some("mar_refresh_token_value_0123456789".into()); + store(&e, &oidc).unwrap(); + assert_eq!(load(&e).unwrap(), Some(oidc)); + clear(&e).unwrap(); + assert_eq!(load(&e).unwrap(), None); + // Clearing an empty keychain is fine (logout twice, or never mirrored). + clear(&e).unwrap(); + } + + #[test] + fn rejects_incomplete_or_oversized_sessions() { + let e = mock_entry(); + let mut t = tokens(); + t.access_token = String::new(); + assert!(store(&e, &t).is_err()); + let mut big = tokens(); + big.access_token = "x".repeat(MAX_LEN); + assert!(store(&e, &big).unwrap_err().contains("too large")); + assert_eq!(load(&e).unwrap(), None, "nothing written on error"); + } + + #[test] + fn serialized_shape_matches_the_web_client() { + let json = serde_json::to_value(tokens()).unwrap(); + assert_eq!(json["userId"], "@alice:lotusguild.org"); + assert_eq!(json["deviceId"], "ABCDEFGHIJ"); + assert!(json.get("refreshToken").is_none()); + let back: SessionTokens = serde_json::from_str( + r#"{"userId":"@a:b","deviceId":"D","accessToken":"t","refreshToken":"r"}"#, + ) + .unwrap(); + assert_eq!(back.refresh_token.as_deref(), Some("r")); + } + + #[test] + fn a_realistic_oidc_session_fits() { + let e = mock_entry(); + let t = SessionTokens { + user_id: format!("@{}:matrix.lotusguild.org", "a".repeat(60)), + device_id: "X".repeat(40), + access_token: "mat_".to_string() + &"A".repeat(200), + refresh_token: Some("mar_".to_string() + &"B".repeat(200)), + }; + store(&e, &t).unwrap(); + } +}