From d9ac4c3dd90255df52cea4d1801b0dc6db298a6d Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 29 Jul 2026 06:31:25 +0700 Subject: [PATCH 1/7] fix(hive): add explicit lost identity rotation --- desktop/src-tauri/src/evaos_teams.rs | 303 ++++++++++++++++++ desktop/src-tauri/src/evaos_teams/tests.rs | 130 ++++++++ desktop/src-tauri/src/lib.rs | 1 + .../evaosTeams/EvaosTeamsAuthGate.tsx | 46 +++ desktop/src/features/evaosTeams/api.ts | 4 + .../evaosTeams/loginFallback.test.mjs | 12 + 6 files changed, 496 insertions(+) diff --git a/desktop/src-tauri/src/evaos_teams.rs b/desktop/src-tauri/src/evaos_teams.rs index 58939a82c7f..c72574d9f01 100644 --- a/desktop/src-tauri/src/evaos_teams.rs +++ b/desktop/src-tauri/src/evaos_teams.rs @@ -69,11 +69,13 @@ const KEYRING_SERVICE: &str = "evaos-teams-desktop"; // candidate. New keys are scoped by the server-selected membership UUID. const IDENTITY_KEY: &str = "identity"; const IDENTITY_KEY_PREFIX: &str = "identity:"; +const IDENTITY_ROTATION_KEY_PREFIX: &str = "pending_identity_rotation:"; const ACTIVE_MEMBERSHIP_KEY: &str = "active_membership_id"; const SESSION_KEY: &str = "electric_desktop_session"; const LOGOUT_PENDING_KEY: &str = "logout_pending"; const KEY_BINDING_KIND: u16 = 27_235; const KEY_BINDING_SCHEMA: &str = "evaos.buzz_key_binding.v1"; +const IDENTITY_ROTATION_SCHEMA: &str = "evaos.buzz_identity_rotation.v1"; const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const IDENTITY_RECOVERY_TIMEOUT: Duration = Duration::from_secs(130); @@ -322,6 +324,27 @@ struct ChallengeResponse { relay_host: String, } +#[derive(Debug, Deserialize, Serialize, PartialEq)] +struct IdentityRotationChallenge { + schema_version: String, + rotation_id: String, + previous_identity_id: String, + membership_id: String, + community_id: String, + desktop_session_id: String, + replacement_public_key: String, + nonce: String, + expires_at: String, +} + +#[derive(Debug, Deserialize)] +struct IdentityRotationChallengeResponse { + status: String, + challenge: IdentityRotationChallenge, + event_template: EventTemplate, + relay_host: String, +} + #[derive(Debug, Deserialize)] struct EntitlementResponse { status: String, @@ -568,6 +591,97 @@ fn signed_challenge( .map_err(|error| format!("could not encode managed key challenge: {error}")) } +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())?; + if (created_at - now.timestamp()).abs() > 5 * 60 { + return Err("managed identity replacement timestamp is invalid".to_string()); + } + relay_websocket_url(&response.relay_host)?; + Ok(()) +} + +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}")) +} + fn disable_managed_access(app_state: &AppState) { app_state .evaos_teams_authorized @@ -597,10 +711,31 @@ fn membership_identity_key(membership_id: &str) -> Result { Ok(format!("{IDENTITY_KEY_PREFIX}{membership_id}")) } +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}")) +} + fn parse_stored_identity(value: &str) -> Result { Keys::parse(value.trim()).map_err(|_| "managed Keychain identity is invalid".to_string()) } +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)) +} + enum LoginKeySelection { Ready(Keys), RecoveryRequired { public_key: String }, @@ -630,6 +765,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 { @@ -661,6 +803,7 @@ fn managed_credential_entries( 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)?, @@ -971,6 +1114,24 @@ fn persist_managed_credentials( Ok(EvaosTeamsAuthStatus::active(entitlement)) } +#[cfg(feature = "evaos-teams-managed")] +fn stage_identity_rotation_key(membership_id: &str) -> Result { + let (replacement, keys, staging_key, encoded) = staged_identity_rotation_entries( + managed_store().load_all_readonly()?.unwrap_or_default(), + membership_id, + )?; + managed_store() + .replace_all(&replacement) + .map_err(|_| "Could not stage a replacement identity in macOS Keychain".to_string())?; + 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) +} + #[cfg(feature = "evaos-teams-managed")] fn identity_recovery_message(public_key: &str) -> String { let suffix = public_key @@ -1679,6 +1840,19 @@ async fn bind_identity( bind_verified_entitlement(verified.entitlement, &challenge.relay_host, &public_key) } +#[cfg(feature = "evaos-teams-managed")] +fn validate_rotated_entitlement( + entitlement: EvaosTeamsEntitlement, + expected_relay: &str, + expected_public_key: &str, +) -> Result { + 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 get_identity_binding( client: &reqwest::Client, @@ -1699,6 +1873,135 @@ async fn get_identity_binding( Ok(response.binding) } +#[cfg(feature = "evaos-teams-managed")] +async fn recover_completed_identity_rotation( + client: &reqwest::Client, + token: &str, + expected_membership_id: &str, + keys: &Keys, +) -> 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_entitlement(&entitlement, &public_key)?; + Ok(entitlement) +} + +#[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.relay_host, + &public_key, + ), + Ok(_) | Err(_) => recover_completed_identity_rotation( + client, + token, + expected_membership_id, + keys, + ) + .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) + } +} + /// Start an account-selecting browser login and complete device-code claim and /// server key binding entirely in Rust. #[tauri::command] diff --git a/desktop/src-tauri/src/evaos_teams/tests.rs b/desktop/src-tauri/src/evaos_teams/tests.rs index 12db6a98cf5..b15ab47166c 100644 --- a/desktop/src-tauri/src/evaos_teams/tests.rs +++ b/desktop/src-tauri/src/evaos_teams/tests.rs @@ -28,6 +28,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 +124,38 @@ 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 callback_requires_exact_state_and_a_valid_server_code() { let expected_state = "state-12345678"; @@ -488,6 +549,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/features/evaosTeams/EvaosTeamsAuthGate.tsx b/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx index cfc7e90de3b..c39f1dd2c07 100644 --- a/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx +++ b/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx @@ -14,6 +14,7 @@ import { cancelEvaosTeamsIdentityRecovery, confirmEvaosTeamsIdentityRecoverySas, getEvaosTeamsAuthStatus, + replaceLostEvaosTeamsIdentity, startEvaosTeamsLogin, startEvaosTeamsIdentityRecovery, submitEvaosTeamsLoginCode, @@ -42,6 +43,7 @@ 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 refresh = useCallback(async () => { try { @@ -270,6 +272,12 @@ export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { } } + async function replaceLostIdentity() { + if (working) return; + await run(replaceLostEvaosTeamsIdentity); + setLostDeviceConfirmed(false); + } + if ( !tauri || (status && !status.managed) || @@ -433,6 +441,44 @@ export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { both devices. Hive will import only the exact identity selected by Electric Sheep.

+ {!lostDeviceConfirmed ? ( + + ) : ( +
+

+ 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..40d098885e7 100644 --- a/desktop/src/features/evaosTeams/loginFallback.test.mjs +++ b/desktop/src/features/evaosTeams/loginFallback.test.mjs @@ -23,3 +23,15 @@ 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(apiSource, /replace_lost_evaos_teams_identity/); +}); From c0eac63846e3af7a53edd5ca36f7d97acc515264 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 29 Jul 2026 06:49:36 +0700 Subject: [PATCH 2/7] refactor(hive): split managed identity rotation module --- desktop/src-tauri/src/evaos_teams.rs | 303 +----------------- .../src/evaos_teams/identity_rotation.rs | 297 +++++++++++++++++ 2 files changed, 305 insertions(+), 295 deletions(-) create mode 100644 desktop/src-tauri/src/evaos_teams/identity_rotation.rs diff --git a/desktop/src-tauri/src/evaos_teams.rs b/desktop/src-tauri/src/evaos_teams.rs index c72574d9f01..43d7dd955e0 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,13 @@ 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, + IdentityRotationChallenge, IdentityRotationChallengeResponse, IDENTITY_ROTATION_SCHEMA, +}; #[cfg(test)] use login::callback_device_code; use login::{login_callback, register_pending_login, submit_pending_login_code, LoginCallback}; @@ -69,13 +77,11 @@ const KEYRING_SERVICE: &str = "evaos-teams-desktop"; // candidate. New keys are scoped by the server-selected membership UUID. const IDENTITY_KEY: &str = "identity"; const IDENTITY_KEY_PREFIX: &str = "identity:"; -const IDENTITY_ROTATION_KEY_PREFIX: &str = "pending_identity_rotation:"; const ACTIVE_MEMBERSHIP_KEY: &str = "active_membership_id"; const SESSION_KEY: &str = "electric_desktop_session"; const LOGOUT_PENDING_KEY: &str = "logout_pending"; const KEY_BINDING_KIND: u16 = 27_235; const KEY_BINDING_SCHEMA: &str = "evaos.buzz_key_binding.v1"; -const IDENTITY_ROTATION_SCHEMA: &str = "evaos.buzz_identity_rotation.v1"; const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const IDENTITY_RECOVERY_TIMEOUT: Duration = Duration::from_secs(130); @@ -324,27 +330,6 @@ struct ChallengeResponse { relay_host: String, } -#[derive(Debug, Deserialize, Serialize, PartialEq)] -struct IdentityRotationChallenge { - schema_version: String, - rotation_id: String, - previous_identity_id: String, - membership_id: String, - community_id: String, - desktop_session_id: String, - replacement_public_key: String, - nonce: String, - expires_at: String, -} - -#[derive(Debug, Deserialize)] -struct IdentityRotationChallengeResponse { - status: String, - challenge: IdentityRotationChallenge, - event_template: EventTemplate, - relay_host: String, -} - #[derive(Debug, Deserialize)] struct EntitlementResponse { status: String, @@ -591,97 +576,6 @@ fn signed_challenge( .map_err(|error| format!("could not encode managed key challenge: {error}")) } -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())?; - if (created_at - now.timestamp()).abs() > 5 * 60 { - return Err("managed identity replacement timestamp is invalid".to_string()); - } - relay_websocket_url(&response.relay_host)?; - Ok(()) -} - -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}")) -} - fn disable_managed_access(app_state: &AppState) { app_state .evaos_teams_authorized @@ -711,31 +605,10 @@ fn membership_identity_key(membership_id: &str) -> Result { Ok(format!("{IDENTITY_KEY_PREFIX}{membership_id}")) } -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}")) -} - fn parse_stored_identity(value: &str) -> Result { Keys::parse(value.trim()).map_err(|_| "managed Keychain identity is invalid".to_string()) } -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)) -} - enum LoginKeySelection { Ready(Keys), RecoveryRequired { public_key: String }, @@ -1114,24 +987,6 @@ fn persist_managed_credentials( Ok(EvaosTeamsAuthStatus::active(entitlement)) } -#[cfg(feature = "evaos-teams-managed")] -fn stage_identity_rotation_key(membership_id: &str) -> Result { - let (replacement, keys, staging_key, encoded) = staged_identity_rotation_entries( - managed_store().load_all_readonly()?.unwrap_or_default(), - membership_id, - )?; - managed_store() - .replace_all(&replacement) - .map_err(|_| "Could not stage a replacement identity in macOS Keychain".to_string())?; - 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) -} - #[cfg(feature = "evaos-teams-managed")] fn identity_recovery_message(public_key: &str) -> String { let suffix = public_key @@ -1840,19 +1695,6 @@ async fn bind_identity( bind_verified_entitlement(verified.entitlement, &challenge.relay_host, &public_key) } -#[cfg(feature = "evaos-teams-managed")] -fn validate_rotated_entitlement( - entitlement: EvaosTeamsEntitlement, - expected_relay: &str, - expected_public_key: &str, -) -> Result { - 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 get_identity_binding( client: &reqwest::Client, @@ -1873,135 +1715,6 @@ async fn get_identity_binding( Ok(response.binding) } -#[cfg(feature = "evaos-teams-managed")] -async fn recover_completed_identity_rotation( - client: &reqwest::Client, - token: &str, - expected_membership_id: &str, - keys: &Keys, -) -> 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_entitlement(&entitlement, &public_key)?; - Ok(entitlement) -} - -#[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.relay_host, - &public_key, - ), - Ok(_) | Err(_) => recover_completed_identity_rotation( - client, - token, - expected_membership_id, - keys, - ) - .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) - } -} - /// Start an account-selecting browser login and complete device-code claim and /// server key binding entirely in Rust. #[tauri::command] 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..535a78afb16 --- /dev/null +++ b/desktop/src-tauri/src/evaos_teams/identity_rotation.rs @@ -0,0 +1,297 @@ +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())?; + if (created_at - now.timestamp()).abs() > 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}")) +} + +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 (replacement, keys, staging_key, encoded) = staged_identity_rotation_entries( + managed_store().load_all_readonly()?.unwrap_or_default(), + membership_id, + )?; + managed_store() + .replace_all(&replacement) + .map_err(|_| "Could not stage a replacement identity in macOS Keychain".to_string())?; + 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) +} + +#[cfg(feature = "evaos-teams-managed")] +fn validate_rotated_entitlement( + entitlement: EvaosTeamsEntitlement, + expected_relay: &str, + expected_public_key: &str, +) -> Result { + 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, +) -> 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_entitlement(&entitlement, &public_key)?; + Ok(entitlement) +} + +#[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.relay_host, + &public_key, + ), + Ok(_) | Err(_) => recover_completed_identity_rotation( + client, + token, + expected_membership_id, + keys, + ) + .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) + } +} From a536e11c950a347e994402afd9581574c604da93 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 29 Jul 2026 07:14:28 +0700 Subject: [PATCH 3/7] fix(hive): harden lost identity replacement flow --- desktop/src-tauri/src/evaos_teams.rs | 3 +- .../src/evaos_teams/identity_rotation.rs | 14 +++++--- desktop/src-tauri/src/evaos_teams/tests.rs | 34 +++++++++++++++++++ .../evaosTeams/EvaosTeamsAuthGate.tsx | 20 +++++++---- .../evaosTeams/loginFallback.test.mjs | 5 +++ 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/desktop/src-tauri/src/evaos_teams.rs b/desktop/src-tauri/src/evaos_teams.rs index 43d7dd955e0..515067f1b1b 100644 --- a/desktop/src-tauri/src/evaos_teams.rs +++ b/desktop/src-tauri/src/evaos_teams.rs @@ -60,7 +60,8 @@ pub(crate) use identity_rotation::replace_lost_evaos_teams_identity; #[cfg(test)] use identity_rotation::{ signed_identity_rotation_challenge, staged_identity_rotation_entries, - IdentityRotationChallenge, IdentityRotationChallengeResponse, IDENTITY_ROTATION_SCHEMA, + validate_rotated_entitlement, IdentityRotationChallenge, IdentityRotationChallengeResponse, + IDENTITY_ROTATION_SCHEMA, }; #[cfg(test)] use login::callback_device_code; diff --git a/desktop/src-tauri/src/evaos_teams/identity_rotation.rs b/desktop/src-tauri/src/evaos_teams/identity_rotation.rs index 535a78afb16..706d5ac7785 100644 --- a/desktop/src-tauri/src/evaos_teams/identity_rotation.rs +++ b/desktop/src-tauri/src/evaos_teams/identity_rotation.rs @@ -76,7 +76,11 @@ fn validate_identity_rotation_challenge( } let created_at = i64::try_from(response.event_template.created_at) .map_err(|_| "managed identity replacement timestamp is invalid".to_string())?; - if (created_at - now.timestamp()).abs() > 5 * 60 { + 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)?; @@ -154,8 +158,7 @@ fn stage_identity_rotation_key(membership_id: &str) -> Result { Ok(keys) } -#[cfg(feature = "evaos-teams-managed")] -fn validate_rotated_entitlement( +pub(super) fn validate_rotated_entitlement( entitlement: EvaosTeamsEntitlement, expected_relay: &str, expected_public_key: &str, @@ -173,6 +176,7 @@ async fn recover_completed_identity_rotation( token: &str, expected_membership_id: &str, keys: &Keys, + expected_relay: &str, ) -> Result { let public_key = keys.public_key().to_hex(); let binding = get_identity_binding(client, token).await?; @@ -184,8 +188,7 @@ async fn recover_completed_identity_rotation( let entitlement = get_remote_entitlement(client, token) .await .map_err(|_| "managed identity replacement entitlement was not available".to_string())?; - validate_entitlement(&entitlement, &public_key)?; - Ok(entitlement) + validate_rotated_entitlement(entitlement, expected_relay, &public_key) } #[cfg(feature = "evaos-teams-managed")] @@ -236,6 +239,7 @@ async fn rotate_lost_identity( token, expected_membership_id, keys, + &challenge.relay_host, ) .await .map_err(|_| { diff --git a/desktop/src-tauri/src/evaos_teams/tests.rs b/desktop/src-tauri/src/evaos_teams/tests.rs index b15ab47166c..40d78465861 100644 --- a/desktop/src-tauri/src/evaos_teams/tests.rs +++ b/desktop/src-tauri/src/evaos_teams/tests.rs @@ -156,6 +156,40 @@ fn altered_identity_rotation_template_is_rejected() { .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, + "https://other-relay.example.com", + &public_key, + ) + .is_err()); +} + #[test] fn callback_requires_exact_state_and_a_valid_server_code() { let expected_state = "state-12345678"; diff --git a/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx b/desktop/src/features/evaosTeams/EvaosTeamsAuthGate.tsx index c39f1dd2c07..06e2b1925c5 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"; @@ -44,6 +45,7 @@ export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { const [recoverySas, setRecoverySas] = useState(null); const [recoveryWorking, setRecoveryWorking] = useState(false); const [lostDeviceConfirmed, setLostDeviceConfirmed] = useState(false); + const replacingLostIdentity = useRef(false); const refresh = useCallback(async () => { try { @@ -273,9 +275,14 @@ export function EvaosTeamsAuthGate({ children }: { children: ReactNode }) { } async function replaceLostIdentity() { - if (working) return; - await run(replaceLostEvaosTeamsIdentity); - setLostDeviceConfirmed(false); + if (working || replacingLostIdentity.current) return; + replacingLostIdentity.current = true; + try { + await run(replaceLostEvaosTeamsIdentity); + setLostDeviceConfirmed(false); + } finally { + replacingLostIdentity.current = false; + } } if ( @@ -452,14 +459,15 @@ 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.