Skip to content
This repository was archived by the owner on Aug 17, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ mesh-llm = ["dep:iroh", "dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime", "dep:me
# OS keyring backing for desktop secret storage (nsec private keys). When
# disabled, secrets fall back to 0o600 files. On by default for real builds.
system-keyring = ["dep:keyring"]
# Electric-only evaOS Teams identity-broker variant. This is deliberately a
# compile-time boundary: managed key custody must not be switchable by an
# inherited environment variable at runtime.
evaos-teams-managed = ["system-keyring"]

[build-dependencies]
base64 = "0.22"
Expand Down
44 changes: 44 additions & 0 deletions desktop/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,50 @@ include!("src/commands/reconnect_hook_config.rs");

use base64::Engine as _;

fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
match (base, overlay) {
(serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
for (key, value) in overlay {
merge_json(base.entry(key).or_insert(serde_json::Value::Null), value);
}
}
(base, overlay) => *base = overlay,
}
}

fn configure_managed_deep_link_scheme() {
if std::env::var_os("CARGO_FEATURE_EVAOS_TEAMS_MANAGED").is_none() {
return;
}
let mut config = std::env::var("TAURI_CONFIG")
.ok()
.map(|value| {
serde_json::from_str(&value)
.unwrap_or_else(|error| panic!("TAURI_CONFIG is not valid JSON: {error}"))
})
.unwrap_or_else(|| serde_json::json!({}));
merge_json(
&mut config,
serde_json::json!({
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["buzz", "evaos-teams"]
}
}
}
}),
);
let config = config.to_string();
std::env::set_var("TAURI_CONFIG", &config);
// `tauri_build` reads the build-script process environment, while
// `tauri::generate_context!()` expands later in rustc. Propagate the same
// merged overlay to rustc so runtime context and bundle registration agree.
println!("cargo:rustc-env=TAURI_CONFIG={config}");
}

fn main() {
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_EVAOS_TEAMS_MANAGED");
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL");
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY");
Expand Down Expand Up @@ -117,6 +160,7 @@ fn main() {
);
}

configure_managed_deep_link_scheme();
tauri_build::try_build(
tauri_build::Attributes::new().plugin(
"websocket",
Expand Down
54 changes: 27 additions & 27 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::{
collections::HashMap,
io::Write,
sync::{
atomic::{AtomicBool, AtomicU16},
atomic::{AtomicBool, AtomicI64, AtomicU16, AtomicU64},
Arc, Mutex,
},
};
Expand All @@ -15,6 +15,8 @@ use tokio::sync::Mutex as AsyncMutex;
use crate::huddle::HuddleState;
use crate::managed_agents::config_bridge::SessionConfigCache;
use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey};
mod signing;

pub struct AppState {
pub keys: Mutex<Keys>,
pub http_client: reqwest::Client,
Expand Down Expand Up @@ -90,6 +92,7 @@ pub struct AppState {
/// a newer imported key during concurrent calls. Deliberately separate from
/// `keys` so readers (signing, get_identity, etc.) are not blocked during
/// keyring I/O.
#[cfg_attr(feature = "evaos-teams-managed", allow(dead_code))]
pub identity_mutation: Mutex<()>,
/// Set when the boot-time Phase 2 reset attempted a wipe but verification
/// failed. The sentinel is preserved so the next relaunch retries. All
Expand All @@ -99,6 +102,17 @@ pub struct AppState {
/// Ordering: written once in `setup()` with `Ordering::Release`; read in
/// `get_identity` with `Ordering::Acquire`.
pub reset_failed: AtomicBool,
/// Whether the Electric broker currently authorizes managed signing.
#[cfg_attr(not(feature = "evaos-teams-managed"), allow(dead_code))]
pub evaos_teams_authorized: AtomicBool,
/// Broker-provided managed signing deadline; native uses `i64::MAX`.
#[cfg_attr(not(feature = "evaos-teams-managed"), allow(dead_code))]
pub evaos_teams_expires_at: AtomicI64,
/// Serializes managed entitlement install, refresh, expiry, and revoke.
#[cfg_attr(not(feature = "evaos-teams-managed"), allow(dead_code))]
pub evaos_teams_access_transition: Mutex<()>,
#[cfg_attr(not(feature = "evaos-teams-managed"), allow(dead_code))]
pub evaos_teams_access_generation: AtomicU64,
/// Cached ACP session config from running agents, keyed by canonical
/// `(agent pubkey, relay URL)` runtime identity.
/// Populated when the harness emits `session_config_captured` observer events.
Expand Down Expand Up @@ -176,6 +190,7 @@ pub fn build_media_fetch_client() -> reqwest::Result<reqwest::Client> {
pub fn build_app_state() -> AppState {
// Env var takes precedence (dev/CI). If absent, resolve_persisted_identity()
// in setup() will replace the ephemeral placeholder with a persisted key.
#[cfg(not(feature = "evaos-teams-managed"))]
let keys = match identity_from_env() {
Some(keys) => {
eprintln!(
Expand All @@ -186,6 +201,8 @@ pub fn build_app_state() -> AppState {
}
None => Keys::generate(),
};
#[cfg(feature = "evaos-teams-managed")]
let keys = Keys::generate();

AppState {
keys: Mutex::new(keys),
Expand Down Expand Up @@ -220,6 +237,14 @@ pub fn build_app_state() -> AppState {
keyring_locked: AtomicBool::new(false),
identity_lost: AtomicBool::new(false),
reset_failed: AtomicBool::new(false),
evaos_teams_authorized: AtomicBool::new(!cfg!(feature = "evaos-teams-managed")),
evaos_teams_expires_at: AtomicI64::new(if cfg!(feature = "evaos-teams-managed") {
0
} else {
i64::MAX
}),
evaos_teams_access_transition: Mutex::new(()),
evaos_teams_access_generation: AtomicU64::new(0),
#[cfg(feature = "mesh-llm")]
mesh_llm_runtime: AsyncMutex::new(None),
#[cfg(feature = "mesh-llm")]
Expand Down Expand Up @@ -287,32 +312,6 @@ impl AppState {
}
}

/// Return the active identity keys if they are in a signable state.
///
/// Returns `Err` when the identity is in a lost state (`identity_lost`
/// — ephemeral key, user must re-import their nsec) or when the keyring
/// is locked (`keyring_locked` — key is held in a keyring that is
/// unavailable this boot). All signing and publish commands must call
/// this instead of locking `state.keys` directly, so that recovery mode
/// blocks publishing under an invalid or inaccessible identity.
pub fn signing_keys(&self) -> Result<Keys, String> {
if self
.identity_lost
.load(std::sync::atomic::Ordering::Acquire)
|| self
.keyring_locked
.load(std::sync::atomic::Ordering::Acquire)
{
return Err("identity is in recovery mode; event signing is disabled \
until the identity is restored and Buzz is relaunched"
.to_string());
}
self.keys
.lock()
.map_err(|e| e.to_string())
.map(|k| k.clone())
}

/// Emit the current huddle state to the frontend via Tauri event.
///
/// Acquires both locks (app_handle + huddle_state), clones a snapshot,
Expand Down Expand Up @@ -883,6 +882,7 @@ fn persist_imported_identity_impl(

/// Public entry point binding [`persist_imported_identity_impl`] to the shared
/// [`crate::secret_store::SecretStore`]. See the impl for the persistence policy.
#[cfg_attr(feature = "evaos-teams-managed", allow(dead_code))]
pub(crate) fn persist_imported_identity(
store: &crate::secret_store::SecretStore,
keys: &Keys,
Expand Down
158 changes: 158 additions & 0 deletions desktop/src-tauri/src/app_state/signing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
use std::sync::atomic::Ordering;

use nostr::Keys;

use super::AppState;

impl AppState {
/// Revoke every in-process capability derived from a managed entitlement.
/// This is also called when `signing_keys` discovers backend expiry, so a
/// long-lived huddle cannot outlive the authorization that started it.
#[cfg(feature = "evaos-teams-managed")]
pub(crate) fn disable_evaos_teams_access(&self) {
let old_pipelines = {
let _transition = self
.evaos_teams_access_transition
.lock()
.unwrap_or_else(|error| error.into_inner());
self.evaos_teams_access_generation
.fetch_add(1, Ordering::AcqRel);
self.evaos_teams_authorized.store(false, Ordering::Release);
self.evaos_teams_expires_at.store(0, Ordering::Release);
if let Ok(mut relay) = self.relay_url_override.lock() {
*relay = None;
}
self.huddle_state.lock().ok().map(|mut huddle| {
huddle.session_generation.fetch_add(1, Ordering::Release);
if let Some(cancel) = huddle.audio_ws_cancel.take() {
cancel.cancel();
}
huddle.audio_relay_pcm_tx.take();
let stt = huddle.stt_pipeline.take();
let tts = huddle.tts_pipeline.take();
huddle.reset_preserving_generation();
(stt, tts)
})
};
drop(old_pipelines);
self.emit_huddle_state_changed();
}

/// Install a validated managed capability and arm an entitlement-owned
/// expiry task. The transition lock and generation token make refresh and
/// expiry mutually exclusive; the timer is independent of huddle sockets.
#[cfg(feature = "evaos-teams-managed")]
pub(crate) fn install_evaos_teams_access(
&self,
keys: Keys,
relay: String,
expires_at: i64,
) -> Result<(), String> {
use tauri::Manager;

let app = self
.app_handle
.lock()
.map_err(|error| error.to_string())?
.clone()
.ok_or_else(|| "managed entitlement expiry task is unavailable".to_string())?;
let generation = {
let _transition = self
.evaos_teams_access_transition
.lock()
.map_err(|error| error.to_string())?;
self.evaos_teams_authorized.store(false, Ordering::Release);
*self.keys.lock().map_err(|error| error.to_string())? = keys;
*self
.relay_url_override
.lock()
.map_err(|error| error.to_string())? = Some(relay);
self.evaos_teams_expires_at
.store(expires_at, Ordering::Release);
let generation = self
.evaos_teams_access_generation
.fetch_add(1, Ordering::AcqRel)
.wrapping_add(1);
self.evaos_teams_authorized.store(true, Ordering::Release);
generation
};

tauri::async_runtime::spawn(async move {
loop {
let wait_seconds =
u64::try_from(expires_at.saturating_sub(chrono::Utc::now().timestamp()))
.unwrap_or(0);
if wait_seconds > 0 {
tokio::time::sleep(std::time::Duration::from_secs(wait_seconds)).await;
continue;
}
let state = app.state::<AppState>();
let _transition = state
.evaos_teams_access_transition
.lock()
.unwrap_or_else(|error| error.into_inner());
let still_current = state.evaos_teams_access_generation.load(Ordering::Acquire)
== generation
&& state.evaos_teams_expires_at.load(Ordering::Acquire) == expires_at;
if !still_current {
break;
}
state
.evaos_teams_access_generation
.fetch_add(1, Ordering::AcqRel);
state.evaos_teams_authorized.store(false, Ordering::Release);
state.evaos_teams_expires_at.store(0, Ordering::Release);
if let Ok(mut relay) = state.relay_url_override.lock() {
*relay = None;
}
let old_pipelines = state.huddle_state.lock().ok().map(|mut huddle| {
huddle.session_generation.fetch_add(1, Ordering::Release);
if let Some(cancel) = huddle.audio_ws_cancel.take() {
cancel.cancel();
}
huddle.audio_relay_pcm_tx.take();
let stt = huddle.stt_pipeline.take();
let tts = huddle.tts_pipeline.take();
huddle.reset_preserving_generation();
(stt, tts)
});
drop(_transition);
drop(old_pipelines);
state.emit_huddle_state_changed();
break;
}
});
Ok(())
}

/// Return the active identity keys if they are in a signable state.
///
/// Managed builds additionally require a current broker entitlement.
/// Native recovery mode blocks publishing under an invalid or inaccessible
/// identity until the identity is restored and Buzz is relaunched.
pub fn signing_keys(&self) -> Result<Keys, String> {
if self.identity_lost.load(Ordering::Acquire) || self.keyring_locked.load(Ordering::Acquire)
{
return Err("identity is in recovery mode; event signing is disabled \
until the identity is restored and Buzz is relaunched"
.to_string());
}
#[cfg(feature = "evaos-teams-managed")]
{
let now = chrono::Utc::now().timestamp();
if !self.evaos_teams_authorized.load(Ordering::Acquire)
|| self.evaos_teams_expires_at.load(Ordering::Acquire) <= now
{
self.disable_evaos_teams_access();
return Err(
"evaOS Teams access is not currently authorized; sign in or refresh access"
.to_string(),
);
}
}
self.keys
.lock()
.map_err(|e| e.to_string())
.map(|keys| keys.clone())
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/app_state_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,7 @@ fn reachable_but_empty_with_marker_and_no_file_returns_lost_ephemeral_not_persis
// ── signing_keys() gate tests ─────────────────────────────────────────────

#[test]
#[cfg(not(feature = "evaos-teams-managed"))]
fn signing_keys_returns_ok_when_normal() {
// When neither identity_lost nor keyring_locked is set, signing_keys()
// must return the live keys and allow signing.
Expand Down
6 changes: 1 addition & 5 deletions desktop/src-tauri/src/archive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,7 @@ pub async fn archive_events(
let bucket_results = query_buckets(plan.buckets, state_ref).await;

// ── Phase 3: persist (blocking SQLite) ──────────────────────────────────
let owner_keys = {
let keys_guard = state.keys.lock().map_err(|e| e.to_string())?;
keys_guard.clone()
// guard drops here, before awaiting the blocking commit task.
};
let owner_keys = state.signing_keys()?;
let commit_identity_pk = identity_pk.clone();
let commit_relay_url = relay_url.clone();
run_archive_db_task(move |conn| {
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/builderlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@ pub(crate) async fn bind_builderlab_nostr_identity(
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<serde_json::Value, String> {
if cfg!(feature = "evaos-teams-managed") {
return Err("Managed identities cannot be bound to Builderlab".to_string());
}
let challenge_value = authenticated_json(
&app_state.http_client,
&session,
Expand Down
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/commands/channels_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,14 @@ fn pending_owner_mark_uses_signer_captured_before_identity_swap() {
// marks with that captured identity, so a swap that happens afterward
// (i.e. during what would be the submit await) can't retarget the mark.
let state = crate::app_state::build_app_state();
#[cfg(feature = "evaos-teams-managed")]
state
.evaos_teams_authorized
.store(true, std::sync::atomic::Ordering::Release);
#[cfg(feature = "evaos-teams-managed")]
state
.evaos_teams_expires_at
.store(i64::MAX, std::sync::atomic::Ordering::Release);

// Mirrors `create_channel`'s new capture-before-submit step: read the
// signer identity once, before anything that could race with a swap.
Expand Down
Loading
Loading