+ 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.