diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index dcb432b7362..ccc6855f66e 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -22,6 +22,10 @@ mesh-llm = ["dep:iroh", "dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime", "dep:me # OS keyring backing for desktop secret storage (nsec private keys). When # disabled, secrets fall back to 0o600 files. On by default for real builds. system-keyring = ["dep:keyring"] +# Electric-only evaOS Teams identity-broker variant. This is deliberately a +# compile-time boundary: managed key custody must not be switchable by an +# inherited environment variable at runtime. +evaos-teams-managed = ["system-keyring"] [build-dependencies] base64 = "0.22" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index dfad9c2fece..a67e5ec9b7f 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -4,7 +4,50 @@ include!("src/commands/reconnect_hook_config.rs"); use base64::Engine as _; +fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) { + match (base, overlay) { + (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => { + for (key, value) in overlay { + merge_json(base.entry(key).or_insert(serde_json::Value::Null), value); + } + } + (base, overlay) => *base = overlay, + } +} + +fn configure_managed_deep_link_scheme() { + if std::env::var_os("CARGO_FEATURE_EVAOS_TEAMS_MANAGED").is_none() { + return; + } + let mut config = std::env::var("TAURI_CONFIG") + .ok() + .map(|value| { + serde_json::from_str(&value) + .unwrap_or_else(|error| panic!("TAURI_CONFIG is not valid JSON: {error}")) + }) + .unwrap_or_else(|| serde_json::json!({})); + merge_json( + &mut config, + serde_json::json!({ + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["buzz", "evaos-teams"] + } + } + } + }), + ); + let config = config.to_string(); + std::env::set_var("TAURI_CONFIG", &config); + // `tauri_build` reads the build-script process environment, while + // `tauri::generate_context!()` expands later in rustc. Propagate the same + // merged overlay to rustc so runtime context and bundle registration agree. + println!("cargo:rustc-env=TAURI_CONFIG={config}"); +} + fn main() { + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_EVAOS_TEAMS_MANAGED"); println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL"); println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP"); println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY"); @@ -117,6 +160,7 @@ fn main() { ); } + configure_managed_deep_link_scheme(); tauri_build::try_build( tauri_build::Attributes::new().plugin( "websocket", diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 778bd5fb3fb..49a59933d37 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, io::Write, sync::{ - atomic::{AtomicBool, AtomicU16}, + atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU64}, Arc, Mutex, }, }; @@ -15,6 +15,8 @@ use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; +mod signing; + pub struct AppState { pub keys: Mutex, pub http_client: reqwest::Client, @@ -90,6 +92,7 @@ pub struct AppState { /// a newer imported key during concurrent calls. Deliberately separate from /// `keys` so readers (signing, get_identity, etc.) are not blocked during /// keyring I/O. + #[cfg_attr(feature = "evaos-teams-managed", allow(dead_code))] pub identity_mutation: Mutex<()>, /// Set when the boot-time Phase 2 reset attempted a wipe but verification /// failed. The sentinel is preserved so the next relaunch retries. All @@ -99,6 +102,17 @@ pub struct AppState { /// Ordering: written once in `setup()` with `Ordering::Release`; read in /// `get_identity` with `Ordering::Acquire`. pub reset_failed: AtomicBool, + /// Whether the Electric broker currently authorizes managed signing. + #[cfg_attr(not(feature = "evaos-teams-managed"), allow(dead_code))] + pub evaos_teams_authorized: AtomicBool, + /// Broker-provided managed signing deadline; native uses `i64::MAX`. + #[cfg_attr(not(feature = "evaos-teams-managed"), allow(dead_code))] + pub evaos_teams_expires_at: AtomicI64, + /// Serializes managed entitlement install, refresh, expiry, and revoke. + #[cfg_attr(not(feature = "evaos-teams-managed"), allow(dead_code))] + pub evaos_teams_access_transition: Mutex<()>, + #[cfg_attr(not(feature = "evaos-teams-managed"), allow(dead_code))] + pub evaos_teams_access_generation: AtomicU64, /// Cached ACP session config from running agents, keyed by canonical /// `(agent pubkey, relay URL)` runtime identity. /// Populated when the harness emits `session_config_captured` observer events. @@ -176,6 +190,7 @@ pub fn build_media_fetch_client() -> reqwest::Result { pub fn build_app_state() -> AppState { // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() // in setup() will replace the ephemeral placeholder with a persisted key. + #[cfg(not(feature = "evaos-teams-managed"))] let keys = match identity_from_env() { Some(keys) => { eprintln!( @@ -186,6 +201,8 @@ pub fn build_app_state() -> AppState { } None => Keys::generate(), }; + #[cfg(feature = "evaos-teams-managed")] + let keys = Keys::generate(); AppState { keys: Mutex::new(keys), @@ -220,6 +237,14 @@ pub fn build_app_state() -> AppState { keyring_locked: AtomicBool::new(false), identity_lost: AtomicBool::new(false), reset_failed: AtomicBool::new(false), + evaos_teams_authorized: AtomicBool::new(!cfg!(feature = "evaos-teams-managed")), + evaos_teams_expires_at: AtomicI64::new(if cfg!(feature = "evaos-teams-managed") { + 0 + } else { + i64::MAX + }), + evaos_teams_access_transition: Mutex::new(()), + evaos_teams_access_generation: AtomicU64::new(0), #[cfg(feature = "mesh-llm")] mesh_llm_runtime: AsyncMutex::new(None), #[cfg(feature = "mesh-llm")] @@ -287,32 +312,6 @@ impl AppState { } } - /// Return the active identity keys if they are in a signable state. - /// - /// Returns `Err` when the identity is in a lost state (`identity_lost` - /// — ephemeral key, user must re-import their nsec) or when the keyring - /// is locked (`keyring_locked` — key is held in a keyring that is - /// unavailable this boot). All signing and publish commands must call - /// this instead of locking `state.keys` directly, so that recovery mode - /// blocks publishing under an invalid or inaccessible identity. - pub fn signing_keys(&self) -> Result { - if self - .identity_lost - .load(std::sync::atomic::Ordering::Acquire) - || self - .keyring_locked - .load(std::sync::atomic::Ordering::Acquire) - { - return Err("identity is in recovery mode; event signing is disabled \ - until the identity is restored and Buzz is relaunched" - .to_string()); - } - self.keys - .lock() - .map_err(|e| e.to_string()) - .map(|k| k.clone()) - } - /// Emit the current huddle state to the frontend via Tauri event. /// /// Acquires both locks (app_handle + huddle_state), clones a snapshot, @@ -883,6 +882,7 @@ fn persist_imported_identity_impl( /// Public entry point binding [`persist_imported_identity_impl`] to the shared /// [`crate::secret_store::SecretStore`]. See the impl for the persistence policy. +#[cfg_attr(feature = "evaos-teams-managed", allow(dead_code))] pub(crate) fn persist_imported_identity( store: &crate::secret_store::SecretStore, keys: &Keys, diff --git a/desktop/src-tauri/src/app_state/signing.rs b/desktop/src-tauri/src/app_state/signing.rs new file mode 100644 index 00000000000..1b9902ac136 --- /dev/null +++ b/desktop/src-tauri/src/app_state/signing.rs @@ -0,0 +1,158 @@ +use std::sync::atomic::Ordering; + +use nostr::Keys; + +use super::AppState; + +impl AppState { + /// Revoke every in-process capability derived from a managed entitlement. + /// This is also called when `signing_keys` discovers backend expiry, so a + /// long-lived huddle cannot outlive the authorization that started it. + #[cfg(feature = "evaos-teams-managed")] + pub(crate) fn disable_evaos_teams_access(&self) { + let old_pipelines = { + let _transition = self + .evaos_teams_access_transition + .lock() + .unwrap_or_else(|error| error.into_inner()); + self.evaos_teams_access_generation + .fetch_add(1, Ordering::AcqRel); + self.evaos_teams_authorized.store(false, Ordering::Release); + self.evaos_teams_expires_at.store(0, Ordering::Release); + if let Ok(mut relay) = self.relay_url_override.lock() { + *relay = None; + } + self.huddle_state.lock().ok().map(|mut huddle| { + huddle.session_generation.fetch_add(1, Ordering::Release); + if let Some(cancel) = huddle.audio_ws_cancel.take() { + cancel.cancel(); + } + huddle.audio_relay_pcm_tx.take(); + let stt = huddle.stt_pipeline.take(); + let tts = huddle.tts_pipeline.take(); + huddle.reset_preserving_generation(); + (stt, tts) + }) + }; + drop(old_pipelines); + self.emit_huddle_state_changed(); + } + + /// Install a validated managed capability and arm an entitlement-owned + /// expiry task. The transition lock and generation token make refresh and + /// expiry mutually exclusive; the timer is independent of huddle sockets. + #[cfg(feature = "evaos-teams-managed")] + pub(crate) fn install_evaos_teams_access( + &self, + keys: Keys, + relay: String, + expires_at: i64, + ) -> Result<(), String> { + use tauri::Manager; + + let app = self + .app_handle + .lock() + .map_err(|error| error.to_string())? + .clone() + .ok_or_else(|| "managed entitlement expiry task is unavailable".to_string())?; + let generation = { + let _transition = self + .evaos_teams_access_transition + .lock() + .map_err(|error| error.to_string())?; + self.evaos_teams_authorized.store(false, Ordering::Release); + *self.keys.lock().map_err(|error| error.to_string())? = keys; + *self + .relay_url_override + .lock() + .map_err(|error| error.to_string())? = Some(relay); + self.evaos_teams_expires_at + .store(expires_at, Ordering::Release); + let generation = self + .evaos_teams_access_generation + .fetch_add(1, Ordering::AcqRel) + .wrapping_add(1); + self.evaos_teams_authorized.store(true, Ordering::Release); + generation + }; + + tauri::async_runtime::spawn(async move { + loop { + let wait_seconds = + u64::try_from(expires_at.saturating_sub(chrono::Utc::now().timestamp())) + .unwrap_or(0); + if wait_seconds > 0 { + tokio::time::sleep(std::time::Duration::from_secs(wait_seconds)).await; + continue; + } + let state = app.state::(); + let _transition = state + .evaos_teams_access_transition + .lock() + .unwrap_or_else(|error| error.into_inner()); + let still_current = state.evaos_teams_access_generation.load(Ordering::Acquire) + == generation + && state.evaos_teams_expires_at.load(Ordering::Acquire) == expires_at; + if !still_current { + break; + } + state + .evaos_teams_access_generation + .fetch_add(1, Ordering::AcqRel); + state.evaos_teams_authorized.store(false, Ordering::Release); + state.evaos_teams_expires_at.store(0, Ordering::Release); + if let Ok(mut relay) = state.relay_url_override.lock() { + *relay = None; + } + let old_pipelines = state.huddle_state.lock().ok().map(|mut huddle| { + huddle.session_generation.fetch_add(1, Ordering::Release); + if let Some(cancel) = huddle.audio_ws_cancel.take() { + cancel.cancel(); + } + huddle.audio_relay_pcm_tx.take(); + let stt = huddle.stt_pipeline.take(); + let tts = huddle.tts_pipeline.take(); + huddle.reset_preserving_generation(); + (stt, tts) + }); + drop(_transition); + drop(old_pipelines); + state.emit_huddle_state_changed(); + break; + } + }); + Ok(()) + } + + /// Return the active identity keys if they are in a signable state. + /// + /// Managed builds additionally require a current broker entitlement. + /// Native recovery mode blocks publishing under an invalid or inaccessible + /// identity until the identity is restored and Buzz is relaunched. + pub fn signing_keys(&self) -> Result { + if self.identity_lost.load(Ordering::Acquire) || self.keyring_locked.load(Ordering::Acquire) + { + return Err("identity is in recovery mode; event signing is disabled \ + until the identity is restored and Buzz is relaunched" + .to_string()); + } + #[cfg(feature = "evaos-teams-managed")] + { + let now = chrono::Utc::now().timestamp(); + if !self.evaos_teams_authorized.load(Ordering::Acquire) + || self.evaos_teams_expires_at.load(Ordering::Acquire) <= now + { + self.disable_evaos_teams_access(); + return Err( + "evaOS Teams access is not currently authorized; sign in or refresh access" + .to_string(), + ); + } + } + self.keys + .lock() + .map_err(|e| e.to_string()) + .map(|keys| keys.clone()) + } +} diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs index 485dfaea150..cf68f744569 100644 --- a/desktop/src-tauri/src/app_state_tests.rs +++ b/desktop/src-tauri/src/app_state_tests.rs @@ -918,6 +918,7 @@ fn reachable_but_empty_with_marker_and_no_file_returns_lost_ephemeral_not_persis // ── signing_keys() gate tests ───────────────────────────────────────────── #[test] +#[cfg(not(feature = "evaos-teams-managed"))] fn signing_keys_returns_ok_when_normal() { // When neither identity_lost nor keyring_locked is set, signing_keys() // must return the live keys and allow signing. diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index a65b126a4d0..abed3e80580 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -156,11 +156,7 @@ pub async fn archive_events( let bucket_results = query_buckets(plan.buckets, state_ref).await; // ── Phase 3: persist (blocking SQLite) ────────────────────────────────── - let owner_keys = { - let keys_guard = state.keys.lock().map_err(|e| e.to_string())?; - keys_guard.clone() - // guard drops here, before awaiting the blocking commit task. - }; + let owner_keys = state.signing_keys()?; let commit_identity_pk = identity_pk.clone(); let commit_relay_url = relay_url.clone(); run_archive_db_task(move |conn| { diff --git a/desktop/src-tauri/src/builderlab.rs b/desktop/src-tauri/src/builderlab.rs index 1252946c58b..5747e27cd20 100644 --- a/desktop/src-tauri/src/builderlab.rs +++ b/desktop/src-tauri/src/builderlab.rs @@ -483,6 +483,9 @@ pub(crate) async fn bind_builderlab_nostr_identity( app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, ) -> Result { + if cfg!(feature = "evaos-teams-managed") { + return Err("Managed identities cannot be bound to Builderlab".to_string()); + } let challenge_value = authenticated_json( &app_state.http_client, &session, diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index 5b65695a913..1ccaead7e26 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -221,6 +221,14 @@ fn pending_owner_mark_uses_signer_captured_before_identity_swap() { // marks with that captured identity, so a swap that happens afterward // (i.e. during what would be the submit await) can't retarget the mark. let state = crate::app_state::build_app_state(); + #[cfg(feature = "evaos-teams-managed")] + state + .evaos_teams_authorized + .store(true, std::sync::atomic::Ordering::Release); + #[cfg(feature = "evaos-teams-managed")] + state + .evaos_teams_expires_at + .store(i64::MAX, std::sync::atomic::Ordering::Release); // Mirrors `create_channel`'s new capture-before-submit step: read the // signer identity once, before anything that could race with a swap. diff --git a/desktop/src-tauri/src/commands/engrams.rs b/desktop/src-tauri/src/commands/engrams.rs index 74de1294925..9ce6170bf86 100644 --- a/desktop/src-tauri/src/commands/engrams.rs +++ b/desktop/src-tauri/src/commands/engrams.rs @@ -162,10 +162,8 @@ pub async fn get_agent_memory( // ── Resolve owner key material ────────────────────────────────────── // Owner = viewer. Clone the secret key out of the lock immediately so // we don't hold the mutex across the relay round trip. - let (owner_pubkey, owner_seckey) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - (keys.public_key(), keys.secret_key().clone()) - }; + let owner_keys = state.signing_keys()?; + let (owner_pubkey, owner_seckey) = (owner_keys.public_key(), owner_keys.secret_key().clone()); // ── Relay query ───────────────────────────────────────────────────── // Mirrors the CLI `mem ls` filter: kind 30174, authored by the agent, diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 387dea69c24..5230e8980ba 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -56,6 +56,9 @@ pub fn get_default_relay_url() -> String { #[tauri::command] pub fn is_shared_identity() -> bool { + if cfg!(feature = "evaos-teams-managed") { + return false; + } std::env::var("BUZZ_SHARE_IDENTITY") .map(|v| v == "1") .unwrap_or(false) @@ -161,6 +164,9 @@ pub fn build_observer_control_event( #[tauri::command] pub fn get_nsec(state: State<'_, AppState>) -> Result { + if cfg!(feature = "evaos-teams-managed") { + return Err("Managed identity export is disabled".to_string()); + } let keys = state.signing_keys()?; keys.secret_key() .to_bech32() @@ -172,6 +178,9 @@ pub async fn import_identity( nsec: String, app_handle: tauri::AppHandle, ) -> Result { + if cfg!(feature = "evaos-teams-managed") { + return Err("Managed identity import is disabled".to_string()); + } tokio::task::spawn_blocking(move || { let trimmed = nsec.trim(); let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; @@ -247,6 +256,9 @@ pub async fn import_identity( pub async fn persist_current_identity( app_handle: tauri::AppHandle, ) -> Result { + if cfg!(feature = "evaos-teams-managed") { + return Err("Managed identity persistence is broker-controlled".to_string()); + } tokio::task::spawn_blocking(move || { let state = app_handle.state::(); @@ -313,6 +325,9 @@ pub async fn persist_current_identity( /// would be confusing. #[tauri::command] pub async fn sign_out(app: tauri::AppHandle) -> Result<(), String> { + if cfg!(feature = "evaos-teams-managed") { + return Err("Use managed evaOS Teams logout".to_string()); + } if is_shared_identity() { return Err( "Sign out isn't available while BUZZ_SHARE_IDENTITY provides your identity. Unset BUZZ_SHARE_IDENTITY and BUZZ_PRIVATE_KEY, then relaunch to sign out." @@ -386,6 +401,9 @@ pub async fn sign_nostr_identity_binding( expires_at: String, state: State<'_, AppState>, ) -> Result { + if cfg!(feature = "evaos-teams-managed") { + return Err("Managed identity binding is broker-controlled".to_string()); + } nostr_bind::validate_signing_request( &challenge_id, &nonce, @@ -394,11 +412,7 @@ pub async fn sign_nostr_identity_binding( &expires_at, )?; - let keys = state - .keys - .lock() - .map_err(|error| error.to_string())? - .clone(); + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { let event = build_nostr_identity_binding_event( diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 1928d69d23d..b4772038a19 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -687,7 +687,7 @@ fn managed_agent_submission_auth_tag( return Ok(Some(auth_tag)); } - let owner_keys = state.keys.lock().map_err(|error| error.to_string())?; + let owner_keys = state.signing_keys()?; legacy_managed_agent_auth_tag(&owner_keys, agent_pubkey) } diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 639ae16da3d..fb054e12dcf 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -60,6 +60,10 @@ impl PairingHandle { } } +fn pairing_identity_export_allowed() -> bool { + !cfg!(feature = "evaos-teams-managed") +} + /// Start a NIP-AB pairing session as the source device. /// /// Creates a `PairingSession`, connects to the relay, and returns the @@ -71,6 +75,12 @@ pub async fn start_pairing( state: State<'_, AppState>, pairing: State<'_, PairingHandle>, ) -> Result { + if !pairing_identity_export_allowed() { + return Err( + "Device pairing is unavailable in evaOS Teams because managed identities cannot be exported" + .to_string(), + ); + } if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } @@ -530,10 +540,19 @@ where #[cfg(test)] mod pairing_relay_tests { use super::{ - pairing_relay_from_nip11, probe_pairing_relay, resolve_pairing_relay_url, PairingRelay, + pairing_identity_export_allowed, pairing_relay_from_nip11, probe_pairing_relay, + resolve_pairing_relay_url, PairingRelay, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + #[test] + fn managed_build_never_exports_identity_through_pairing() { + assert_eq!( + pairing_identity_export_allowed(), + !cfg!(feature = "evaos-teams-managed") + ); + } + #[tokio::test] async fn live_nip11_probe_discovers_configured_pairing_relay() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index 4d887ca39e9..b2c4848ba62 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -40,7 +40,7 @@ pub(in crate::commands) fn retain_persona_pending( let d_tag = persona_d_tag(persona); let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; // Monotonic created_at: read the retained head for this coordinate // and bump past it (NIP-AP step 3) so a same-second edit supersedes. let prior = @@ -102,7 +102,7 @@ pub(in crate::commands) fn tombstone_persona_pending( let result = (|| -> Result<(), String> { let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; let pubkey = keys.public_key().to_hex(); let event = build_persona_delete(d_tag, &pubkey)? .sign_with_keys(&keys) diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index ea9a6a49582..d65bbc13bf8 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -50,7 +50,7 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team let result = (|| -> Result<(), String> { let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; let pubkey = keys.public_key().to_hex(); // Monotonic created_at: bump past the retained head (NIP-AP step 3). let prior = @@ -104,7 +104,7 @@ fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { let result = (|| -> Result<(), String> { let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; let pubkey = keys.public_key().to_hex(); let event = build_team_delete(d_tag, &pubkey)? .sign_with_keys(&keys) diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 561e9019987..0e344c06532 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -10,6 +10,27 @@ use crate::managed_agents::{ }; use crate::relay; +#[cfg(any(test, feature = "evaos-teams-managed"))] +fn validate_managed_workspace_request( + authorized: bool, + allowed_relay: Option<&str>, + requested_relay: &str, + nsec: Option<&str>, +) -> Result<(), String> { + if nsec.is_some_and(|value| !value.trim().is_empty()) { + return Err("Managed workspaces cannot import a private key".to_string()); + } + if !authorized { + return Err("Managed workspace access is not authorized".to_string()); + } + let allowed_relay = + allowed_relay.ok_or_else(|| "Managed workspace relay is unavailable".to_string())?; + if requested_relay.trim_end_matches('/') != allowed_relay.trim_end_matches('/') { + return Err("Managed workspace relay must come from the active entitlement".to_string()); + } + Ok(()) +} + #[derive(Deserialize)] struct RelayInfoIcon { #[serde(default)] @@ -111,6 +132,23 @@ pub async fn apply_workspace( let state = app.state::(); // ── Validate before mutating ────────────────────────────────────────── + #[cfg(feature = "evaos-teams-managed")] + { + let authorized = state + .evaos_teams_authorized + .load(std::sync::atomic::Ordering::Acquire); + let allowed_relay = state + .relay_url_override + .lock() + .map_err(|error| error.to_string())? + .clone(); + validate_managed_workspace_request( + authorized, + allowed_relay.as_deref(), + &relay_url, + nsec.as_deref(), + )?; + } let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(nsec_trimmed) => { Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?) @@ -236,3 +274,44 @@ pub async fn apply_workspace( Ok(()) } + +#[cfg(test)] +mod managed_tests { + use super::validate_managed_workspace_request; + + #[test] + fn managed_workspace_rejects_private_key_and_relay_injection() { + assert!(validate_managed_workspace_request( + true, + Some("wss://relay.example.com"), + "wss://relay.example.com", + Some("nsec1secret"), + ) + .is_err()); + assert!(validate_managed_workspace_request( + true, + Some("wss://relay.example.com"), + "wss://attacker.example.com", + None, + ) + .is_err()); + } + + #[test] + fn managed_workspace_accepts_only_the_authorized_relay() { + assert!(validate_managed_workspace_request( + true, + Some("wss://relay.example.com"), + "wss://relay.example.com/", + None, + ) + .is_ok()); + assert!(validate_managed_workspace_request( + false, + Some("wss://relay.example.com"), + "wss://relay.example.com", + None, + ) + .is_err()); + } +} diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc367..3dce15f49c2 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -298,14 +298,28 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result u, - Err(e) => { - eprintln!("buzz-desktop: invalid deep link URL {url_str:?}: {e}"); + Err(_) => { + eprintln!("buzz-desktop: invalid deep link URL"); return; } }; + if url.scheme() == "evaos-teams" { + #[cfg(feature = "evaos-teams-managed")] + if crate::evaos_teams::handle_login_deep_link(app, &url) { + activate_main_window(app); + } + return; + } + if url.scheme() != "buzz" { - eprintln!("buzz-desktop: ignoring unsupported deep link scheme: {url_str}"); + eprintln!("buzz-desktop: ignoring unsupported deep link scheme"); + return; + } + + #[cfg(feature = "evaos-teams-managed")] + if !managed_deep_link_allowed(url.host_str()) { + eprintln!("buzz-desktop: managed build rejected an authority-mutating deep link"); return; } @@ -384,15 +398,29 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } } +#[cfg(any(test, feature = "evaos-teams-managed"))] +fn managed_deep_link_allowed(action: Option<&str>) -> bool { + matches!(action, Some("message")) +} + #[cfg(test)] mod tests { use url::Url; use super::{ - parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, - parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, + managed_deep_link_allowed, parse_add_community_deep_link, parse_join_deep_link, + parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink, + PendingCommunityDeepLinks, }; + #[test] + fn managed_deep_links_allow_messages_but_not_authority_changes() { + assert!(managed_deep_link_allowed(Some("message"))); + for action in ["connect", "join", "add-community", "nostr-bind"] { + assert!(!managed_deep_link_allowed(Some(action)), "{action}"); + } + } + fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { PendingCommunityDeepLink { id: id.to_owned(), diff --git a/desktop/src-tauri/src/evaos_teams.rs b/desktop/src-tauri/src/evaos_teams.rs new file mode 100644 index 00000000000..4f47b89c333 --- /dev/null +++ b/desktop/src-tauri/src/evaos_teams.rs @@ -0,0 +1,938 @@ +//! Electric-only evaOS Teams authentication. +//! +//! This module is intentionally separate from Buzz's native identity path. +//! Managed builds use an opaque Electric Desktop session plus a Nostr key held +//! under a distinct OS-keyring service. Neither value is serialized to the +//! renderer, migrated from native Buzz, or accepted from environment input. +#![cfg_attr(not(feature = "evaos-teams-managed"), allow(dead_code, unused_imports))] + +use std::{ + sync::{Arc, Mutex, OnceLock}, + time::Duration, +}; + +use axum::{routing::get, Router}; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tauri_plugin_opener::OpenerExt; +use tokio::{net::TcpListener, sync::oneshot}; +use url::Url; +use zeroize::Zeroizing; + +use crate::{app_state::AppState, secret_store::SecretStore}; + +mod api; +mod credentials; +mod login_callback; +#[cfg(feature = "evaos-teams-managed")] +mod logout; +use api::{post_json, ApiFailure}; +#[cfg(feature = "evaos-teams-managed")] +use credentials::managed_credential_entries; +#[cfg(test)] +use login_callback::callback_device_code; +#[cfg(feature = "evaos-teams-managed")] +use login_callback::clear_pending_login; +#[cfg(feature = "evaos-teams-managed")] +pub(crate) use login_callback::handle_login_deep_link; +#[cfg(all(test, feature = "evaos-teams-managed"))] +use login_callback::managed_login_callback_url; +use login_callback::{login_callback, LoginCallback}; +#[cfg(feature = "evaos-teams-managed")] +use logout::{begin_managed_logout, retry_pending_logout}; + +const DASHBOARD_ORIGIN: &str = "https://www.electricsheephq.com"; +const KEYRING_SERVICE: &str = "evaos-teams-desktop"; +const IDENTITY_KEY: &str = "identity"; +const SESSION_KEY: &str = "electric_desktop_session"; +const LOGOUT_PENDING_KEY: &str = "logout_pending"; +const LOGOUT_CONFIRMED_KEY: &str = "logout_confirmed"; +const PREVIOUS_SESSION_KEY: &str = "previous_electric_desktop_session"; +const KEY_BINDING_KIND: u16 = 27_235; +const KEY_BINDING_SCHEMA: &str = "evaos.buzz_key_binding.v1"; +const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +fn managed_store() -> &'static SecretStore { + static STORE: OnceLock = OnceLock::new(); + STORE.get_or_init(|| SecretStore::keyring(KEYRING_SERVICE)) +} + +#[cfg(feature = "evaos-teams-managed")] +fn verify_managed_store_writable() -> Result<(), String> { + let rewritten = managed_store() + .force_rewrite_current() + .map_err(|_| "evaOS Teams cannot write to macOS Keychain".to_string())?; + if managed_store().load_all_readonly()? != Some(rewritten) { + return Err("evaOS Teams could not verify its Keychain write".to_string()); + } + Ok(()) +} + +/// Public, renderer-safe entitlement projection. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EvaosTeamsEntitlement { + community_id: String, + relay_host: String, + public_key: Option, + role: String, + access_revision: u64, + expires_at: String, + refresh_after_seconds: u64, +} + +/// Public authentication state. It deliberately contains no Desktop token, +/// device code, private key, challenge nonce, email, or account selector. +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EvaosTeamsAuthStatus { + managed: bool, + phase: &'static str, + authenticated: bool, + keychain_available: bool, + message: Option, + entitlement: Option, +} + +impl EvaosTeamsAuthStatus { + #[cfg(not(feature = "evaos-teams-managed"))] + fn unmanaged() -> Self { + Self { + managed: false, + phase: "native", + authenticated: false, + keychain_available: true, + message: None, + entitlement: None, + } + } + + fn signed_out() -> Self { + Self { + managed: true, + phase: "signed_out", + authenticated: false, + keychain_available: true, + message: None, + entitlement: None, + } + } + + fn locked(message: String) -> Self { + Self { + managed: true, + phase: "keychain_locked", + authenticated: false, + keychain_available: false, + message: Some(message), + entitlement: None, + } + } + + fn reauth(message: String) -> Self { + Self { + managed: true, + phase: "reauth_required", + authenticated: false, + keychain_available: true, + message: Some(message), + entitlement: None, + } + } + + fn logout_pending(message: String) -> Self { + Self { + managed: true, + phase: "logout_pending", + authenticated: false, + keychain_available: true, + message: Some(message), + entitlement: None, + } + } + + fn active(entitlement: EvaosTeamsEntitlement) -> Self { + Self { + managed: true, + phase: "active", + authenticated: true, + keychain_available: true, + message: None, + entitlement: Some(entitlement), + } + } +} + +#[derive(Default)] +struct ManagedRuntime { + initialized: bool, + session: Option>, + previous_session: Option>, + keys: Option, + logout_pending: bool, + logout_confirmed: bool, +} + +/// Backend-only managed credential state. +#[derive(Default)] +pub(crate) struct EvaosTeamsState { + runtime: Mutex, + operation: tokio::sync::Mutex<()>, + pending_login: Mutex>>, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +struct KeyBindingChallenge { + schema_version: String, + identity_id: String, + membership_id: String, + community_id: String, + desktop_session_id: String, + public_key: String, + nonce: String, + expires_at: String, +} + +#[derive(Debug, Deserialize)] +struct EventTemplate { + kind: u16, + created_at: u64, + tags: Vec>, + content: String, +} + +#[derive(Debug, Deserialize)] +struct ChallengeResponse { + status: String, + challenge: KeyBindingChallenge, + event_template: EventTemplate, + relay_host: String, +} + +#[derive(Debug, Deserialize)] +struct EntitlementResponse { + status: String, + entitlement: EvaosTeamsEntitlement, +} + +#[derive(Debug, Deserialize)] +struct LogoutResponse { + status: String, +} + +#[derive(Debug, Deserialize)] +struct ClaimResponse { + desktop_session: String, + desktop_session_expires_at: String, +} + +fn normalize_device_code(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_uppercase) + .collect() +} + +fn dashboard_login_url(callback: &str, state: &str, device_code: &str) -> Result { + let mut url = Url::parse(&format!("{DASHBOARD_ORIGIN}/desktop-auth")) + .map_err(|error| format!("invalid dashboard URL: {error}"))?; + url.query_pairs_mut() + .append_pair("desktop_callback", callback) + .append_pair("desktop_auth_state", state) + .append_pair("callback_scheme", "evaos-teams") + .append_pair("fresh", device_code) + .append_pair("switch_account", "1") + .append_pair("prompt", "select_account"); + Ok(url) +} + +fn relay_websocket_url(relay_host: &str) -> Result { + let mut url = Url::parse(relay_host).map_err(|_| "invalid managed relay URL".to_string())?; + if url.scheme() != "https" + || url.username() != "" + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + || url.host_str().is_none() + { + return Err("managed relay must be a credential-free HTTPS origin".to_string()); + } + url.set_scheme("wss") + .map_err(|_| "could not convert managed relay URL".to_string())?; + Ok(url.to_string().trim_end_matches('/').to_string()) +} + +fn validate_entitlement( + entitlement: &EvaosTeamsEntitlement, + expected_public_key: &str, +) -> Result { + uuid::Uuid::parse_str(&entitlement.community_id) + .map_err(|_| "managed entitlement has an invalid community".to_string())?; + if entitlement.public_key.as_deref() != Some(expected_public_key) { + return Err("managed entitlement belongs to a different identity".to_string()); + } + if entitlement.role.trim().is_empty() + || entitlement.refresh_after_seconds < 30 + || entitlement.refresh_after_seconds > 3600 + { + return Err("managed entitlement policy is invalid".to_string()); + } + let expires_at = chrono::DateTime::parse_from_rfc3339(&entitlement.expires_at) + .map_err(|_| "managed entitlement expiry is invalid".to_string())?; + if expires_at <= chrono::Utc::now() { + return Err("managed entitlement has expired".to_string()); + } + relay_websocket_url(&entitlement.relay_host) +} + +fn bind_verified_entitlement( + mut entitlement: EvaosTeamsEntitlement, + expected_relay: &str, + public_key: &str, +) -> Result { + if entitlement.relay_host != expected_relay || entitlement.public_key.is_some() { + return Err("Managed key verification changed the server-selected scope".to_string()); + } + entitlement.public_key = Some(public_key.to_string()); + validate_entitlement(&entitlement, public_key)?; + Ok(entitlement) +} + +fn validate_challenge( + response: &ChallengeResponse, + expected_public_key: &str, +) -> Result<(), String> { + if response.status != "challenge_issued" + || response.challenge.schema_version != KEY_BINDING_SCHEMA + || response.challenge.public_key != expected_public_key + || response.event_template.kind != KEY_BINDING_KIND + { + return Err("managed key challenge does not match this device".to_string()); + } + for id in [ + &response.challenge.identity_id, + &response.challenge.membership_id, + &response.challenge.community_id, + &response.challenge.desktop_session_id, + ] { + uuid::Uuid::parse_str(id) + .map_err(|_| "managed key challenge contains an invalid identifier".to_string())?; + } + if response.challenge.nonce.len() != 43 + || !response.challenge.nonce.chars().all(|character| { + character.is_ascii_alphanumeric() || character == '_' || character == '-' + }) + { + return Err("managed key challenge nonce is invalid".to_string()); + } + let expected_content = serde_json::to_string(&response.challenge) + .map_err(|error| format!("could not serialize managed challenge: {error}"))?; + let expected_tags = vec![ + vec!["t".to_string(), "evaos-teams-key-binding".to_string()], + vec!["challenge".to_string(), response.challenge.nonce.clone()], + ]; + if response.event_template.content != expected_content + || response.event_template.tags != expected_tags + { + return Err("managed key challenge template is not canonical".to_string()); + } + let expires_at = chrono::DateTime::parse_from_rfc3339(&response.challenge.expires_at) + .map_err(|_| "managed key challenge expiry is invalid".to_string())?; + let now = chrono::Utc::now(); + if expires_at <= now || expires_at > now + chrono::Duration::minutes(5) { + return Err("managed key challenge has expired".to_string()); + } + let created_at = i64::try_from(response.event_template.created_at) + .map_err(|_| "managed key challenge timestamp is invalid".to_string())?; + if (created_at - now.timestamp()).abs() > 5 * 60 { + return Err("managed key challenge timestamp is invalid".to_string()); + } + relay_websocket_url(&response.relay_host)?; + Ok(()) +} + +fn signed_challenge( + response: &ChallengeResponse, + keys: &Keys, +) -> Result { + validate_challenge(response, &keys.public_key().to_hex())?; + let tags = response + .event_template + .tags + .iter() + .cloned() + .map(|tag| Tag::parse(tag).map_err(|error| format!("invalid challenge tag: {error}"))) + .collect::, _>>()?; + let event = EventBuilder::new( + Kind::Custom(response.event_template.kind), + response.event_template.content.clone(), + ) + .tags(tags) + .custom_created_at(Timestamp::from(response.event_template.created_at)) + .sign_with_keys(keys) + .map_err(|error| format!("could not sign managed key challenge: {error}"))?; + serde_json::to_value(event) + .map_err(|error| format!("could not encode managed key challenge: {error}")) +} + +#[cfg(feature = "evaos-teams-managed")] +fn disable_managed_access(app_state: &AppState) { + app_state.disable_evaos_teams_access(); +} + +#[cfg(feature = "evaos-teams-managed")] +fn install_entitlement( + app_state: &AppState, + keys: &Keys, + entitlement: &EvaosTeamsEntitlement, +) -> Result<(), String> { + let relay = validate_entitlement(entitlement, &keys.public_key().to_hex())?; + let expires_at = chrono::DateTime::parse_from_rfc3339(&entitlement.expires_at) + .map_err(|_| "managed entitlement expiry is invalid".to_string())? + .timestamp(); + app_state.install_evaos_teams_access(keys.clone(), relay, expires_at) +} + +#[cfg(feature = "evaos-teams-managed")] +fn initialize_runtime(state: &EvaosTeamsState) -> Result<(), String> { + let mut runtime = state.runtime.lock().map_err(|error| error.to_string())?; + if runtime.initialized { + return Ok(()); + } + let stored = managed_store().load_all_readonly()?; + let Some(stored) = stored else { + runtime.initialized = true; + return Ok(()); + }; + let identity = stored.get(IDENTITY_KEY); + let session = stored.get(SESSION_KEY); + match (identity, session) { + (None, None) if stored.is_empty() => { + runtime.initialized = true; + Ok(()) + } + (Some(identity), Some(session)) if !session.trim().is_empty() => { + runtime.keys = Some( + Keys::parse(identity.trim()) + .map_err(|_| "managed Keychain identity is invalid".to_string())?, + ); + runtime.session = Some(Zeroizing::new(session.clone())); + runtime.logout_pending = stored + .get(LOGOUT_PENDING_KEY) + .is_some_and(|value| value == "1"); + runtime.logout_confirmed = stored + .get(LOGOUT_CONFIRMED_KEY) + .is_some_and(|value| value == "1"); + if runtime.logout_pending && runtime.logout_confirmed { + return Err("managed logout state is inconsistent".to_string()); + } + runtime.previous_session = stored + .get(PREVIOUS_SESSION_KEY) + .filter(|value| { + (runtime.logout_pending || runtime.logout_confirmed) && !value.trim().is_empty() + }) + .map(|value| Zeroizing::new(value.clone())); + runtime.initialized = true; + Ok(()) + } + _ => Err("managed Keychain identity and session are incomplete".to_string()), + } +} + +#[cfg(feature = "evaos-teams-managed")] +async fn current_credentials( + state: &EvaosTeamsState, +) -> Result< + ( + Zeroizing, + Keys, + bool, + bool, + Option>, + ), + String, +> { + initialize_runtime(state)?; + let runtime = state.runtime.lock().map_err(|error| error.to_string())?; + let session = runtime + .session + .clone() + .ok_or_else(|| "Sign in to evaOS Teams first".to_string())?; + let keys = runtime + .keys + .clone() + .ok_or_else(|| "Managed identity is unavailable".to_string())?; + Ok(( + session, + keys, + runtime.logout_pending, + runtime.logout_confirmed, + runtime.previous_session.clone(), + )) +} + +#[cfg(feature = "evaos-teams-managed")] +async fn remote_logout(client: &reqwest::Client, token: &str) -> Result<(), ApiFailure> { + let response: LogoutResponse = post_json( + client, + "evaos-teams-access", + Some(token), + serde_json::json!({ "action": "logout" }), + ) + .await?; + if response.status == "logged_out" { + Ok(()) + } else { + Err(ApiFailure { + status: reqwest::StatusCode::BAD_GATEWAY, + code: "invalid_logout_response".to_string(), + }) + } +} + +#[cfg(feature = "evaos-teams-managed")] +fn wipe_managed_store() -> Result<(), String> { + managed_store().delete_all_with_legacy_cleanup()?; + if !managed_store().verify_fully_wiped() { + return Err("could not verify managed Keychain wipe".to_string()); + } + Ok(()) +} + +#[cfg(feature = "evaos-teams-managed")] +fn reset_runtime_for_reload(state: &EvaosTeamsState, app_state: &AppState) { + disable_managed_access(app_state); + if let Ok(mut runtime) = state.runtime.lock() { + *runtime = ManagedRuntime::default(); + } +} + +#[cfg(feature = "evaos-teams-managed")] +fn stage_pending_claim( + state: &EvaosTeamsState, + app_state: &AppState, + session: &str, + keys: &Keys, + previous_session: Option<&str>, +) -> Result<(), String> { + disable_managed_access(app_state); + let pending = managed_credential_entries(keys, session, true, false, previous_session)?; + managed_store() + .replace_all(&pending) + .map_err(|_| "Could not record the pending managed session in Keychain".to_string())?; + if managed_store().load_all_readonly()? != Some(pending) { + return Err("Managed Keychain pending-session verification failed".to_string()); + } + let mut runtime = state.runtime.lock().map_err(|error| error.to_string())?; + *runtime = ManagedRuntime { + initialized: true, + session: Some(Zeroizing::new(session.to_string())), + previous_session: previous_session.map(|value| Zeroizing::new(value.to_string())), + keys: Some(keys.clone()), + logout_pending: true, + logout_confirmed: false, + }; + Ok(()) +} + +#[cfg(feature = "evaos-teams-managed")] +async fn finish_failed_staged_login( + state: &EvaosTeamsState, + app_state: &AppState, + session: &str, + original_error: String, +) -> Result { + let outcome = retry_pending_logout(&app_state.http_client, state, app_state, session).await; + if outcome.phase == "signed_out" { + Err(original_error) + } else { + Ok(outcome) + } +} + +#[cfg(feature = "evaos-teams-managed")] +async fn get_remote_entitlement( + client: &reqwest::Client, + token: &str, +) -> Result { + let response: EntitlementResponse = post_json( + client, + "evaos-teams-access", + Some(token), + serde_json::json!({ "action": "get_entitlement" }), + ) + .await?; + if response.status == "active" { + Ok(response.entitlement) + } else { + Err(ApiFailure { + status: reqwest::StatusCode::BAD_GATEWAY, + code: "inactive_entitlement".to_string(), + }) + } +} + +#[cfg(feature = "evaos-teams-managed")] +fn persist_managed_credentials( + state: &EvaosTeamsState, + app_state: &AppState, + session: String, + keys: Keys, + entitlement: EvaosTeamsEntitlement, +) -> Result { + let replacement = managed_credential_entries(&keys, &session, false, false, None)?; + let activation = (|| -> Result<(), String> { + managed_store() + .replace_all(&replacement) + .map_err(|_| "Could not save managed access in macOS Keychain".to_string())?; + if managed_store().load_all_readonly()? != Some(replacement) { + return Err("Managed Keychain read-back verification failed".to_string()); + } + let mut runtime = state.runtime.lock().map_err(|error| error.to_string())?; + *runtime = ManagedRuntime { + initialized: true, + session: Some(Zeroizing::new(session.clone())), + previous_session: None, + keys: Some(keys.clone()), + logout_pending: false, + logout_confirmed: false, + }; + drop(runtime); + install_entitlement(app_state, &keys, &entitlement) + })(); + if let Err(error) = activation { + reset_runtime_for_reload(state, app_state); + return Err(error); + } + Ok(EvaosTeamsAuthStatus::active(entitlement)) +} + +/// Return current managed-auth status and perform a bounded entitlement refresh. +#[tauri::command] +pub(crate) async fn get_evaos_teams_auth_status( + state: State<'_, EvaosTeamsState>, + app_state: State<'_, AppState>, +) -> Result { + #[cfg(not(feature = "evaos-teams-managed"))] + { + let _ = (&state, &app_state); + Ok(EvaosTeamsAuthStatus::unmanaged()) + } + + #[cfg(feature = "evaos-teams-managed")] + { + let _operation = state.operation.lock().await; + if let Err(error) = initialize_runtime(&state) { + disable_managed_access(&app_state); + return Ok(EvaosTeamsAuthStatus::locked(error)); + } + let credentials = current_credentials(&state).await; + let (session, keys, logout_pending, logout_confirmed, _) = match credentials { + Ok(value) => value, + Err(_) => { + disable_managed_access(&app_state); + return Ok(EvaosTeamsAuthStatus::signed_out()); + } + }; + if logout_pending || logout_confirmed { + return Ok( + retry_pending_logout(&app_state.http_client, &state, &app_state, &session).await, + ); + } + let response = get_remote_entitlement(&app_state.http_client, &session).await; + match response { + Ok(entitlement) => { + if let Err(error) = install_entitlement(&app_state, &keys, &entitlement) { + disable_managed_access(&app_state); + return Ok(EvaosTeamsAuthStatus::reauth(error)); + } + Ok(EvaosTeamsAuthStatus::active(entitlement)) + } + Err(error) => { + disable_managed_access(&app_state); + Ok(EvaosTeamsAuthStatus::reauth(format!( + "Managed access could not be refreshed: {error}" + ))) + } + } + } +} + +#[cfg(feature = "evaos-teams-managed")] +async fn claim_device_code( + client: &reqwest::Client, + device_code: &str, +) -> Result { + let response: ClaimResponse = post_json( + client, + "desktop-runtime-session", + None, + serde_json::json!({ + "action": "claim_desktop_device_code", + "device_code": device_code, + }), + ) + .await + .map_err(|error| format!("Device sign-in could not be claimed: {error}"))?; + if response.desktop_session.len() < 32 || response.desktop_session.len() > 512 { + return Err("Device sign-in returned an invalid Desktop session".to_string()); + } + let expires_at = chrono::DateTime::parse_from_rfc3339(&response.desktop_session_expires_at) + .map_err(|_| "Device sign-in returned an invalid expiry".to_string())?; + if expires_at <= chrono::Utc::now() { + return Err("Device sign-in returned an expired Desktop session".to_string()); + } + Ok(response) +} + +#[cfg(feature = "evaos-teams-managed")] +async fn bind_identity( + client: &reqwest::Client, + token: &str, + keys: &Keys, +) -> Result { + let public_key = keys.public_key().to_hex(); + let challenge: ChallengeResponse = post_json( + client, + "evaos-teams-access", + Some(token), + serde_json::json!({ + "action": "issue_key_challenge", + "public_key": public_key, + "device_metadata": { + "label": "evaOS Teams", + "app_version": env!("CARGO_PKG_VERSION"), + "platform": std::env::consts::OS, + }, + }), + ) + .await + .map_err(|error| format!("Managed key challenge was not available: {error}"))?; + let signed_event = signed_challenge(&challenge, keys)?; + let verified: EntitlementResponse = post_json( + client, + "evaos-teams-access", + Some(token), + serde_json::json!({ + "action": "verify_key_challenge", + "signed_event": signed_event, + }), + ) + .await + .map_err(|error| format!("Managed key challenge was rejected: {error}"))?; + if verified.status != "active" { + return Err("Managed key verification did not activate access".to_string()); + } + // The verification response intentionally omits public_key; the key is + // already bound by the exact signed challenge above. Install the locally + // derived public key so every later entitlement validation stays strict. + bind_verified_entitlement(verified.entitlement, &challenge.relay_host, &public_key) +} + +/// Start an account-selecting browser login and complete device-code claim and +/// server key binding entirely in Rust. +#[tauri::command] +pub(crate) async fn start_evaos_teams_login( + app: tauri::AppHandle, + state: State<'_, EvaosTeamsState>, + app_state: State<'_, AppState>, +) -> Result { + #[cfg(not(feature = "evaos-teams-managed"))] + { + let _ = (&app, &state, &app_state); + Err("evaOS Teams managed login is not enabled in this build".to_string()) + } + + #[cfg(feature = "evaos-teams-managed")] + { + let _operation = state.operation.lock().await; + // Prove Keychain reachability before opening the browser. Managed mode + // never falls back to a plaintext identity or Desktop token. + verify_managed_store_writable()?; + + initialize_runtime(&state)?; + let mut reauth_credentials = None; + if let Ok((session, keys, logout_pending, logout_confirmed, _)) = + current_credentials(&state).await + { + if logout_pending || logout_confirmed { + return Ok(retry_pending_logout( + &app_state.http_client, + &state, + &app_state, + &session, + ) + .await); + } else { + match get_remote_entitlement(&app_state.http_client, &session).await { + Ok(entitlement) + if validate_entitlement(&entitlement, &keys.public_key().to_hex()) + .is_ok() => + { + // An explicit account switch starts with a confirmed, + // atomic logout. If browser sign-in is later canceled, + // the previous account remains safely revoked. + let outcome = begin_managed_logout(&state, &app_state).await?; + if outcome.phase != "signed_out" { + return Ok(outcome); + } + } + _ => { + // The old Desktop token can no longer prove logout. + // Permit only same-account reauthentication, established + // after claim by an entitlement for this exact old key. + reauth_credentials = Some((session, keys)); + } + } + } + } + + disable_managed_access(&app_state); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|error| format!("could not start local sign-in callback: {error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("could not read local sign-in callback: {error}"))? + .port(); + let auth_state = uuid::Uuid::new_v4().simple().to_string(); + let device_code = uuid::Uuid::new_v4().simple().to_string().to_uppercase(); + let callback = format!("http://127.0.0.1:{port}/auth/callback"); + let login_url = dashboard_login_url(&callback, &auth_state, &device_code)?; + let (sender, receiver) = oneshot::channel(); + let callback_state = Arc::new(LoginCallback::new(auth_state, device_code, sender)); + *state + .pending_login + .lock() + .map_err(|error| error.to_string())? = Some(callback_state.clone()); + let router = Router::new() + .route("/auth/callback", get(login_callback)) + .with_state(callback_state.clone()); + let server = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + if let Err(error) = app.opener().open_url(login_url.as_str(), None::<&str>) { + server.abort(); + clear_pending_login(&state, &callback_state); + return Err(format!("could not open ElectricSheep sign-in: {error}")); + } + let code = match tokio::time::timeout(LOGIN_TIMEOUT, receiver).await { + Ok(Ok(Ok(code))) => code, + Ok(Ok(Err(error))) => { + server.abort(); + clear_pending_login(&state, &callback_state); + return Err(error); + } + Ok(Err(_)) => { + server.abort(); + clear_pending_login(&state, &callback_state); + return Err("local sign-in callback stopped unexpectedly".to_string()); + } + Err(_) => { + server.abort(); + clear_pending_login(&state, &callback_state); + return Err("ElectricSheep sign-in timed out".to_string()); + } + }; + server.abort(); + clear_pending_login(&state, &callback_state); + + let claim = claim_device_code(&app_state.http_client, &code).await?; + let keys = reauth_credentials + .as_ref() + .map(|(_, keys)| keys.clone()) + .unwrap_or_else(Keys::generate); + let previous_session = reauth_credentials + .as_ref() + .map(|(session, _)| session.as_str()); + if let Err(error) = stage_pending_claim( + &state, + &app_state, + &claim.desktop_session, + &keys, + previous_session, + ) { + // The dashboard claim response is the first point at which the + // bearer exists, so a crash before this write cannot be recovered + // client-side. On an observed write failure, synchronously revoke + // the new session and report whether that cleanup was confirmed. + return match remote_logout(&app_state.http_client, &claim.desktop_session).await { + Ok(()) => { + reset_runtime_for_reload(&state, &app_state); + Err(error) + } + Err(cleanup_error) => Err(format!( + "{error}; the newly claimed session could not be durably recorded or confirmed revoked: {cleanup_error}" + )), + }; + } + + if reauth_credentials.is_some() { + match get_remote_entitlement(&app_state.http_client, &claim.desktop_session).await { + Ok(entitlement) + if validate_entitlement(&entitlement, &keys.public_key().to_hex()).is_ok() => + { + return persist_managed_credentials( + &state, + &app_state, + claim.desktop_session, + keys, + entitlement, + ); + } + _ => { + return finish_failed_staged_login( + &state, + &app_state, + &claim.desktop_session, + "The previous account can no longer prove logout, so evaOS Teams will not replace it with a different account" + .to_string(), + ) + .await; + } + } + } + let entitlement = + match bind_identity(&app_state.http_client, &claim.desktop_session, &keys).await { + Ok(entitlement) => entitlement, + Err(error) => { + return finish_failed_staged_login( + &state, + &app_state, + &claim.desktop_session, + error, + ) + .await; + } + }; + persist_managed_credentials(&state, &app_state, claim.desktop_session, keys, entitlement) + } +} + +/// Revoke the server session and active human relay identity, then wipe only +/// the managed Keychain service. A network-ambiguous logout remains durable +/// and is retried on the next status check or relaunch. +#[tauri::command] +pub(crate) async fn logout_evaos_teams( + state: State<'_, EvaosTeamsState>, + app_state: State<'_, AppState>, +) -> Result { + #[cfg(not(feature = "evaos-teams-managed"))] + { + let _ = (&state, &app_state); + Err("evaOS Teams managed login is not enabled in this build".to_string()) + } + + #[cfg(feature = "evaos-teams-managed")] + { + let _operation = state.operation.lock().await; + begin_managed_logout(&state, &app_state).await + } +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/evaos_teams/api.rs b/desktop/src-tauri/src/evaos_teams/api.rs new file mode 100644 index 00000000000..a8fd33d68cd --- /dev/null +++ b/desktop/src-tauri/src/evaos_teams/api.rs @@ -0,0 +1,73 @@ +use std::time::Duration; + +use serde::Deserialize; +use url::Url; + +const SUPABASE_ORIGIN: &str = "https://rhfojelkgtwcxnrfhtlj.supabase.co"; +// Supabase publishable keys are intentionally public client identifiers. This +// value grants no service-role access; authorization still comes exclusively +// from the opaque Desktop session returned after browser authentication. +const SUPABASE_PUBLISHABLE_KEY: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmUiLCJyZWYiOiJyaGZvamVsa2d0d2N4bnJmaHRsaiIsInJvbGUiOiJhbm9uIiwiaWF0IjoxNzczMjQzNTc2LCJleHAiOjIwODg4MTk1NzZ9.X8mJHaYIolCmx6j_473GGb05OyFTy43Hq-BEelZRjAE"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug)] +pub(super) struct ApiFailure { + pub(super) status: reqwest::StatusCode, + pub(super) code: String, +} + +impl std::fmt::Display for ApiFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "HTTP {} ({})", self.status, self.code) + } +} + +fn functions_url(name: &str) -> Result { + Url::parse(&format!("{SUPABASE_ORIGIN}/functions/v1/{name}")) + .map_err(|error| format!("invalid managed API URL: {error}")) +} + +pub(super) async fn post_json Deserialize<'de>>( + client: &reqwest::Client, + function: &str, + bearer: Option<&str>, + body: serde_json::Value, +) -> Result { + let url = functions_url(function).map_err(|code| ApiFailure { + status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, + code, + })?; + let mut request = client + .post(url) + .header("apikey", SUPABASE_PUBLISHABLE_KEY) + .header("x-client-info", "evaos-teams-desktop/0.4.23") + .timeout(REQUEST_TIMEOUT) + .json(&body); + if let Some(token) = bearer { + request = request.bearer_auth(token); + } + let response = request.send().await.map_err(|error| ApiFailure { + status: reqwest::StatusCode::SERVICE_UNAVAILABLE, + code: format!("network_error:{}", error.is_timeout()), + })?; + let status = response.status(); + if !status.is_success() { + return Err(ApiFailure { + status, + // A remote error body is untrusted and may echo a device code, + // signed challenge, or bearer token. Only return a local category. + code: "request_failed".to_string(), + }); + } + let value = response + .json::() + .await + .map_err(|_| ApiFailure { + status, + code: "invalid_json".to_string(), + })?; + serde_json::from_value(value).map_err(|_| ApiFailure { + status, + code: "invalid_response".to_string(), + }) +} diff --git a/desktop/src-tauri/src/evaos_teams/credentials.rs b/desktop/src-tauri/src/evaos_teams/credentials.rs new file mode 100644 index 00000000000..d84c672e977 --- /dev/null +++ b/desktop/src-tauri/src/evaos_teams/credentials.rs @@ -0,0 +1,40 @@ +use std::collections::HashMap; + +use nostr::{Keys, ToBech32}; + +use super::{ + IDENTITY_KEY, LOGOUT_CONFIRMED_KEY, LOGOUT_PENDING_KEY, PREVIOUS_SESSION_KEY, SESSION_KEY, +}; + +pub(super) fn managed_credential_entries( + keys: &Keys, + session: &str, + logout_pending: bool, + logout_confirmed: bool, + previous_session: Option<&str>, +) -> Result, String> { + if logout_pending && logout_confirmed { + return Err("managed logout cannot be pending and confirmed".to_string()); + } + let identity = keys + .secret_key() + .to_bech32() + .map_err(|error| format!("could not encode managed identity: {error}"))?; + let mut entries = HashMap::from([ + (IDENTITY_KEY.to_string(), identity), + (SESSION_KEY.to_string(), session.to_string()), + ]); + if logout_pending { + entries.insert(LOGOUT_PENDING_KEY.to_string(), "1".to_string()); + } + if logout_confirmed { + entries.insert(LOGOUT_CONFIRMED_KEY.to_string(), "1".to_string()); + } + if let Some(previous_session) = previous_session.filter(|value| !value.trim().is_empty()) { + entries.insert( + PREVIOUS_SESSION_KEY.to_string(), + previous_session.to_string(), + ); + } + Ok(entries) +} diff --git a/desktop/src-tauri/src/evaos_teams/login_callback.rs b/desktop/src-tauri/src/evaos_teams/login_callback.rs new file mode 100644 index 00000000000..84bed4c4b80 --- /dev/null +++ b/desktop/src-tauri/src/evaos_teams/login_callback.rs @@ -0,0 +1,137 @@ +use std::sync::{Arc, Mutex}; + +use axum::{ + extract::{RawQuery, State as AxumState}, + http::StatusCode, + response::{Html, IntoResponse, Response}, +}; +use tauri::Manager; +use tokio::sync::oneshot; +use url::Url; + +use super::{normalize_device_code, EvaosTeamsState}; + +pub(super) struct LoginCallback { + expected_state: String, + expected_code: String, + sender: Mutex>>>, +} + +impl LoginCallback { + pub(super) fn new( + expected_state: String, + expected_code: String, + sender: oneshot::Sender>, + ) -> Self { + Self { + expected_state, + expected_code, + sender: Mutex::new(Some(sender)), + } + } + + pub(super) fn try_complete(&self, query: &[(String, String)]) -> Result<(), String> { + let code = callback_device_code(query, &self.expected_state, &self.expected_code)?; + let sender = self + .sender + .lock() + .map_err(|error| error.to_string())? + .take() + .ok_or_else(|| "Authentication callback was already completed".to_string())?; + let _ = sender.send(Ok(code)); + Ok(()) + } +} + +pub(super) fn callback_device_code( + query: &[(String, String)], + expected_state: &str, + expected_code: &str, +) -> Result { + if query.len() != 2 { + return Err("Authentication callback contained unexpected fields".to_string()); + } + let states: Vec<&str> = query + .iter() + .filter_map(|(key, value)| (key == "desktop_auth_state").then_some(value.as_str())) + .collect(); + let codes: Vec<&str> = query + .iter() + .filter_map(|(key, value)| (key == "device_code").then_some(value.as_str())) + .collect(); + let received_state = (states.len() == 1).then_some(states[0]); + let received_code = (codes.len() == 1).then_some(codes[0]); + match (received_state, received_code) { + (Some(received_state), Some(received_code)) + if received_state == expected_state + && normalize_device_code(received_code) == expected_code => + { + Ok(expected_code.to_string()) + } + _ => Err("Authentication callback did not match this login attempt".to_string()), + } +} + +pub(super) async fn login_callback( + RawQuery(raw_query): RawQuery, + AxumState(state): AxumState>, +) -> Response { + let query = raw_query + .as_deref() + .map(|raw| { + url::form_urlencoded::parse(raw.as_bytes()) + .into_owned() + .collect::>() + }) + .unwrap_or_default(); + match state.try_complete(&query) { + Ok(()) => ( + StatusCode::OK, + Html("evaOS Teams

Sign-in received. Return to evaOS Teams.

"), + ) + .into_response(), + Err(_) => ( + StatusCode::BAD_REQUEST, + Html("evaOS Teams

This sign-in callback is not valid.

"), + ) + .into_response(), + } +} + +#[cfg(feature = "evaos-teams-managed")] +pub(super) fn managed_login_callback_url(url: &Url) -> bool { + url.scheme() == "evaos-teams" + && url.host_str() == Some("auth") + && url.path() == "/callback" + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() +} + +#[cfg(feature = "evaos-teams-managed")] +pub(crate) fn handle_login_deep_link(app: &tauri::AppHandle, url: &Url) -> bool { + if !managed_login_callback_url(url) { + return false; + } + let query = url.query_pairs().into_owned().collect::>(); + let state = app.state::(); + let pending = state + .pending_login + .lock() + .ok() + .and_then(|pending| pending.clone()); + pending.is_some_and(|pending| pending.try_complete(&query).is_ok()) +} + +#[cfg(feature = "evaos-teams-managed")] +pub(super) fn clear_pending_login(state: &EvaosTeamsState, attempt: &Arc) { + if let Ok(mut pending) = state.pending_login.lock() { + if pending + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, attempt)) + { + *pending = None; + } + } +} diff --git a/desktop/src-tauri/src/evaos_teams/logout.rs b/desktop/src-tauri/src/evaos_teams/logout.rs new file mode 100644 index 00000000000..7010d615379 --- /dev/null +++ b/desktop/src-tauri/src/evaos_teams/logout.rs @@ -0,0 +1,126 @@ +use super::*; + +fn finish_confirmed_logout(state: &EvaosTeamsState) -> EvaosTeamsAuthStatus { + let previous = state + .runtime + .lock() + .ok() + .and_then(|runtime| runtime.previous_session.clone().zip(runtime.keys.clone())); + if let Some((previous_session, keys)) = previous { + let restored = managed_credential_entries(&keys, &previous_session, false, false, None); + return match restored.and_then(|restored| { + managed_store().replace_all(&restored)?; + if managed_store().load_all_readonly()? != Some(restored) { + return Err("Managed Keychain previous-session verification failed".to_string()); + } + let mut runtime = state.runtime.lock().map_err(|error| error.to_string())?; + *runtime = ManagedRuntime { + initialized: true, + session: Some(previous_session), + previous_session: None, + keys: Some(keys), + logout_pending: false, + logout_confirmed: false, + }; + Ok(()) + }) { + Ok(()) => EvaosTeamsAuthStatus::reauth( + "The prior account was preserved. Sign in to that account again.".to_string(), + ), + Err(error) => EvaosTeamsAuthStatus::locked(error), + }; + } + match wipe_managed_store() { + Ok(()) => { + if let Ok(mut runtime) = state.runtime.lock() { + *runtime = ManagedRuntime { + initialized: true, + ..ManagedRuntime::default() + }; + } + EvaosTeamsAuthStatus::signed_out() + } + Err(error) => EvaosTeamsAuthStatus::locked(error), + } +} + +pub(super) async fn retry_pending_logout( + client: &reqwest::Client, + state: &EvaosTeamsState, + app_state: &AppState, + session: &str, +) -> EvaosTeamsAuthStatus { + disable_managed_access(app_state); + let snapshot = state.runtime.lock().ok().and_then(|runtime| { + runtime.keys.clone().map(|keys| { + ( + keys, + runtime.previous_session.clone(), + runtime.logout_confirmed, + ) + }) + }); + let Some((keys, previous_session, already_confirmed)) = snapshot else { + return EvaosTeamsAuthStatus::locked( + "Managed logout credentials are unavailable".to_string(), + ); + }; + if !already_confirmed { + if let Err(error) = remote_logout(client, session).await { + return EvaosTeamsAuthStatus::logout_pending(format!( + "Remote logout is still pending: {error}" + )); + } + let confirmed = match managed_credential_entries( + &keys, + session, + false, + true, + previous_session.as_deref().map(String::as_str), + ) { + Ok(confirmed) => confirmed, + Err(error) => return EvaosTeamsAuthStatus::locked(error), + }; + if let Err(error) = managed_store().replace_all(&confirmed).and_then(|()| { + if managed_store().load_all_readonly()? == Some(confirmed) { + Ok(()) + } else { + Err("Managed Keychain logout-confirmation verification failed".to_string()) + } + }) { + return EvaosTeamsAuthStatus::locked(format!( + "Remote logout succeeded, but its local checkpoint failed: {error}" + )); + } + match state.runtime.lock() { + Ok(mut runtime) => { + runtime.logout_pending = false; + runtime.logout_confirmed = true; + } + Err(error) => { + return EvaosTeamsAuthStatus::locked(format!( + "Remote logout was confirmed, but runtime recovery is locked: {error}" + )); + } + } + } + finish_confirmed_logout(state) +} + +pub(super) async fn begin_managed_logout( + state: &EvaosTeamsState, + app_state: &AppState, +) -> Result { + let (session, keys, _, _, _) = current_credentials(state).await?; + disable_managed_access(app_state); + let pending = managed_credential_entries(&keys, &session, true, false, None)?; + managed_store() + .replace_all(&pending) + .map_err(|_| "Could not record durable managed logout".to_string())?; + if let Ok(mut runtime) = state.runtime.lock() { + runtime.logout_pending = true; + runtime.logout_confirmed = false; + runtime.previous_session = None; + } + Ok(retry_pending_logout(&app_state.http_client, state, app_state, &session).await) +} diff --git a/desktop/src-tauri/src/evaos_teams/tests.rs b/desktop/src-tauri/src/evaos_teams/tests.rs new file mode 100644 index 00000000000..6fc13e4cf84 --- /dev/null +++ b/desktop/src-tauri/src/evaos_teams/tests.rs @@ -0,0 +1,389 @@ +use super::*; +use std::collections::HashMap; + +fn challenge(keys: &Keys) -> ChallengeResponse { + let challenge = KeyBindingChallenge { + schema_version: KEY_BINDING_SCHEMA.to_string(), + identity_id: "10000000-0000-4000-8000-000000000001".to_string(), + membership_id: "10000000-0000-4000-8000-000000000002".to_string(), + community_id: "10000000-0000-4000-8000-000000000003".to_string(), + desktop_session_id: "10000000-0000-4000-8000-000000000004".to_string(), + public_key: keys.public_key().to_hex(), + nonce: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ".to_string(), + expires_at: (chrono::Utc::now() + chrono::Duration::minutes(2)).to_rfc3339(), + }; + let content = serde_json::to_string(&challenge).unwrap(); + ChallengeResponse { + status: "challenge_issued".to_string(), + event_template: EventTemplate { + kind: KEY_BINDING_KIND, + created_at: chrono::Utc::now().timestamp() as u64, + tags: vec![ + vec!["t".to_string(), "evaos-teams-key-binding".to_string()], + vec!["challenge".to_string(), challenge.nonce.clone()], + ], + content, + }, + challenge, + relay_host: "https://relay.example.com".to_string(), + } +} + +#[test] +fn login_url_is_account_selecting_and_callback_bound() { + let url = dashboard_login_url( + "http://127.0.0.1:4567/auth/callback", + "state-12345678", + "AABBCCDDEEFF00112233445566778899", + ) + .unwrap(); + let pairs: HashMap<_, _> = url.query_pairs().into_owned().collect(); + assert_eq!(url.origin().ascii_serialization(), DASHBOARD_ORIGIN); + assert_eq!(url.path(), "/desktop-auth"); + assert_eq!(pairs.get("callback_scheme").unwrap(), "evaos-teams"); + assert_eq!(pairs.get("switch_account").unwrap(), "1"); + assert_eq!(pairs.get("prompt").unwrap(), "select_account"); + assert_eq!( + pairs.get("desktop_callback").unwrap(), + "http://127.0.0.1:4567/auth/callback" + ); +} + +#[test] +fn relay_validation_accepts_only_https_origin() { + assert_eq!( + relay_websocket_url("https://relay.example.com:7447").unwrap(), + "wss://relay.example.com:7447" + ); + for invalid in [ + "http://relay.example.com", + "https://user@relay.example.com", + "https://relay.example.com/path", + "https://relay.example.com/?customer=other", + "wss://relay.example.com", + ] { + assert!(relay_websocket_url(invalid).is_err(), "{invalid}"); + } +} + +#[test] +fn challenge_signature_uses_exact_server_template() { + let keys = Keys::generate(); + let response = challenge(&keys); + let event = signed_challenge(&response, &keys).unwrap(); + assert_eq!(event["kind"], KEY_BINDING_KIND); + assert_eq!(event["content"], response.event_template.content); + assert_eq!( + event["tags"], + serde_json::to_value(&response.event_template.tags).unwrap() + ); + assert_eq!(event["pubkey"], keys.public_key().to_hex()); +} + +#[test] +fn altered_challenge_template_is_rejected() { + let keys = Keys::generate(); + let mut response = challenge(&keys); + response.event_template.tags.push(vec![ + "community".to_string(), + response.challenge.community_id.clone(), + ]); + assert!(signed_challenge(&response, &keys).is_err()); +} + +#[test] +fn callback_requires_exact_state_and_normalized_code() { + let expected_code = "AABBCCDDEEFF00112233445566778899"; + let expected_state = "state-12345678"; + let valid = vec![ + ("desktop_auth_state".to_string(), expected_state.to_string()), + ( + "device_code".to_string(), + "aabb-ccdd-eeff-0011-2233-4455-6677-8899".to_string(), + ), + ]; + assert_eq!( + callback_device_code(&valid, expected_state, expected_code).unwrap(), + expected_code + ); + let mut wrong_state = valid.clone(); + wrong_state[0].1 = "other-state".to_string(); + assert!(callback_device_code(&wrong_state, expected_state, expected_code).is_err()); + let mut wrong_code = valid.clone(); + wrong_code[1].1 = "BBBBCCDDEEFF00112233445566778899".to_string(); + assert!(callback_device_code(&wrong_code, expected_state, expected_code).is_err()); + let mut credential_bearing = valid.clone(); + credential_bearing.push(("desktop_session".to_string(), "secret".to_string())); + assert!(callback_device_code(&credential_bearing, expected_state, expected_code).is_err()); + let duplicate = vec![valid[0].clone(), valid[0].clone(), valid[1].clone()]; + assert!(callback_device_code(&duplicate, expected_state, expected_code).is_err()); +} + +#[test] +fn invalid_callback_does_not_consume_valid_attempt() { + let (sender, mut receiver) = oneshot::channel(); + let callback = LoginCallback::new( + "state-12345678".to_string(), + "AABBCCDDEEFF00112233445566778899".to_string(), + sender, + ); + let invalid = vec![ + ("desktop_auth_state".to_string(), "wrong-state".to_string()), + ( + "device_code".to_string(), + "AABBCCDDEEFF00112233445566778899".to_string(), + ), + ]; + assert!(callback.try_complete(&invalid).is_err()); + assert!(matches!( + receiver.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + + let valid = vec![ + ( + "desktop_auth_state".to_string(), + "state-12345678".to_string(), + ), + ( + "device_code".to_string(), + "aabb-ccdd-eeff-0011-2233-4455-6677-8899".to_string(), + ), + ]; + callback.try_complete(&valid).unwrap(); + assert_eq!( + receiver.try_recv().unwrap().unwrap(), + "AABBCCDDEEFF00112233445566778899" + ); + assert!(callback.try_complete(&valid).is_err()); +} + +#[cfg(feature = "evaos-teams-managed")] +#[test] +fn custom_scheme_accepts_only_the_exact_login_callback_shape() { + assert!(managed_login_callback_url( + &Url::parse("evaos-teams://auth/callback?desktop_auth_state=state&device_code=code") + .unwrap() + )); + for invalid in [ + "evaos-teams://connect/callback?desktop_auth_state=state&device_code=code", + "evaos-teams://auth/other?desktop_auth_state=state&device_code=code", + "evaos-teams://auth:443/callback?desktop_auth_state=state&device_code=code", + "evaos-teams://auth/callback?desktop_auth_state=state&device_code=code#fragment", + "buzz://auth/callback?desktop_auth_state=state&device_code=code", + ] { + assert!(!managed_login_callback_url(&Url::parse(invalid).unwrap())); + } +} + +#[test] +fn entitlement_rejects_wrong_key_expiry_and_relay_injection() { + let keys = Keys::generate(); + let public_key = keys.public_key().to_hex(); + let base = EvaosTeamsEntitlement { + community_id: "10000000-0000-4000-8000-000000000003".to_string(), + relay_host: "https://relay.example.com".to_string(), + public_key: Some(public_key.clone()), + role: "member".to_string(), + access_revision: 7, + expires_at: (chrono::Utc::now() + chrono::Duration::minutes(15)).to_rfc3339(), + refresh_after_seconds: 300, + }; + assert_eq!( + validate_entitlement(&base, &public_key).unwrap(), + "wss://relay.example.com" + ); + assert!(validate_entitlement(&base, &Keys::generate().public_key().to_hex()).is_err()); + assert!(validate_entitlement( + &EvaosTeamsEntitlement { + expires_at: (chrono::Utc::now() - chrono::Duration::seconds(1)).to_rfc3339(), + ..base.clone() + }, + &public_key, + ) + .is_err()); + assert!(validate_entitlement( + &EvaosTeamsEntitlement { + relay_host: "https://relay.example.com/other-community".to_string(), + ..base + }, + &public_key, + ) + .is_err()); + assert!(validate_entitlement( + &EvaosTeamsEntitlement { + public_key: None, + ..EvaosTeamsEntitlement { + community_id: "10000000-0000-4000-8000-000000000003".to_string(), + relay_host: "https://relay.example.com".to_string(), + public_key: Some(public_key.clone()), + role: "member".to_string(), + access_revision: 7, + expires_at: (chrono::Utc::now() + chrono::Duration::minutes(15)).to_rfc3339(), + refresh_after_seconds: 300, + } + }, + &public_key, + ) + .is_err()); +} + +#[test] +fn verification_binds_the_local_key_but_refresh_requires_the_server_key() { + let public_key = Keys::generate().public_key().to_hex(); + let verified = EvaosTeamsEntitlement { + community_id: "10000000-0000-4000-8000-000000000003".to_string(), + relay_host: "https://relay.example.com".to_string(), + public_key: None, + role: "member".to_string(), + access_revision: 7, + expires_at: (chrono::Utc::now() + chrono::Duration::minutes(15)).to_rfc3339(), + refresh_after_seconds: 300, + }; + let bound = + bind_verified_entitlement(verified, "https://relay.example.com", &public_key).unwrap(); + assert_eq!(bound.public_key.as_deref(), Some(public_key.as_str())); + assert!(validate_entitlement( + &EvaosTeamsEntitlement { + public_key: None, + ..bound + }, + &public_key, + ) + .is_err()); +} + +#[test] +fn managed_keychain_service_is_distinct_from_native_buzz() { + assert_eq!(KEYRING_SERVICE, "evaos-teams-desktop"); + assert_ne!(KEYRING_SERVICE, "buzz-desktop"); + assert_ne!(KEYRING_SERVICE, "buzz-desktop-dev"); +} + +#[cfg(feature = "evaos-teams-managed")] +#[test] +fn managed_credential_states_are_exact_and_pending_is_removed_on_activation() { + let keys = Keys::generate(); + let pending = managed_credential_entries(&keys, "session", true, false, None).unwrap(); + assert_eq!(pending.len(), 3); + assert_eq!( + pending.get(LOGOUT_PENDING_KEY).map(String::as_str), + Some("1") + ); + let confirmed = managed_credential_entries(&keys, "session", false, true, None).unwrap(); + assert_eq!(confirmed.len(), 3); + assert_eq!( + confirmed.get(LOGOUT_CONFIRMED_KEY).map(String::as_str), + Some("1") + ); + assert!(!confirmed.contains_key(LOGOUT_PENDING_KEY)); + + let active = managed_credential_entries(&keys, "session", false, false, None).unwrap(); + assert_eq!(active.len(), 2); + assert!(!active.contains_key(LOGOUT_PENDING_KEY)); + assert!(!active.contains_key(LOGOUT_CONFIRMED_KEY)); + assert_eq!(active.get(SESSION_KEY).map(String::as_str), Some("session")); + + let reauth_pending = + managed_credential_entries(&keys, "new-session", true, false, Some("old-session")).unwrap(); + assert_eq!(reauth_pending.len(), 4); + assert_eq!( + reauth_pending.get(PREVIOUS_SESSION_KEY).map(String::as_str), + Some("old-session") + ); + assert!( + managed_credential_entries(&keys, "session", true, true, None).is_err(), + "logout phases must be mutually exclusive" + ); +} + +#[cfg(feature = "evaos-teams-managed")] +#[test] +fn managed_nip98_auth_requires_current_entitlement() { + let state = crate::app_state::build_app_state(); + let url = "https://relay.example.com/query"; + assert!( + crate::relay::build_nip98_auth_header(&reqwest::Method::POST, url, b"{}", &state).is_err() + ); + state + .evaos_teams_authorized + .store(true, std::sync::atomic::Ordering::Release); + state + .evaos_teams_expires_at + .store(i64::MAX, std::sync::atomic::Ordering::Release); + assert!( + crate::relay::build_nip98_auth_header(&reqwest::Method::POST, url, b"{}", &state).is_ok() + ); +} + +#[cfg(feature = "evaos-teams-managed")] +#[test] +fn managed_signing_expires_in_the_backend_and_disables_access() { + let state = crate::app_state::build_app_state(); + state + .evaos_teams_authorized + .store(true, std::sync::atomic::Ordering::Release); + state.evaos_teams_expires_at.store( + chrono::Utc::now().timestamp() - 1, + std::sync::atomic::Ordering::Release, + ); + assert!(state.signing_keys().is_err()); + assert!(!state + .evaos_teams_authorized + .load(std::sync::atomic::Ordering::Acquire)); + + state + .evaos_teams_authorized + .store(true, std::sync::atomic::Ordering::Release); + state + .evaos_teams_expires_at + .store(i64::MAX, std::sync::atomic::Ordering::Release); + assert!(state.signing_keys().is_ok()); +} + +#[cfg(feature = "evaos-teams-managed")] +#[test] +fn disabling_managed_access_cancels_a_long_lived_huddle() { + let state = crate::app_state::build_app_state(); + let cancel = tokio_util::sync::CancellationToken::new(); + { + let mut huddle = state.huddle_state.lock().unwrap(); + huddle.phase = crate::huddle::HuddlePhase::Active; + huddle.audio_ws_cancel = Some(cancel.clone()); + } + state + .evaos_teams_authorized + .store(true, std::sync::atomic::Ordering::Release); + state + .evaos_teams_expires_at + .store(i64::MAX, std::sync::atomic::Ordering::Release); + + disable_managed_access(&state); + + assert!(cancel.is_cancelled()); + assert_eq!( + state.huddle_state.lock().unwrap().phase, + crate::huddle::HuddlePhase::Idle + ); + assert_eq!( + state + .evaos_teams_expires_at + .load(std::sync::atomic::Ordering::Acquire), + 0 + ); +} + +#[test] +fn public_status_never_serializes_credentials() { + let status = EvaosTeamsAuthStatus::reauth("Sign in again".to_string()); + let json = serde_json::to_string(&status).unwrap(); + assert!(!json.contains("desktop_session")); + assert!(!json.contains("nsec")); + assert!(!json.contains("device_code")); + assert!(!json.contains("email")); +} + +#[test] +fn device_code_normalization_matches_dashboard_contract() { + assert_eq!(normalize_device_code("aabb-ccdd eeff"), "AABBCCDDEEFF"); +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index ceccedd8b6d..75dd3f53770 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -9,6 +9,7 @@ use std::sync::{ }; use nostr::JsonUtil; +use tauri::Manager; use uuid::Uuid; use crate::app_state::AppState; @@ -16,10 +17,33 @@ use crate::events; use super::models; use super::relay_api::{self, fetch_channel_members, parse_channel_uuid}; -use super::state::{HuddlePhase, VoiceInputMode}; +use super::state::{HuddlePhase, HuddleState, VoiceInputMode}; use super::stt; use super::tts; +#[derive(Clone, Copy)] +struct AudioConnectGuard { + huddle_generation: u64, + access_generation: u64, +} + +fn audio_connect_is_current( + huddle: &HuddleState, + ephemeral_channel_id: &str, + guard: AudioConnectGuard, + access_generation: u64, + authorized: bool, + expires_at: i64, + now: i64, +) -> bool { + !matches!(huddle.phase, HuddlePhase::Idle | HuddlePhase::Leaving) + && huddle.ephemeral_channel_id.as_deref() == Some(ephemeral_channel_id) + && huddle.session_generation.load(Ordering::Acquire) == guard.huddle_generation + && access_generation == guard.access_generation + && authorized + && expires_at > now +} + pub(crate) async fn post_connect_setup( state: &AppState, ephemeral_channel_id: &str, @@ -50,15 +74,53 @@ pub(crate) async fn post_connect_setup( // Connect audio relay WebSocket (Opus encode/decode pipeline). // This is the core audio path — failure is fatal for the huddle. - let parent_id = { + let (parent_id, guard) = { + let _transition = state + .evaos_teams_access_transition + .lock() + .map_err(|error| error.to_string())?; let hs = state.huddle()?; - hs.parent_channel_id.clone() + let now = chrono::Utc::now().timestamp(); + if !state.evaos_teams_authorized.load(Ordering::Acquire) + || state.evaos_teams_expires_at.load(Ordering::Acquire) <= now + { + return Err("managed access expired before audio connection".to_string()); + } + ( + hs.parent_channel_id.clone(), + AudioConnectGuard { + huddle_generation: hs.session_generation.load(Ordering::Acquire), + access_generation: state.evaos_teams_access_generation.load(Ordering::Acquire), + }, + ) }; let (cancel, pcm_tx) = relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await?; { + // Serialize the terminal re-check and handle install with entitlement + // refresh/expiry. A network handshake that finishes after expiry, + // refresh, leave, or reset must not resurrect audio authority. + let _transition = state + .evaos_teams_access_transition + .lock() + .map_err(|error| error.to_string())?; let mut hs = state.huddle()?; - hs.audio_ws_cancel = Some(cancel); + let still_current = audio_connect_is_current( + &hs, + ephemeral_channel_id, + guard, + state.evaos_teams_access_generation.load(Ordering::Acquire), + state.evaos_teams_authorized.load(Ordering::Acquire), + state.evaos_teams_expires_at.load(Ordering::Acquire), + chrono::Utc::now().timestamp(), + ); + if !still_current { + cancel.cancel(); + return Err("huddle or managed access ended while audio was connecting".to_string()); + } + if let Some(old_cancel) = hs.audio_ws_cancel.replace(cancel) { + old_cancel.cancel(); + } hs.audio_relay_pcm_tx = Some(pcm_tx); } @@ -270,9 +332,14 @@ pub(crate) fn spawn_transcription_task( let spawned_gen = session_generation.load(Ordering::Acquire); let http_client = state.http_client.clone(); - let keys = match state.keys.lock() { - Ok(k) => k.clone(), - Err(_) => return, + let app_handle = match state + .app_handle + .lock() + .ok() + .and_then(|handle| handle.clone()) + { + Some(handle) => handle, + None => return, }; let relay_base_url = crate::relay::relay_api_base_url_with_override(state); @@ -310,6 +377,13 @@ pub(crate) fn spawn_transcription_task( // the kind event and build NIP-98 auth after the wait so both // timestamps are fresh — single clean order: wait → sign → auth → send. crate::relay_admission::wait_for_rate_limit().await; + let keys = match app_handle.state::().signing_keys() { + Ok(keys) => keys, + Err(error) => { + eprintln!("buzz-desktop: STT signing stopped: {error}"); + break; + } + }; let event = match builder.sign_with_keys(&keys) { Ok(e) => e, Err(e) => { @@ -357,3 +431,94 @@ pub(crate) fn spawn_transcription_task( } }); } + +#[cfg(test)] +mod tests { + use super::*; + + fn active_huddle() -> HuddleState { + HuddleState { + phase: HuddlePhase::Connecting, + ephemeral_channel_id: Some("ephemeral-channel".to_string()), + ..HuddleState::default() + } + } + + #[test] + fn audio_connect_guard_accepts_only_the_same_live_huddle_and_access_generation() { + let huddle = active_huddle(); + let guard = AudioConnectGuard { + huddle_generation: 0, + access_generation: 7, + }; + assert!(audio_connect_is_current( + &huddle, + "ephemeral-channel", + guard, + 7, + true, + 101, + 100, + )); + + assert!(!audio_connect_is_current( + &huddle, + "ephemeral-channel", + guard, + 8, + true, + 101, + 100, + )); + assert!(!audio_connect_is_current( + &huddle, + "ephemeral-channel", + guard, + 7, + false, + 101, + 100, + )); + assert!(!audio_connect_is_current( + &huddle, + "ephemeral-channel", + guard, + 7, + true, + 100, + 100, + )); + } + + #[test] + fn audio_connect_guard_rejects_reset_or_replaced_huddle() { + let mut huddle = active_huddle(); + let guard = AudioConnectGuard { + huddle_generation: 0, + access_generation: 7, + }; + + huddle.session_generation.fetch_add(1, Ordering::Release); + assert!(!audio_connect_is_current( + &huddle, + "ephemeral-channel", + guard, + 7, + true, + 101, + 100, + )); + + huddle.session_generation.store(0, Ordering::Release); + huddle.reset_preserving_generation(); + assert!(!audio_connect_is_current( + &huddle, + "ephemeral-channel", + guard, + 7, + true, + 101, + 100, + )); + } +} diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index eb3fea92d5f..d44eb7fe400 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -55,7 +55,7 @@ pub(crate) async fn connect_audio_relay( let relay_url = crate::relay::relay_ws_url_with_override(state); let ws_url = format!("{relay_url}/huddle/{channel_id}/audio"); - let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + let keys = state.signing_keys()?; // TTS interrupt flags — recv task cancels TTS when remote humans speak. let (tts_cancel, tts_active) = { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9017356ab55..40423fcead9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod archive; mod builderlab; mod commands; mod deep_link; +mod evaos_teams; mod event_sync; mod events; mod huddle; @@ -36,6 +37,7 @@ use deep_link::{ acknowledge_pending_community_deep_link, handle_deep_link_url, take_pending_community_deep_link, PendingCommunityDeepLinks, }; +use evaos_teams::*; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, }; @@ -171,7 +173,10 @@ pub fn run() { } // Forward any deep link URLs from the duplicate launch. for arg in &argv { - if arg.starts_with("buzz://") { + if arg.starts_with("buzz://") + || (cfg!(feature = "evaos-teams-managed") + && arg.starts_with("evaos-teams://")) + { handle_deep_link_url(app, arg); } } @@ -355,10 +360,37 @@ pub fn run() { .manage(PendingCommunityDeepLinks::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) + .manage(EvaosTeamsState::default()) .manage(commands::pairing::PairingHandle::new()) .setup(move |app| { let app_handle = app.handle().clone(); + // Register runtime/cold-start deep links before the managed setup + // boundary. Managed mode returns early to avoid all native + // migration and owner-key side effects, but still needs its + // device-login custom-scheme fallback. + #[cfg(desktop)] + { + use tauri_plugin_deep_link::DeepLinkExt; + let dl_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + handle_deep_link_url(&dl_handle, url.as_str()); + } + }); + } + + // Managed Teams owns a separate Keychain identity and performs no + // native Buzz migration, plaintext fallback, or owner-keyed boot + // side effect before the broker has refreshed an entitlement. + if cfg!(feature = "evaos-teams-managed") { + let state = app_handle.state::(); + if let Ok(mut guard) = state.app_handle.lock() { + *guard = Some(app_handle.clone()); + } + return Ok(()); + } + // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe // completes atomically on crash recovery. @@ -546,20 +578,6 @@ pub fn run() { mgr.start_tts_download(state.http_client.clone()); } - // Handle deep link URLs received while the app is running (macOS) - // and on cold start. The single-instance plugin handles forwarding - // from duplicate launches on Windows/Linux. - #[cfg(desktop)] - { - use tauri_plugin_deep_link::DeepLinkExt; - let dl_handle = app.handle().clone(); - app.deep_link().on_open_url(move |event| { - for url in event.urls() { - handle_deep_link_url(&dl_handle, url.as_str()); - } - }); - } - // Defer launch-time agent restoration until `apply_workspace` has // installed the active workspace relay and identity. Starting here // would race React initialization and send agents whose saved record @@ -651,6 +669,9 @@ pub fn run() { cancel_builderlab_login, get_builderlab_auth, clear_builderlab_auth, + get_evaos_teams_auth_status, + start_evaos_teams_login, + logout_evaos_teams, get_builderlab_nostr_identity, bind_builderlab_nostr_identity, delete_builderlab_nostr_identity, diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index d62cc5b4bbd..750507ff43a 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -748,6 +748,13 @@ mod flush_barrier { .unwrap(); let state = build_app_state(); *state.keys.lock().unwrap() = keys; + state + .evaos_teams_authorized + .store(true, std::sync::atomic::Ordering::Release); + #[cfg(feature = "evaos-teams-managed")] + state + .evaos_teams_expires_at + .store(i64::MAX, std::sync::atomic::Ordering::Release); let fresh = resign_with_fresh_timestamp(&stale, &state).unwrap(); @@ -806,6 +813,13 @@ mod flush_barrier { } let state = build_app_state(); + state + .evaos_teams_authorized + .store(true, std::sync::atomic::Ordering::Release); + #[cfg(feature = "evaos-teams-managed")] + state + .evaos_teams_expires_at + .store(i64::MAX, std::sync::atomic::Ordering::Release); *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index e6463f50922..01daec0f08b 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -104,7 +104,7 @@ pub fn build_nip98_auth_header( body: &[u8], state: &AppState, ) -> Result { - let keys = state.keys.lock().map_err(|error| error.to_string())?; + let keys = state.signing_keys()?; build_nip98_auth_header_for_keys(&keys, method, url, body) } diff --git a/desktop/src-tauri/src/relay_admission.rs b/desktop/src-tauri/src/relay_admission.rs index 15222f85908..a54d06196ad 100644 --- a/desktop/src-tauri/src/relay_admission.rs +++ b/desktop/src-tauri/src/relay_admission.rs @@ -291,6 +291,14 @@ mod tests { activate_rate_limit(Some(1)); let state = crate::app_state::build_app_state(); + #[cfg(feature = "evaos-teams-managed")] + state + .evaos_teams_authorized + .store(true, std::sync::atomic::Ordering::Release); + #[cfg(feature = "evaos-teams-managed")] + state + .evaos_teams_expires_at + .store(i64::MAX, std::sync::atomic::Ordering::Release); *state.relay_url_override.lock().unwrap() = Some(format!("http://{addr}")); let filters = [serde_json::json!({ "kinds": [1], "limit": 1 })]; @@ -467,6 +475,14 @@ mod tests { }); let state = crate::app_state::build_app_state(); + #[cfg(feature = "evaos-teams-managed")] + state + .evaos_teams_authorized + .store(true, std::sync::atomic::Ordering::Release); + #[cfg(feature = "evaos-teams-managed")] + state + .evaos_teams_expires_at + .store(i64::MAX, std::sync::atomic::Ordering::Release); *state.relay_url_override.lock().unwrap() = Some(format!("http://{addr}")); let filters = [serde_json::json!({ "kinds": [1], "limit": 1 })]; diff --git a/desktop/src-tauri/src/secret_store.rs b/desktop/src-tauri/src/secret_store.rs index 43854761b50..76e9146e9bd 100644 --- a/desktop/src-tauri/src/secret_store.rs +++ b/desktop/src-tauri/src/secret_store.rs @@ -212,7 +212,6 @@ impl Drop for BlobLockGuard { } // ── End interprocess advisory lock ──────────────────────────────────────── - /// An OS keyring, addressed by service name. All secrets are stored in a /// single JSON blob entry (one OS prompt per process lifetime). pub struct SecretStore { @@ -220,7 +219,8 @@ pub struct SecretStore { /// In-memory cache of the deserialized blob. `None` means "not yet loaded". cache: Mutex>>, } - +#[cfg(feature = "evaos-teams-managed")] +mod managed; impl SecretStore { /// Keyring-backed store under `service`. The active platform backend /// (apple-native / windows-native / sync-secret-service) is chosen at diff --git a/desktop/src-tauri/src/secret_store/managed.rs b/desktop/src-tauri/src/secret_store/managed.rs new file mode 100644 index 00000000000..182f17d7360 --- /dev/null +++ b/desktop/src-tauri/src/secret_store/managed.rs @@ -0,0 +1,117 @@ +use std::collections::HashMap; + +use super::SecretStore; + +fn replace_entries(current: &mut HashMap, replacement: &HashMap) { + current.clear(); + current.extend(replacement.clone()); +} + +fn rewrite_current_with(read: R, write: W) -> Result, String> +where + R: FnOnce() -> Result>, String>, + W: FnOnce(&[u8]) -> Result<(), String>, +{ + let current = match read()? { + None => HashMap::new(), + Some(bytes) => { + let json = String::from_utf8(bytes).map_err(|error| format!("blob utf8: {error}"))?; + serde_json::from_str::>(&json) + .map_err(|error| format!("blob json: {error}"))? + } + }; + let json = + serde_json::to_string(¤t).map_err(|error| format!("blob serialize: {error}"))?; + write(json.as_bytes())?; + Ok(current) +} + +impl SecretStore { + /// Atomically replace the managed keychain blob with exactly `entries`. + pub fn replace_all(&self, entries: &HashMap) -> Result<(), String> { + self.mutate_blob(|map| replace_entries(map, entries)) + } + + /// Prove that the managed keychain entry is writable without changing its + /// semantic contents. Unlike `mutate_blob`, this deliberately performs the + /// write even when the freshly read map is unchanged so macOS validates the + /// caller's write ACL before browser authentication creates a remote + /// session. + pub fn force_rewrite_current(&self) -> Result, String> { + let _lock = super::acquire_blob_lock(&self.service)?; + match rewrite_current_with(|| self.read_blob_raw(), |bytes| self.write_blob_raw(bytes)) { + Ok(current) => { + let mut cache = self.cache.lock().unwrap_or_else(|error| error.into_inner()); + *cache = Some(current.clone()); + Ok(current) + } + Err(error) => { + let mut cache = self.cache.lock().unwrap_or_else(|error| error.into_inner()); + *cache = None; + Err(error) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replacement_removes_prior_account_secrets() { + let mut current = HashMap::from([ + ("identity".to_string(), "old-identity".to_string()), + ( + "electric_desktop_session".to_string(), + "old-session".to_string(), + ), + ("unrelated".to_string(), "must-not-survive".to_string()), + ]); + let replacement = HashMap::from([ + ("identity".to_string(), "new-identity".to_string()), + ( + "electric_desktop_session".to_string(), + "new-session".to_string(), + ), + ]); + replace_entries(&mut current, &replacement); + assert_eq!(current, replacement); + } + + #[test] + fn writable_probe_forces_an_identical_or_empty_blob_write() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let writes = AtomicUsize::new(0); + let original = HashMap::from([("identity".to_string(), "nsec-test".to_string())]); + let encoded = serde_json::to_vec(&original).unwrap(); + let rewritten = rewrite_current_with( + || Ok(Some(encoded)), + |bytes| { + writes.fetch_add(1, Ordering::Relaxed); + assert_eq!( + serde_json::from_slice::>(bytes).unwrap(), + original + ); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(rewritten, original); + assert_eq!(writes.load(Ordering::Relaxed), 1); + + let empty_writes = AtomicUsize::new(0); + let empty = rewrite_current_with( + || Ok(None), + |bytes| { + empty_writes.fetch_add(1, Ordering::Relaxed); + assert_eq!(bytes, b"{}"); + Ok(()) + }, + ) + .unwrap(); + assert!(empty.is_empty()); + assert_eq!(empty_writes.load(Ordering::Relaxed), 1); + } +} diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index e89bf61fbef..91f4da88e8b 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -15,6 +15,7 @@ import { router } from "@/app/router"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; +import { EvaosTeamsAuthGate } from "@/features/evaosTeams/EvaosTeamsAuthGate"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding"; import { @@ -626,9 +627,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { ); } -export function App() { - useReloadShortcut(); - useInitialRenderReady(); +function NativeBuzzApp() { const [sharedIdentity, setSharedIdentity] = useState(null); const [queryClient] = useState(createBuzzQueryClient); @@ -649,3 +648,13 @@ export function App() { ); } + +export function App() { + useReloadShortcut(); + useInitialRenderReady(); + return ( + + + + ); +} diff --git a/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx b/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx new file mode 100644 index 00000000000..99ca689bc4f --- /dev/null +++ b/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx @@ -0,0 +1,174 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; + +import { + evaosTeamsRefreshDelay, + evaosTeamsStatusCopy, + getEvaosTeamsAuthStatus, + logoutEvaosTeams, + startEvaosTeamsLogin, + type EvaosTeamsAuthStatus, +} from "@/features/evaosTeams/api"; +import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; +import { Button } from "@/shared/ui/button"; +import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; +import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; + +export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { + const tauri = isTauri(); + const [status, setStatus] = useState(null); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const next = await getEvaosTeamsAuthStatus(); + setStatus(next); + setError(null); + return next; + } catch (refreshError) { + setError( + refreshError instanceof Error + ? refreshError.message + : String(refreshError), + ); + return null; + } + }, []); + + useEffect(() => { + if (!tauri) return; + void refresh(); + }, [refresh, tauri]); + + useEffect(() => { + if (!status?.managed || status.phase !== "active") return; + const timer = window.setTimeout(() => { + void refresh(); + }, evaosTeamsRefreshDelay(status)); + return () => window.clearTimeout(timer); + }, [refresh, status]); + + async function run(action: () => Promise) { + setWorking(true); + setError(null); + try { + setStatus(await action()); + } catch (actionError) { + setError( + actionError instanceof Error + ? actionError.message + : String(actionError), + ); + await refresh(); + } finally { + setWorking(false); + } + } + + if (!tauri || (status && !status.managed)) return children; + const copy = status + ? evaosTeamsStatusCopy(status) + : { + title: "Checking managed access", + body: "evaOS Teams is checking Keychain and your ElectricSheep access.", + }; + const active = status?.phase === "active" && status.entitlement; + const maySignIn = + status?.phase === "signed_out" || status?.phase === "reauth_required"; + + return ( +
+ + +
+ +

+ evaOS Teams +

+

{copy.title}

+

+ {copy.body} +

+ + {active ? ( +
+
+
+ Relay selected by ElectricSheep +
+
+ {active.relayHost} +
+
+
+
Role
+
{active.role}
+
+
+
Access revision
+
+ {active.accessRevision} +
+
+
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + +
+ {maySignIn ? ( + + ) : null} + {active ? ( + <> + + + + ) : null} + {status?.phase === "keychain_locked" || + status?.phase === "logout_pending" ? ( + + ) : null} +
+ +

+ Provider setup and agent permissions remain inside Hermes. This screen + verifies only your ElectricSheep Teams membership and managed relay + identity. +

+
+
+ ); +} diff --git a/desktop/src/features/evaosTeams/api.test.mjs b/desktop/src/features/evaosTeams/api.test.mjs new file mode 100644 index 00000000000..8f513aa44a3 --- /dev/null +++ b/desktop/src/features/evaosTeams/api.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { evaosTeamsRefreshDelay, evaosTeamsStatusCopy } from "./api.ts"; + +test("managed auth copy never claims authentication while refresh is unknown", () => { + const status = { + managed: true, + phase: "reauth_required", + authenticated: false, + keychainAvailable: true, + message: "Session refresh failed", + }; + const copy = evaosTeamsStatusCopy(status); + assert.equal(copy.title, "Sign in again"); + assert.doesNotMatch( + JSON.stringify(copy), + /authenticated|desktop_session|nsec/, + ); +}); + +test("managed entitlement controls a bounded refresh delay", () => { + const status = { + managed: true, + phase: "active", + authenticated: true, + keychainAvailable: true, + entitlement: { + communityId: "10000000-0000-4000-8000-000000000001", + relayHost: "https://relay.example.com", + role: "member", + accessRevision: 4, + expiresAt: "2030-01-01T00:00:00Z", + refreshAfterSeconds: 300, + }, + }; + assert.equal(evaosTeamsRefreshDelay(status), 300_000); + assert.equal( + evaosTeamsRefreshDelay({ + ...status, + entitlement: { ...status.entitlement, refreshAfterSeconds: 1 }, + }), + 30_000, + ); +}); diff --git a/desktop/src/features/evaosTeams/api.ts b/desktop/src/features/evaosTeams/api.ts new file mode 100644 index 00000000000..6e40922e834 --- /dev/null +++ b/desktop/src/features/evaosTeams/api.ts @@ -0,0 +1,79 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type EvaosTeamsEntitlement = { + communityId: string; + relayHost: string; + publicKey?: string; + role: string; + accessRevision: number; + expiresAt: string; + refreshAfterSeconds: number; +}; + +export type EvaosTeamsAuthStatus = { + managed: boolean; + phase: + | "native" + | "signed_out" + | "active" + | "keychain_locked" + | "reauth_required" + | "logout_pending"; + authenticated: boolean; + keychainAvailable: boolean; + message?: string; + entitlement?: EvaosTeamsEntitlement; +}; + +export function getEvaosTeamsAuthStatus() { + return invoke("get_evaos_teams_auth_status"); +} + +export function startEvaosTeamsLogin() { + return invoke("start_evaos_teams_login"); +} + +export function logoutEvaosTeams() { + return invoke("logout_evaos_teams"); +} + +export function evaosTeamsRefreshDelay(status: EvaosTeamsAuthStatus) { + const seconds = status.entitlement?.refreshAfterSeconds ?? 300; + return Math.min(Math.max(seconds, 30), 3600) * 1000; +} + +export function evaosTeamsStatusCopy(status: EvaosTeamsAuthStatus) { + switch (status.phase) { + case "keychain_locked": + return { + title: "Unlock macOS Keychain", + body: + status.message ?? + "evaOS Teams cannot read its managed identity. Unlock Keychain and try again.", + }; + case "logout_pending": + return { + title: "Finishing sign-out", + body: + status.message ?? + "evaOS Teams is disconnected locally and will retry remote revocation.", + }; + case "reauth_required": + return { + title: "Sign in again", + body: + status.message ?? + "Your managed access could not be refreshed. evaOS Teams remains disconnected.", + }; + case "active": + return { + title: "Access ready", + body: "ElectricSheep verified this device and selected your Teams community.", + }; + default: + return { + title: "Sign in to evaOS Teams", + body: "Use your ElectricSheep account to connect this device.", + }; + } +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 33e9e02ba17..77faa41b653 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9125,6 +9125,13 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload }); switch (command) { + case "get_evaos_teams_auth_status": + return { + managed: false, + phase: "native", + authenticated: false, + keychainAvailable: true, + }; case "get_builderlab_auth": return activeConfig?.mock?.builderlabAuth ?? null; case "start_builderlab_login": {