diff --git a/desktop/src-tauri/src/evaos_teams.rs b/desktop/src-tauri/src/evaos_teams.rs index 58939a82c7f..b53a59cac9e 100644 --- a/desktop/src-tauri/src/evaos_teams.rs +++ b/desktop/src-tauri/src/evaos_teams.rs @@ -43,6 +43,7 @@ use device_code::{dashboard_login_url, normalize_device_code, DeviceCodeProof}; mod company_agent_policy; mod company_directory; mod device_code; +mod identity_rotation; mod login; pub(crate) use company_agent_policy::{ @@ -54,6 +55,14 @@ use company_directory::{ }; #[cfg(test)] use company_directory::{RawHiveCompanyAgent, RawHiveCompanyMember}; +use identity_rotation::pending_identity_rotation_key; +pub(crate) use identity_rotation::replace_lost_evaos_teams_identity; +#[cfg(test)] +use identity_rotation::{ + signed_identity_rotation_challenge, staged_identity_rotation_entries, + validate_rotated_entitlement, IdentityRotationChallenge, IdentityRotationChallengeResponse, + IDENTITY_ROTATION_SCHEMA, +}; #[cfg(test)] use login::callback_device_code; use login::{login_callback, register_pending_login, submit_pending_login_code, LoginCallback}; @@ -630,6 +639,13 @@ fn select_login_keys( return Ok(LoginKeySelection::Ready(keys)); } } + if let Some(value) = stored.get(&pending_identity_rotation_key(&binding.membership_id)?) + { + let staged = parse_stored_identity(value)?; + if staged.public_key().to_hex() == public_key { + return Ok(LoginKeySelection::Ready(staged)); + } + } if let Some(value) = stored.get(IDENTITY_KEY) { let legacy = parse_stored_identity(value)?; if legacy.public_key().to_hex() == public_key { @@ -646,31 +662,6 @@ fn select_login_keys( } } -fn managed_credential_entries( - mut stored: HashMap, - membership_id: &str, - keys: &Keys, - session: &str, -) -> Result, String> { - let public_key = keys.public_key(); - let migrated_legacy = stored - .get(IDENTITY_KEY) - .map(|value| parse_stored_identity(value)) - .transpose()? - .is_some_and(|legacy| legacy.public_key() == public_key); - if migrated_legacy { - stored.remove(IDENTITY_KEY); - } - stored.remove(LOGOUT_PENDING_KEY); - stored.insert( - membership_identity_key(membership_id)?, - encode_managed_identity(keys)?, - ); - stored.insert(ACTIVE_MEMBERSHIP_KEY.to_string(), membership_id.to_string()); - stored.insert(SESSION_KEY.to_string(), session.to_string()); - Ok(stored) -} - fn runtime_from_entries(stored: Option>) -> Result { let Some(stored) = stored else { return Ok(ManagedRuntime { @@ -945,16 +936,45 @@ fn persist_managed_credentials( membership_id: String, entitlement: EvaosTeamsEntitlement, ) -> Result { - let replacement = managed_credential_entries( - managed_store().load_all_readonly()?.unwrap_or_default(), - &membership_id, - &keys, - &session, - )?; - managed_store() - .replace_all(&replacement) + let store = managed_store(); + let stored = store.load_all_readonly()?.unwrap_or_default(); + let encoded_identity = encode_managed_identity(&keys)?; + let legacy_to_remove = stored + .get(IDENTITY_KEY) + .map(|value| parse_stored_identity(value).map(|legacy| (value.clone(), legacy))) + .transpose()? + .filter(|(_, legacy)| legacy.public_key() == keys.public_key()) + .map(|(value, _)| value); + let scoped_identity_key = membership_identity_key(&membership_id)?; + let pending_rotation_key = pending_identity_rotation_key(&membership_id)?; + let removals = vec![LOGOUT_PENDING_KEY.to_string()]; + let upserts = HashMap::from([ + (scoped_identity_key.clone(), encoded_identity.clone()), + (ACTIVE_MEMBERSHIP_KEY.to_string(), membership_id.clone()), + (SESSION_KEY.to_string(), session.clone()), + ]); + let mut conditional_removals = vec![(pending_rotation_key.as_str(), encoded_identity.as_str())]; + if let Some(value) = legacy_to_remove.as_deref() { + conditional_removals.push((IDENTITY_KEY, value)); + } + store + .update_entries(&removals, &upserts, &conditional_removals) .map_err(|_| "Could not save managed access in macOS Keychain".to_string())?; - if managed_store().load_all_readonly()? != Some(replacement) { + let persisted = store.load_all_readonly()?.unwrap_or_default(); + let required_entries_match = upserts + .iter() + .all(|(key, value)| persisted.get(key) == Some(value)); + let legacy_removed = legacy_to_remove + .as_ref() + .is_none_or(|value| persisted.get(IDENTITY_KEY) != Some(value)); + let pending_rotation_preserved_or_promoted = persisted + .get(&pending_rotation_key) + .is_none_or(|value| value != &encoded_identity); + if !required_entries_match + || !pending_rotation_preserved_or_promoted + || persisted.contains_key(LOGOUT_PENDING_KEY) + || !legacy_removed + { return Err("Managed Keychain read-back verification failed".to_string()); } install_entitlement(app_state, &keys, &entitlement)?; @@ -1149,6 +1169,7 @@ async fn complete_pending_identity_recovery( ) -> Result<(), String> { let state = app.state::(); let app_state = app.state::(); + let _operation = state.operation.lock().await; let pending = state .pending_identity_recovery .lock() @@ -1492,6 +1513,7 @@ pub(crate) async fn start_evaos_teams_identity_recovery( #[cfg(feature = "evaos-teams-managed")] { + let _operation = state.operation.lock().await; { let pending = state .pending_identity_recovery @@ -1716,11 +1738,17 @@ pub(crate) async fn start_evaos_teams_login( #[cfg(feature = "evaos-teams-managed")] { let _operation = state.operation.lock().await; + let _ = abort_identity_recovery_pairing( + &state, + buzz_core_pkg::pairing::types::AbortReason::UserDenied, + ) + .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)?; + revoke_pending_identity_recovery_session(&state, &app_state).await?; if let Ok((session, keys, logout_pending)) = current_credentials(&state).await { if logout_pending { let outcome = begin_managed_logout(&state, &app_state).await?; diff --git a/desktop/src-tauri/src/evaos_teams/identity_rotation.rs b/desktop/src-tauri/src/evaos_teams/identity_rotation.rs new file mode 100644 index 00000000000..b6a951431b8 --- /dev/null +++ b/desktop/src-tauri/src/evaos_teams/identity_rotation.rs @@ -0,0 +1,316 @@ +use super::*; + +const IDENTITY_ROTATION_KEY_PREFIX: &str = "pending_identity_rotation:"; +pub(super) const IDENTITY_ROTATION_SCHEMA: &str = "evaos.buzz_identity_rotation.v1"; + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +pub(super) struct IdentityRotationChallenge { + pub(super) schema_version: String, + pub(super) rotation_id: String, + pub(super) previous_identity_id: String, + pub(super) membership_id: String, + pub(super) community_id: String, + pub(super) desktop_session_id: String, + pub(super) replacement_public_key: String, + pub(super) nonce: String, + pub(super) expires_at: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct IdentityRotationChallengeResponse { + pub(super) status: String, + pub(super) challenge: IdentityRotationChallenge, + pub(super) event_template: EventTemplate, + pub(super) relay_host: String, +} + +fn validate_identity_rotation_challenge( + response: &IdentityRotationChallengeResponse, + expected_membership_id: &str, + expected_public_key: &str, +) -> Result<(), String> { + if response.status != "identity_rotation_challenge_issued" + || response.challenge.schema_version != IDENTITY_ROTATION_SCHEMA + || response.challenge.membership_id != expected_membership_id + || response.challenge.replacement_public_key != expected_public_key + || response.event_template.kind != KEY_BINDING_KIND + { + return Err( + "managed identity replacement challenge does not match this device".to_string(), + ); + } + for id in [ + &response.challenge.rotation_id, + &response.challenge.previous_identity_id, + &response.challenge.membership_id, + &response.challenge.community_id, + &response.challenge.desktop_session_id, + ] { + uuid::Uuid::parse_str(id).map_err(|_| { + "managed identity replacement 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 identity replacement challenge nonce is invalid".to_string()); + } + let expected_content = serde_json::to_string(&response.challenge) + .map_err(|error| format!("could not serialize managed identity replacement: {error}"))?; + let expected_tags = vec![ + vec!["t".to_string(), "evaos-teams-identity-rotation".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 identity replacement template is not canonical".to_string()); + } + let expires_at = chrono::DateTime::parse_from_rfc3339(&response.challenge.expires_at) + .map_err(|_| "managed identity replacement expiry is invalid".to_string())?; + let now = chrono::Utc::now(); + if expires_at <= now || expires_at > now + chrono::Duration::minutes(5) { + return Err("managed identity replacement challenge has expired".to_string()); + } + let created_at = i64::try_from(response.event_template.created_at) + .map_err(|_| "managed identity replacement timestamp is invalid".to_string())?; + let timestamp_skew = created_at + .checked_sub(now.timestamp()) + .and_then(|skew| skew.checked_abs()) + .ok_or_else(|| "managed identity replacement timestamp is invalid".to_string())?; + if timestamp_skew > 5 * 60 { + return Err("managed identity replacement timestamp is invalid".to_string()); + } + relay_websocket_url(&response.relay_host)?; + Ok(()) +} + +pub(super) fn signed_identity_rotation_challenge( + response: &IdentityRotationChallengeResponse, + keys: &Keys, + expected_membership_id: &str, +) -> Result { + validate_identity_rotation_challenge( + response, + expected_membership_id, + &keys.public_key().to_hex(), + )?; + let tags = response + .event_template + .tags + .iter() + .cloned() + .map(|tag| { + Tag::parse(tag) + .map_err(|error| format!("invalid identity replacement 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 identity replacement: {error}"))?; + serde_json::to_value(event) + .map_err(|error| format!("could not encode managed identity replacement: {error}")) +} + +pub(super) fn pending_identity_rotation_key(membership_id: &str) -> Result { + uuid::Uuid::parse_str(membership_id) + .map_err(|_| "managed membership identity is invalid".to_string())?; + Ok(format!("{IDENTITY_ROTATION_KEY_PREFIX}{membership_id}")) +} + +#[cfg(test)] +pub(super) fn staged_identity_rotation_entries( + mut stored: HashMap, + membership_id: &str, +) -> Result<(HashMap, Keys, String, String), String> { + let staging_key = pending_identity_rotation_key(membership_id)?; + let keys = stored + .get(&staging_key) + .map(|value| parse_stored_identity(value)) + .transpose()? + .unwrap_or_else(Keys::generate); + let encoded = encode_managed_identity(&keys)?; + stored.insert(staging_key.clone(), encoded.clone()); + Ok((stored, keys, staging_key, encoded)) +} + +#[cfg(feature = "evaos-teams-managed")] +fn stage_identity_rotation_key(membership_id: &str) -> Result { + let staging_key = pending_identity_rotation_key(membership_id)?; + let generated = Keys::generate(); + let candidate = encode_managed_identity(&generated)?; + let encoded = managed_store() + .store_if_absent(&staging_key, &candidate) + .map_err(|_| "Could not stage a replacement identity in macOS Keychain".to_string())?; + let keys = parse_stored_identity(&encoded)?; + if !managed_store() + .verify_stored_raw(&staging_key, &encoded) + .map_err(|_| "Hive could not verify the staged replacement identity".to_string())? + { + return Err("Hive could not verify the staged replacement identity".to_string()); + } + Ok(keys) +} + +pub(super) fn validate_rotated_entitlement( + entitlement: EvaosTeamsEntitlement, + expected_community_id: &str, + expected_relay: &str, + expected_public_key: &str, +) -> Result { + if entitlement.community_id != expected_community_id { + return Err( + "Managed identity replacement changed the server-selected community".to_string(), + ); + } + if entitlement.relay_host != expected_relay { + return Err("Managed identity replacement changed the server-selected relay".to_string()); + } + validate_entitlement(&entitlement, expected_public_key)?; + Ok(entitlement) +} + +#[cfg(feature = "evaos-teams-managed")] +async fn recover_completed_identity_rotation( + client: &reqwest::Client, + token: &str, + expected_membership_id: &str, + keys: &Keys, + expected_community_id: &str, + expected_relay: &str, +) -> Result { + let public_key = keys.public_key().to_hex(); + let binding = get_identity_binding(client, token).await?; + if binding.membership_id != expected_membership_id + || binding.public_key.as_deref() != Some(public_key.as_str()) + { + return Err("managed identity replacement was not completed".to_string()); + } + let entitlement = get_remote_entitlement(client, token) + .await + .map_err(|_| "managed identity replacement entitlement was not available".to_string())?; + validate_rotated_entitlement( + entitlement, + expected_community_id, + expected_relay, + &public_key, + ) +} + +#[cfg(feature = "evaos-teams-managed")] +async fn rotate_lost_identity( + client: &reqwest::Client, + token: &str, + keys: &Keys, + expected_membership_id: &str, +) -> Result { + let public_key = keys.public_key().to_hex(); + let challenge: IdentityRotationChallengeResponse = post_json( + client, + "evaos-teams-access", + Some(token), + serde_json::json!({ + "action": "issue_identity_rotation_challenge", + "replacement_public_key": public_key, + "device_metadata": { + "label": "Hive", + "app_version": env!("CARGO_PKG_VERSION"), + "platform": std::env::consts::OS, + }, + }), + ) + .await + .map_err(|error| format!("Identity replacement was not available: {error}"))?; + let signed_event = + signed_identity_rotation_challenge(&challenge, keys, expected_membership_id)?; + + let verified: Result = post_json( + client, + "evaos-teams-access", + Some(token), + serde_json::json!({ + "action": "verify_identity_rotation_challenge", + "signed_event": signed_event, + }), + ) + .await; + match verified { + Ok(response) if response.status == "active" => validate_rotated_entitlement( + response.entitlement, + &challenge.challenge.community_id, + &challenge.relay_host, + &public_key, + ), + Ok(_) | Err(_) => recover_completed_identity_rotation( + client, + token, + expected_membership_id, + keys, + &challenge.challenge.community_id, + &challenge.relay_host, + ) + .await + .map_err(|_| { + "Hive could not confirm identity replacement. The replacement key remains safely staged in Keychain; sign in again and retry." + .to_string() + }), + } +} + +/// Explicitly replace a lost managed Hive identity after Electric OAuth has +/// selected the account and exact-key recovery is unavailable. The replacement +/// private key is staged and read back from Keychain before the server is +/// allowed to rotate any public identity. +#[tauri::command] +pub(crate) async fn replace_lost_evaos_teams_identity( + state: State<'_, EvaosTeamsState>, + app_state: State<'_, AppState>, +) -> Result { + #[cfg(not(feature = "evaos-teams-managed"))] + { + let _ = (&state, &app_state); + Err("Hive managed login is not enabled in this build".to_string()) + } + + #[cfg(feature = "evaos-teams-managed")] + { + let _operation = state.operation.lock().await; + let pending = state + .pending_identity_recovery + .lock() + .map_err(|error| error.to_string())? + .clone() + .ok_or_else(|| "No pending Hive identity recovery".to_string())?; + let keys = stage_identity_rotation_key(&pending.membership_id)?; + if keys.public_key().to_hex() == pending.public_key { + return Err("Replacement identity must differ from the lost identity".to_string()); + } + let entitlement = rotate_lost_identity( + &app_state.http_client, + pending.session.as_str(), + &keys, + &pending.membership_id, + ) + .await?; + let status = persist_managed_credentials( + &state, + &app_state, + pending.session.to_string(), + keys, + pending.membership_id, + entitlement, + )?; + *state + .pending_identity_recovery + .lock() + .map_err(|error| error.to_string())? = None; + Ok(status) + } +} diff --git a/desktop/src-tauri/src/evaos_teams/tests.rs b/desktop/src-tauri/src/evaos_teams/tests.rs index 12db6a98cf5..ad29451087e 100644 --- a/desktop/src-tauri/src/evaos_teams/tests.rs +++ b/desktop/src-tauri/src/evaos_teams/tests.rs @@ -1,5 +1,31 @@ use super::*; +fn managed_credential_entries( + mut stored: HashMap, + membership_id: &str, + keys: &Keys, + session: &str, +) -> Result, String> { + let public_key = keys.public_key(); + let migrated_legacy = stored + .get(IDENTITY_KEY) + .map(|value| parse_stored_identity(value)) + .transpose()? + .is_some_and(|legacy| legacy.public_key() == public_key); + if migrated_legacy { + stored.remove(IDENTITY_KEY); + } + stored.remove(&pending_identity_rotation_key(membership_id)?); + stored.remove(LOGOUT_PENDING_KEY); + stored.insert( + membership_identity_key(membership_id)?, + encode_managed_identity(keys)?, + ); + stored.insert(ACTIVE_MEMBERSHIP_KEY.to_string(), membership_id.to_string()); + stored.insert(SESSION_KEY.to_string(), session.to_string()); + Ok(stored) +} + fn challenge(keys: &Keys) -> ChallengeResponse { let challenge = KeyBindingChallenge { schema_version: KEY_BINDING_SCHEMA.to_string(), @@ -28,6 +54,35 @@ fn challenge(keys: &Keys) -> ChallengeResponse { } } +fn identity_rotation_challenge(keys: &Keys) -> IdentityRotationChallengeResponse { + let challenge = IdentityRotationChallenge { + schema_version: IDENTITY_ROTATION_SCHEMA.to_string(), + rotation_id: "10000000-0000-4000-8000-000000000005".to_string(), + previous_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(), + replacement_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(); + IdentityRotationChallengeResponse { + status: "identity_rotation_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-identity-rotation".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 verifier = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -95,6 +150,95 @@ fn altered_challenge_template_is_rejected() { assert!(signed_challenge(&response, &keys).is_err()); } +#[test] +fn identity_rotation_signature_uses_exact_dedicated_template() { + let keys = Keys::generate(); + let response = identity_rotation_challenge(&keys); + let event = + signed_identity_rotation_challenge(&response, &keys, &response.challenge.membership_id) + .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_identity_rotation_template_is_rejected() { + let keys = Keys::generate(); + let mut response = identity_rotation_challenge(&keys); + response.event_template.tags.push(vec![ + "community".to_string(), + response.challenge.community_id.clone(), + ]); + assert!(signed_identity_rotation_challenge( + &response, + &keys, + &response.challenge.membership_id, + ) + .is_err()); +} + +#[test] +fn identity_rotation_rejects_timestamp_overflow_without_panicking() { + let keys = Keys::generate(); + let mut response = identity_rotation_challenge(&keys); + response.event_template.created_at = u64::try_from(i64::MAX).unwrap(); + assert!(signed_identity_rotation_challenge( + &response, + &keys, + &response.challenge.membership_id, + ) + .is_err()); +} + +#[test] +fn rotated_entitlement_remains_pinned_to_the_signed_relay() { + let keys = Keys::generate(); + let public_key = keys.public_key().to_hex(); + let entitlement = 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!(validate_rotated_entitlement( + entitlement, + "10000000-0000-4000-8000-000000000003", + "https://other-relay.example.com", + &public_key, + ) + .is_err()); +} + +#[test] +fn rotated_entitlement_remains_pinned_to_the_signed_community() { + let keys = Keys::generate(); + let public_key = keys.public_key().to_hex(); + let entitlement = EvaosTeamsEntitlement { + community_id: "10000000-0000-4000-8000-000000000099".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!(validate_rotated_entitlement( + entitlement, + "10000000-0000-4000-8000-000000000003", + "https://relay.example.com", + &public_key, + ) + .is_err()); +} + #[test] fn callback_requires_exact_state_and_a_valid_server_code() { let expected_state = "state-12345678"; @@ -488,6 +632,75 @@ fn bound_membership_without_its_private_key_requires_recovery() { } } +#[test] +fn staged_rotation_key_is_selected_only_after_the_server_binding_matches() { + let membership_id = "10000000-0000-4000-8000-000000000001"; + let old_public_key = "a".repeat(64); + let (stored, staged, _, _) = + staged_identity_rotation_entries(HashMap::new(), membership_id).unwrap(); + + match select_login_keys( + &stored, + &IdentityBinding { + membership_id: membership_id.to_string(), + public_key: Some(old_public_key.clone()), + }, + ) + .unwrap() + { + LoginKeySelection::RecoveryRequired { public_key } => { + assert_eq!(public_key, old_public_key) + } + LoginKeySelection::Ready(_) => { + panic!("staged key must not replace an unmatched server binding") + } + } + + let selected = match select_login_keys( + &stored, + &IdentityBinding { + membership_id: membership_id.to_string(), + public_key: Some(staged.public_key().to_hex()), + }, + ) + .unwrap() + { + LoginKeySelection::Ready(keys) => keys, + LoginKeySelection::RecoveryRequired { .. } => { + panic!("matching staged replacement should recover after server rotation") + } + }; + assert_eq!(selected.public_key(), staged.public_key()); +} + +#[test] +fn staged_rotation_key_is_reused_and_removed_only_on_canonical_promotion() { + let membership_id = "10000000-0000-4000-8000-000000000001"; + let (stored, first, staging_key, encoded) = + staged_identity_rotation_entries(HashMap::new(), membership_id).unwrap(); + assert_eq!(stored.get(&staging_key), Some(&encoded)); + + let (stored, second, second_staging_key, second_encoded) = + staged_identity_rotation_entries(stored, membership_id).unwrap(); + assert_eq!(first.public_key(), second.public_key()); + assert_eq!(staging_key, second_staging_key); + assert_eq!(encoded, second_encoded); + + let promoted = + managed_credential_entries(stored, membership_id, &second, "new-session").unwrap(); + assert!(!promoted.contains_key(&staging_key)); + assert_eq!( + parse_stored_identity( + promoted + .get(&membership_identity_key(membership_id).unwrap()) + .unwrap(), + ) + .unwrap() + .public_key(), + second.public_key(), + ); +} + #[test] #[cfg(feature = "evaos-teams-managed")] fn pending_identity_recovery_status_exposes_no_secret_material() { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 97eb7eba7d1..37bb6b74df6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -709,6 +709,7 @@ pub fn run() { start_evaos_teams_identity_recovery, confirm_evaos_teams_identity_recovery_sas, cancel_evaos_teams_identity_recovery, + replace_lost_evaos_teams_identity, logout_evaos_teams, list_hive_company_agents, list_hive_company_members, diff --git a/desktop/src-tauri/src/secret_store/managed.rs b/desktop/src-tauri/src/secret_store/managed.rs index 730e355aa4b..4e486f374b1 100644 --- a/desktop/src-tauri/src/secret_store/managed.rs +++ b/desktop/src-tauri/src/secret_store/managed.rs @@ -7,11 +7,66 @@ fn replace_entries(current: &mut HashMap, replacement: &HashMap< current.extend(replacement.clone()); } +fn store_entry_if_absent( + current: &mut HashMap, + key: &str, + candidate: &str, +) -> String { + current + .entry(key.to_string()) + .or_insert_with(|| candidate.to_string()) + .clone() +} + +fn update_entries( + current: &mut HashMap, + removals: &[String], + upserts: &HashMap, + remove_if_equal: &[(&str, &str)], +) { + for (key, expected) in remove_if_equal { + if current.get(*key).is_some_and(|value| value == *expected) { + current.remove(*key); + } + } + for key in removals { + current.remove(key); + } + current.extend(upserts.clone()); +} + 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)) } + + /// Atomically retain an existing value or store `candidate` for `key`. + /// + /// The mutation reads the durable blob while holding the cross-process + /// lock, so a second Hive process cannot overwrite an already-staged key + /// or lose unrelated entries through a stale in-memory snapshot. + pub fn store_if_absent(&self, key: &str, candidate: &str) -> Result { + let mut stored = None; + self.mutate_blob(|map| { + stored = Some(store_entry_if_absent(map, key, candidate)); + })?; + stored.ok_or_else(|| "keychain mutation did not return a stored value".to_string()) + } + + /// Atomically remove and replace selected managed entries. + /// + /// The mutation starts from a fresh durable read while holding the + /// cross-process lock, so promoting a staged identity cannot discard + /// unrelated keys or sessions written by another Hive process. + pub fn update_entries( + &self, + removals: &[String], + upserts: &HashMap, + remove_if_equal: &[(&str, &str)], + ) -> Result<(), String> { + self.mutate_blob(|map| update_entries(map, removals, upserts, remove_if_equal)) + } } #[cfg(test)] @@ -38,4 +93,74 @@ mod tests { replace_entries(&mut current, &replacement); assert_eq!(current, replacement); } + + #[test] + fn store_if_absent_preserves_existing_value_and_unrelated_entries() { + let mut current = HashMap::from([ + ("staged".to_string(), "first".to_string()), + ("unrelated".to_string(), "keep".to_string()), + ]); + let stored = store_entry_if_absent(&mut current, "staged", "second"); + assert_eq!(stored, "first"); + assert_eq!(current.get("staged").map(String::as_str), Some("first")); + assert_eq!(current.get("unrelated").map(String::as_str), Some("keep")); + } + + #[test] + fn update_entries_preserves_unrelated_fresh_entries() { + let mut current = HashMap::from([ + ("legacy".to_string(), "same-identity".to_string()), + ("staged".to_string(), "pending-identity".to_string()), + ("session".to_string(), "other-session".to_string()), + ("unrelated".to_string(), "keep".to_string()), + ]); + let removals = Vec::new(); + let upserts = HashMap::from([ + ( + "identity:membership".to_string(), + "same-identity".to_string(), + ), + ("session".to_string(), "new-session".to_string()), + ]); + + update_entries( + &mut current, + &removals, + &upserts, + &[("legacy", "same-identity"), ("staged", "same-identity")], + ); + + assert!(!current.contains_key("legacy")); + assert_eq!( + current.get("staged").map(String::as_str), + Some("pending-identity") + ); + assert_eq!( + current.get("identity:membership").map(String::as_str), + Some("same-identity") + ); + assert_eq!( + current.get("session").map(String::as_str), + Some("new-session") + ); + assert_eq!(current.get("unrelated").map(String::as_str), Some("keep")); + } + + #[test] + fn update_entries_removes_only_the_matching_staged_identity() { + let mut current = HashMap::from([ + ("staged".to_string(), "same-identity".to_string()), + ("unrelated".to_string(), "keep".to_string()), + ]); + + update_entries( + &mut current, + &[], + &HashMap::new(), + &[("staged", "same-identity")], + ); + + assert!(!current.contains_key("staged")); + assert_eq!(current.get("unrelated").map(String::as_str), Some("keep")); + } } diff --git a/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx b/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx index cfc7e90de3b..6a26bf7018d 100644 --- a/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx +++ b/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx @@ -5,6 +5,7 @@ import { type ReactNode, useCallback, useEffect, + useRef, useState, } from "react"; @@ -14,6 +15,7 @@ import { cancelEvaosTeamsIdentityRecovery, confirmEvaosTeamsIdentityRecoverySas, getEvaosTeamsAuthStatus, + replaceLostEvaosTeamsIdentity, startEvaosTeamsLogin, startEvaosTeamsIdentityRecovery, submitEvaosTeamsLoginCode, @@ -42,6 +44,8 @@ export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { const [recoveryStarted, setRecoveryStarted] = useState(false); const [recoverySas, setRecoverySas] = useState(null); const [recoveryWorking, setRecoveryWorking] = useState(false); + const [lostDeviceConfirmed, setLostDeviceConfirmed] = useState(false); + const replacingLostIdentity = useRef(false); const refresh = useCallback(async () => { try { @@ -184,6 +188,11 @@ export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { } async function runLogin() { + setLostDeviceConfirmed(false); + setRecoveryStarted(false); + setRecoveryWorking(false); + setRecoverySas(null); + setRecoveryCode(""); setLoginPending(true); setBackupCode(""); setBackupCodeSent(false); @@ -217,7 +226,7 @@ export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { async function startIdentityRecovery(event: FormEvent) { event.preventDefault(); - if (!recoveryCode.trim() || recoveryWorking) return; + if (!recoveryCode.trim() || recoveryWorking || working) return; setError(null); setRecoverySas(null); setRecoveryStarted(true); @@ -270,6 +279,17 @@ export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { } } + async function replaceLostIdentity() { + if (working || replacingLostIdentity.current) return; + replacingLostIdentity.current = true; + try { + await run(replaceLostEvaosTeamsIdentity); + setLostDeviceConfirmed(false); + } finally { + replacingLostIdentity.current = false; + } + } + if ( !tauri || (status && !status.managed) || @@ -369,7 +389,7 @@ export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { aria-label="Hive identity pairing code" autoCapitalize="none" autoComplete="off" - disabled={recoveryStarted} + disabled={recoveryStarted || working} inputMode="text" onChange={(event) => setRecoveryCode(event.target.value)} placeholder="Paste pairing code from an authorized Hive device" @@ -378,7 +398,10 @@ export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { /> + ) : ( +
+

+ This replaces this member's Hive identity. The old key + loses relay access, and offline messages addressed only to + that old key may not be recoverable. +

+
+ + +
+
+ )} ) : null} {status?.phase === "keychain_locked" || diff --git a/desktop/src/features/evaosTeams/api.ts b/desktop/src/features/evaosTeams/api.ts index c64849fc7f7..7a46b8d21fe 100644 --- a/desktop/src/features/evaosTeams/api.ts +++ b/desktop/src/features/evaosTeams/api.ts @@ -81,6 +81,10 @@ export function cancelEvaosTeamsIdentityRecovery() { return invoke("cancel_evaos_teams_identity_recovery"); } +export function replaceLostEvaosTeamsIdentity() { + return invoke("replace_lost_evaos_teams_identity"); +} + export function logoutEvaosTeams() { return invoke("logout_evaos_teams"); } diff --git a/desktop/src/features/evaosTeams/loginFallback.test.mjs b/desktop/src/features/evaosTeams/loginFallback.test.mjs index 8553dda32c3..8a52388058b 100644 --- a/desktop/src/features/evaosTeams/loginFallback.test.mjs +++ b/desktop/src/features/evaosTeams/loginFallback.test.mjs @@ -7,6 +7,10 @@ const authGateSource = await readFile( "utf8", ); const apiSource = await readFile(new URL("./api.ts", import.meta.url), "utf8"); +const backendSource = await readFile( + new URL("../../../src-tauri/src/evaos_teams.rs", import.meta.url), + "utf8", +); test("managed sign-in exposes the proof-bound backup-code path only while pending", () => { assert.match(authGateSource, /loginPending \? \(/); @@ -23,3 +27,43 @@ test("a failed login refresh cannot erase the visible action error", () => { assert.notEqual(actionErrorIndex, -1); assert.ok(refreshIndex < actionErrorIndex); }); + +test("lost-device identity replacement is explicit, consequential, and command-backed", () => { + assert.match(authGateSource, /I no longer have an authorized device/); + assert.match(authGateSource, /old key[\s\S]*loses relay access/); + assert.match( + authGateSource, + /offline messages addressed only to[\s\S]*old key may not be recoverable/, + ); + assert.match(authGateSource, /Replace identity on this Mac/); + assert.match(authGateSource, /replaceLostEvaosTeamsIdentity/); + assert.match(authGateSource, /working \|\| replacingLostIdentity\.current/); + assert.match( + authGateSource, + /disabled=\{working \|\| recoveryWorking \|\| recoveryStarted\}/, + ); + assert.match( + authGateSource, + /!recoveryCode\.trim\(\) \|\| recoveryWorking \|\| working/, + ); + assert.match(authGateSource, /recoveryStarted \|\|\s+working/); + assert.match( + authGateSource, + /async function runLogin\(\) \{\s+setLostDeviceConfirmed\(false\);\s+setRecoveryStarted\(false\);\s+setRecoveryWorking\(false\);\s+setRecoverySas\(null\);\s+setRecoveryCode\(""\);/, + ); + assert.match(apiSource, /replace_lost_evaos_teams_identity/); + const loginStart = backendSource.indexOf( + "pub(crate) async fn start_evaos_teams_login", + ); + const pairingAbort = backendSource.indexOf( + "abort_identity_recovery_pairing(", + loginStart, + ); + const keychainCheck = backendSource.indexOf( + "verify_managed_store_writable()?", + loginStart, + ); + assert.ok(loginStart >= 0); + assert.ok(pairingAbort > loginStart); + assert.ok(keychainCheck > pairingAbort); +});