182 lines
6.4 KiB
Rust
182 lines
6.4 KiB
Rust
//! 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<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
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<Option<SessionTokens>, 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, String> {
|
||
|
|
keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| e.to_string())
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(not(target_os = "windows"))]
|
||
|
|
fn entry() -> Result<keyring::Entry, String> {
|
||
|
|
Err("not supported on this platform".into())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Keychain calls can block (credential store, AV scanners): keep them off
|
||
|
|
/// the main thread.
|
||
|
|
async fn blocking<T: Send + 'static>(
|
||
|
|
f: impl FnOnce() -> Result<T, String> + Send + 'static,
|
||
|
|
) -> Result<T, String> {
|
||
|
|
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<Option<SessionTokens>, 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();
|
||
|
|
}
|
||
|
|
}
|