Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 032b6e04e7 feat: secure_session commands, login tokens in the OS keychain (cinny #105, step 1)
Commands for the web client to keep a copy of the login tokens in the OS
keychain: secure_session_supported / _set / _get / _clear.

- Windows: Credential Manager via the keyring crate (3.6, windows-native),
  entry "session" in service "Lotus Chat". Only the secrets are stored
  (userId, deviceId, accessToken, refreshToken); the serialized value is
  capped at 1200 chars (Windows' limit is 2560 bytes).
- Other platforms: supported = false and the other commands answer "not
  supported on this platform" (Linux Secret Service can prompt to unlock a
  wallet at startup; that needs its own testing). No new Linux dependency:
  without a platform feature the crate only has its mock store.
- Keychain calls run on the blocking pool, off the main thread.

Step 1 is a mirror only (the web client still reads its session from
localStorage); see the cinny PR.

Tests: round trip + clear, clearing an empty keychain, incomplete and
oversized sessions rejected with nothing written, the JSON shape the web
client sends, a realistic OIDC session fits (keyring's mock store). Linux
release build: commands answer as designed and login is unaffected.
Windows: type-checked only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:25:01 -04:00
Lotus CI 884877a55b chore: bump cinny submodule to 91f82d60 (nightly catch-up)
Build Lotus Chat Desktop / prepare (push) Successful in 10s
Build Lotus Chat Desktop / build-linux (push) Successful in 26m49s
Build Lotus Chat Desktop / build-arch (push) Successful in 15s
Build Lotus Chat Desktop / build-windows (push) Successful in 36m5s
Build Lotus Chat Desktop / update-manifest (push) Successful in 5s
2026-09-29 04:01:40 +00:00
Lotus CI bf5b6d7b45 chore: bump cinny submodule to 899e160a
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 31m52s
Build Lotus Chat Desktop / build-windows (push) Successful in 34m30s
Build Lotus Chat Desktop / build-arch (push) Successful in 14s
Build Lotus Chat Desktop / update-manifest (push) Successful in 9s
2026-09-29 02:18:04 +00:00
Lotus CI 29f83df53c chore: bump cinny submodule to c0c93213
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 26m6s
Build Lotus Chat Desktop / build-arch (push) Successful in 44s
Build Lotus Chat Desktop / build-windows (push) Successful in 30m4s
Build Lotus Chat Desktop / update-manifest (push) Successful in 2s
2026-09-28 23:55:43 +00:00
5 changed files with 205 additions and 1 deletions
+1 -1
Submodule cinny updated: be8e49a2bb...91f82d60e3
+13
View File
@@ -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"
+5
View File
@@ -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 = [
+5
View File
@@ -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(
+181
View File
@@ -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<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();
}
}