//! 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(); } }