Files
buzz/desktop/src-tauri/src/app_state.rs
084e442d2d Bug-bash: mention/code-span, feed titles, autocomplete, edit-mentions, draft routing, and right-click media Download (#2027)
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-07-17 10:27:41 -04:00

1073 lines
49 KiB
Rust

use std::{
collections::HashMap,
io::Write,
sync::{
atomic::{AtomicBool, AtomicU16},
Arc, Mutex,
},
};
use nostr::{Keys, ToBech32};
use tauri::{AppHandle, Manager};
#[cfg(feature = "mesh-llm")]
use tokio::sync::Mutex as AsyncMutex;
use crate::huddle::HuddleState;
use crate::managed_agents::config_bridge::SessionConfigCache;
use crate::managed_agents::ManagedAgentProcess;
pub struct AppState {
pub keys: Mutex<Keys>,
pub http_client: reqwest::Client,
/// A no-redirect client for authenticated relay media fetches (download,
/// clipboard copy, snapshot, editor). Every caller pre-validates the URL
/// origin, but the app-wide `http_client` follows redirects by default, so
/// a relay `/media/` URL returning a 3xx to an off-origin or private host
/// would forward the minted media Authorization header across origins —
/// a redirect-hop SSRF. This client treats any 3xx as a non-success
/// response (surfaced as an error) so the auth token never leaves the
/// validated relay origin.
pub media_fetch_client: reqwest::Client,
/// Workspace-provided relay URL override. Set by `apply_workspace` on app
/// init and takes priority over env vars and compile-time defaults.
pub relay_url_override: Mutex<Option<String>>,
/// Set during backend setup when managed agents are eligible for launch
/// restore. `apply_workspace` consumes it after installing the workspace
/// relay and identity, so agents never start against the fallback relay.
pub managed_agent_restore_pending: AtomicBool,
/// Whether desktop may repair managed-agent kind:0 profiles from its local
/// records. Disabled by the agent-managed profiles experiment so an agent's
/// own profile updates are not overwritten on start or restore.
pub managed_agent_profile_reconcile_enabled: AtomicBool,
/// Shared shutdown signal checked by launch-time agent restoration.
pub shutdown_started: AtomicBool,
/// Serializes the restore spawn/register transition with shutdown cleanup,
/// preventing an agent from spawning after shutdown has swept processes.
pub managed_agent_restore_transition: Mutex<()>,
pub managed_agents_store_lock: Mutex<()>,
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex<HashMap<String, ManagedAgentProcess>>,
pub huddle_state: Mutex<HuddleState>,
/// Tauri app handle — stored after setup so huddle commands can emit
/// `huddle-state-changed` events without needing the handle threaded
/// through every call site.
///
/// Set once during `setup()` in `lib.rs`; never cleared.
pub app_handle: Mutex<Option<AppHandle>>,
/// Selected audio output device name. `None` = system default.
/// Used by `connect_audio_relay` and TTS pipeline when opening sinks.
pub audio_output_device: Mutex<Option<String>>,
/// Port of the localhost media streaming proxy (set during setup).
pub media_proxy_port: AtomicU16,
/// Set when identity resolution detected a "keyring-locked" state: the
/// keyring is unreachable this boot but a migration marker shows the key
/// lives there. An ephemeral key is generated so the app can open; all
/// signing commands check this flag via [`AppState::signing_keys`] and
/// return `Err` so no events are published under the inaccessible identity.
/// Mutually exclusive with `identity_lost` (guaranteed by `RecoveryState`
/// at the resolve boundary).
///
/// Ordering: writers store with `Ordering::Release` after `state.keys` is
/// updated, so a reader observing `false` with `Ordering::Acquire` is
/// guaranteed to see the updated keys. Writers: `setup()` (initial
/// resolution via `resolve_persisted_identity`) and `import_identity`
/// (clears the flag when the user successfully imports a new key).
pub keyring_locked: AtomicBool,
/// Set when identity resolution detected a "lost" state: the migration
/// marker was present but the keyring was empty and no plaintext fallback
/// existed. An ephemeral key was generated to let the app boot; the
/// frontend checks this flag via `get_identity` and routes to the nsec
/// re-import step instead of the normal onboarding profile flow.
///
/// Ordering: writers store with `Ordering::Release` after `state.keys` is
/// updated, so a reader observing `false` with `Ordering::Acquire` is
/// guaranteed to see the updated keys. Writers: `setup()` (initial
/// resolution) and `import_identity`/`persist_current_identity`
/// (user-initiated key import).
pub identity_lost: AtomicBool,
/// Serializes runtime identity mutations (`import_identity` and
/// `persist_current_identity`) so a stale ephemeral key can never overwrite
/// a newer imported key during concurrent calls. Deliberately separate from
/// `keys` so readers (signing, get_identity, etc.) are not blocked during
/// keyring I/O.
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
/// identity-dependent setup is skipped; the frontend shows a reset-failed
/// recovery screen via `get_identity`.
///
/// Ordering: written once in `setup()` with `Ordering::Release`; read in
/// `get_identity` with `Ordering::Acquire`.
pub reset_failed: AtomicBool,
/// Cached ACP session config from running agents, keyed by agent pubkey.
/// Populated when the harness emits `session_config_captured` observer events.
pub session_config_cache: Mutex<HashMap<String, SessionConfigCache>>,
/// IOKit power assertion state — prevents idle sleep while agents run.
pub prevent_sleep: Arc<Mutex<crate::prevent_sleep::PreventSleepState>>,
/// In-process mesh-llm node started by Buzz Desktop.
#[cfg(feature = "mesh-llm")]
pub mesh_llm_runtime: AsyncMutex<Option<crate::mesh_llm::DesktopMeshRuntime>>,
/// Runtime-owned shared-compute coordinator. It publishes member-signed
/// discovery status and reconciles MeshLLM's admission roster; MeshLLM
/// itself owns direct QUIC/iroh connection establishment.
#[cfg(feature = "mesh-llm")]
pub mesh_coordinator: AsyncMutex<Option<crate::mesh_llm::MeshCoordinator>>,
/// `(creator_pubkey_hex, channel_id)` pairs for channels the *named*
/// identity created via `create_channel` and has not yet observed its own
/// kind:39002 membership entry for. The relay provisions that entry
/// asynchronously (#1761), so without this overlay a freshly created
/// channel's owner reads back as `is_member=false` until the snapshot
/// propagates, disabling their own composer. Entries are bound to the
/// creating identity so an in-process identity swap (`import_identity`,
/// workspace apply) can never inherit another identity's stale
/// membership. Populated only by this process's own `create_channel`
/// calls — a relay can never write into it — so it carries no
/// trust-boundary risk. `get_channels` clears an entry once the real
/// kind:39002 is observed for the current identity, keeping the set
/// bounded and letting a later leave correctly flip the channel back to
/// `is_member=false`.
pub pending_owned_channels: Mutex<std::collections::HashSet<(String, String)>>,
}
/// Parse the `BUZZ_PRIVATE_KEY` env var into identity keys. `Some` means the
/// env var was present and valid and MUST win over any persisted/keyring key
/// (the dev/CI/harness override). `None` means absent or malformed — callers
/// fall through to persisted resolution. A malformed value is logged and
/// treated as absent rather than left on an ephemeral identity.
fn identity_from_env() -> Option<Keys> {
match std::env::var("BUZZ_PRIVATE_KEY") {
Ok(nsec) => match Keys::parse(nsec.trim()) {
Ok(keys) => Some(keys),
Err(error) => {
eprintln!("buzz-desktop: invalid BUZZ_PRIVATE_KEY: {error}");
None
}
},
Err(std::env::VarError::NotUnicode(_)) => {
eprintln!("buzz-desktop: BUZZ_PRIVATE_KEY contains invalid UTF-8");
None
}
Err(std::env::VarError::NotPresent) => None,
}
}
/// Build the no-redirect HTTP client used for authenticated relay media
/// fetches (download / copy).
///
/// This client is a security boundary, not a convenience: it carries a minted
/// media `Authorization` header, so it MUST NOT follow redirects. A relay 3xx
/// to an off-origin or private host would otherwise forward that header across
/// origins (a redirect-hop SSRF). `redirect::Policy::none()` returns the 3xx
/// verbatim so the caller can reject it.
///
/// Returned as a `Result` so the fail-closed invariant is testable — callers
/// must never substitute a redirect-following client on build failure. Shares
/// the localhost `resolve`/pool config with the app-wide `http_client`.
pub fn build_media_fetch_client() -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
.pool_idle_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(1)
.redirect(reqwest::redirect::Policy::none())
.build()
}
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.
let keys = match identity_from_env() {
Some(keys) => {
eprintln!(
"buzz-desktop: configured identity pubkey {}",
keys.public_key().to_hex()
);
keys
}
None => Keys::generate(),
};
AppState {
keys: Mutex::new(keys),
http_client: reqwest::Client::builder()
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
.pool_idle_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(1)
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
media_fetch_client: build_media_fetch_client().expect(
"media_fetch_client must build with redirect::Policy::none(); a \
redirect-following fallback would forward the minted media auth \
header across origins (redirect-hop SSRF)",
),
relay_url_override: Mutex::new(None),
managed_agent_restore_pending: AtomicBool::new(false),
managed_agent_profile_reconcile_enabled: AtomicBool::new(true),
shutdown_started: AtomicBool::new(false),
managed_agent_restore_transition: Mutex::new(()),
identity_mutation: Mutex::new(()),
managed_agents_store_lock: Mutex::new(()),
channel_templates_store_lock: Mutex::new(()),
managed_agent_processes: Mutex::new(HashMap::new()),
session_config_cache: Mutex::new(HashMap::new()),
huddle_state: Mutex::new(HuddleState::default()),
app_handle: Mutex::new(None),
audio_output_device: Mutex::new(None),
media_proxy_port: AtomicU16::new(0),
prevent_sleep: Arc::new(Mutex::new(
crate::prevent_sleep::PreventSleepState::default(),
)),
keyring_locked: AtomicBool::new(false),
identity_lost: AtomicBool::new(false),
reset_failed: AtomicBool::new(false),
#[cfg(feature = "mesh-llm")]
mesh_llm_runtime: AsyncMutex::new(None),
#[cfg(feature = "mesh-llm")]
mesh_coordinator: AsyncMutex::new(None),
pending_owned_channels: Mutex::new(std::collections::HashSet::new()),
}
}
impl AppState {
/// Lock the huddle state mutex, converting a poisoned-lock error to a String.
///
/// Convenience wrapper — replaces 15+ instances of
/// `state.huddle_state.lock().map_err(|e| e.to_string())?` throughout the
/// huddle module.
pub fn huddle(&self) -> Result<std::sync::MutexGuard<'_, crate::huddle::HuddleState>, String> {
self.huddle_state.lock().map_err(|e| e.to_string())
}
pub fn get_session_cache(&self, pubkey: &str) -> Option<SessionConfigCache> {
self.session_config_cache.lock().ok()?.get(pubkey).cloned()
}
pub fn put_session_cache(&self, pubkey: &str, cache: SessionConfigCache) {
if let Ok(mut map) = self.session_config_cache.lock() {
map.insert(pubkey.to_string(), cache);
}
}
pub fn clear_session_cache(&self, pubkey: &str) {
if let Ok(mut map) = self.session_config_cache.lock() {
map.remove(pubkey);
}
}
/// Record that `channel_id` was just created by `creator_pubkey` and its
/// kind:39002 owner membership has not yet been observed.
pub fn mark_pending_owned_channel(&self, creator_pubkey: &str, channel_id: &str) {
if let Ok(mut set) = self.pending_owned_channels.lock() {
set.insert((creator_pubkey.to_string(), channel_id.to_string()));
}
}
/// Whether `channel_id` is still awaiting `my_pubkey`'s kind:39002 entry.
/// Bound to `my_pubkey` so an in-process identity swap never inherits
/// another identity's pending-owner entry for the same channel id.
pub fn is_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) -> bool {
self.pending_owned_channels
.lock()
.map(|set| set.contains(&(my_pubkey.to_string(), channel_id.to_string())))
.unwrap_or(false)
}
/// Drop the `(my_pubkey, channel_id)` entry from the pending-owner
/// overlay once that identity's real kind:39002 membership has been
/// observed.
pub fn clear_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) {
if let Ok(mut set) = self.pending_owned_channels.lock() {
set.remove(&(my_pubkey.to_string(), channel_id.to_string()));
}
}
/// 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,
/// releases both, then emits. Best-effort — no-op if either lock is
/// poisoned or the app_handle hasn't been set yet.
pub fn emit_huddle_state_changed(&self) {
let app = match self.app_handle.lock() {
Ok(guard) => guard.clone(),
Err(_) => return,
};
let Some(app) = app else { return };
let snapshot = match self.huddle_state.lock() {
Ok(hs) => hs.clone(),
Err(_) => return,
};
crate::huddle::state::emit_huddle_state(&app, &snapshot);
}
}
/// Resolve the user's identity key from the app data directory and wire
/// the resulting [`RecoveryState`] into `AppState`.
///
/// Priority: `BUZZ_PRIVATE_KEY` env var (already handled in `build_app_state`)
/// → keyring → `{app_data_dir}/identity.key` file → generate + save.
///
/// On success, writes the resolved keys into `state.keys` (with the mutex)
/// before storing the recovery flags (Release), so any thread that reads
/// either flag as `false` with Acquire is guaranteed to see the updated keys.
///
/// Sets `state.identity_lost` on `RecoveryState::Lost` (keyring empty after
/// migration — key gone externally) and `state.keyring_locked` on
/// `RecoveryState::KeyringLocked` (keyring unreachable — key still in keyring
/// but inaccessible this boot). Both states boot with an ephemeral key; the
/// frontend shows different recovery screens for each.
pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(), String> {
// Only skip file-based resolution if the env var was present AND parsed
// successfully. A malformed env var should fall through to the persisted
// key rather than leaving the app on an ephemeral identity.
if identity_from_env().is_some() {
return Ok(());
}
let data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let resolved = load_or_create_identity(&data_dir)?;
// Write keys before setting the recovery flags (Release) so any thread
// that reads a flag as false with Acquire is guaranteed to see the keys.
*state.keys.lock().map_err(|e| e.to_string())? = resolved.keys;
state.identity_lost.store(
resolved.recovery == RecoveryState::Lost,
std::sync::atomic::Ordering::Release,
);
state.keyring_locked.store(
resolved.recovery == RecoveryState::KeyringLocked,
std::sync::atomic::Ordering::Release,
);
Ok(())
}
#[path = "app_state_keyring.rs"]
mod keyring_config;
pub(crate) use keyring_config::keyring_service;
/// Keyring key name for the human identity nsec.
const IDENTITY_KEY_NAME: &str = "identity";
/// Filename of the marker written once a successful keyring migration deletes
/// the legacy `identity.key`. Its presence is the only durable signal that a
/// key once lived in the keyring — used to tell a genuine first-ever launch
/// (no key anywhere, generating is correct) from a post-migration boot whose
/// keyring is merely unreachable (the key IS in the keyring, must NOT generate).
const MIGRATION_MARKER_NAME: &str = "identity.migrated";
/// Recovery state produced by identity resolution. `None` means the app has
/// a real, usable identity. `Lost` means the keyring was reachable-but-empty
/// despite a prior successful migration — the key vanished externally. `KeyringLocked`
/// means the keyring is unreachable this boot but was used in the past
/// (marker present, no file) — the key still exists but is temporarily
/// inaccessible. Both non-`None` variants boot with an ephemeral key; the
/// frontend shows a different recovery screen for each.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RecoveryState {
None,
Lost,
KeyringLocked,
}
/// The output of identity resolution.
struct ResolvedIdentity {
keys: Keys,
recovery: RecoveryState,
}
/// The keyring operations the identity resolution flow needs. Abstracted so the
/// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be
/// unit-tested against a fake without touching the live OS keyring.
trait IdentityKeyStore {
fn probe(&self, name: &str) -> crate::secret_store::KeyringProbe;
fn load(&self, name: &str) -> Result<Option<String>, String>;
fn store(&self, name: &str, value: &str) -> Result<(), String>;
fn delete(&self, name: &str) -> Result<(), String>;
/// Verify that `key` holds `expected` by reading directly from the OS
/// backend — bypassing any in-process cache. Returns `Ok(true)` when the
/// stored value matches, `Ok(false)` when it does not or is absent, and
/// `Err` when the backend is unavailable.
fn verify_stored(&self, key: &str, expected: &str) -> Result<bool, String>;
}
impl IdentityKeyStore for crate::secret_store::SecretStore {
fn probe(&self, name: &str) -> crate::secret_store::KeyringProbe {
crate::secret_store::SecretStore::probe(self, name)
}
fn load(&self, name: &str) -> Result<Option<String>, String> {
crate::secret_store::SecretStore::load(self, name)
}
fn store(&self, name: &str, value: &str) -> Result<(), String> {
crate::secret_store::SecretStore::store(self, name, value)
}
fn delete(&self, name: &str) -> Result<(), String> {
crate::secret_store::SecretStore::delete(self, name)
}
fn verify_stored(&self, key: &str, expected: &str) -> Result<bool, String> {
crate::secret_store::SecretStore::verify_stored_raw(self, key, expected)
}
}
/// Resolve the human identity key: migrate a legacy `identity.key` into the
/// keyring when safe, otherwise load from whichever backend holds it, else
/// generate-and-save.
///
/// Migration rule (prevents stale-key resurrection): only import the plaintext
/// file when the keyring is REACHABLE-but-empty. If the keyring is UNREACHABLE
/// this boot, fall back to reading the file directly and do NOT migrate — a
/// later import from a leftover (possibly rotated) file could resurrect an old
/// key.
fn load_or_create_identity(data_dir: &std::path::Path) -> Result<ResolvedIdentity, String> {
let legacy_path = data_dir.join("identity.key");
// No keyring available in this build: the `0o600` file is the only store.
if !cfg!(feature = "system-keyring") {
let keys = load_file_or_generate(&legacy_path, data_dir)?;
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
});
}
let store = crate::secret_store::SecretStore::shared(keyring_service());
resolve_identity_with_store(store, &legacy_path, data_dir)
}
/// Identity resolution over an [`IdentityKeyStore`] seam. Split from
/// [`load_or_create_identity`] so the probe/recover branches are testable
/// without the live OS keyring.
fn resolve_identity_with_store(
store: &impl IdentityKeyStore,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<ResolvedIdentity, String> {
use crate::secret_store::KeyringProbe;
match store.probe(IDENTITY_KEY_NAME) {
KeyringProbe::Present => {
if let Some(nsec) = store.load(IDENTITY_KEY_NAME)? {
match Keys::parse(nsec.trim()) {
Ok(keyring_keys) => {
eprintln!(
"buzz-desktop: persisted identity pubkey {}",
keyring_keys.public_key().to_hex()
);
// Check for a leftover identity.key. If it holds a
// DIFFERENT pubkey, the user imported that key after
// the last boot (pre-fix, import only wrote the file).
// Adopt it into the keyring so the user's intent sticks.
// If the pubkeys match it is a stale leftover from a
// prior migration whose remove_file failed — clean it up.
if legacy_path.exists() {
match load_key_file(legacy_path) {
Ok(file_keys)
if file_keys.public_key() != keyring_keys.public_key() =>
{
eprintln!(
"buzz-desktop: identity.key differs from keyring; \
adopting imported key {}",
file_keys.public_key().to_hex()
);
// Delegate the store→read-back-verify→marker→delete
// sequence to `persist_identity_to_keyring`, which owns
// the marker-before-delete invariant and the fallback
// logic that keeps identity.key when the marker write
// fails. A transient keyring failure must not abort
// boot — the file key is safe and adoption retries next
// boot when the keyring is reachable again.
if let Err(e) = persist_identity_to_keyring(
store,
&file_keys,
legacy_path,
data_dir,
) {
eprintln!(
"buzz-desktop: keyring adoption of identity.key \
failed ({e}); using file key, will retry next boot"
);
}
return Ok(ResolvedIdentity {
keys: file_keys,
recovery: RecoveryState::None,
});
}
// Corrupt file — keyring is authoritative. Log before
// cleanup so there is a diagnostic for the lost data.
Err(e) => {
eprintln!(
"buzz-desktop: leftover identity.key is corrupt ({e}); \
keyring is authoritative, removing"
);
ensure_marker_then_cleanup(data_dir, legacy_path);
}
// Same pubkey (stale leftover from a completed migration
// whose remove_file previously failed) — keyring is
// authoritative. Ensure the marker exists (crash-safe
// ordering: marker before delete), then clean up.
Ok(_) => {
ensure_marker_then_cleanup(data_dir, legacy_path);
}
}
}
// Self-heal: if the identity.key is gone and the migration
// marker is absent (e.g. a stranded keyring-only install from
// a pre-fix path that stored to the keyring but could not write
// the marker or fallback file), write the marker now so a later
// keyring-Unreachable boot does not treat this as a fresh install
// and silently rotate the identity. Failure is non-fatal — boot
// must never be blocked here.
if !legacy_path.exists() && !migration_marker_path(data_dir).exists() {
if let Err(e) = write_migration_marker(&migration_marker_path(data_dir))
{
eprintln!(
"buzz-desktop: keyring present but marker missing; \
self-heal marker write failed ({e}), continuing"
);
}
}
return Ok(ResolvedIdentity {
keys: keyring_keys,
recovery: RecoveryState::None,
});
}
// The corruption is in the KEYRING, not the file. Clear the
// bad keyring value and recover from the file (or generate
// fresh) — do NOT quarantine a valid leftover `identity.key`
// that holds the user's only good key.
Err(error) => {
return recover_from_keyring(
store,
legacy_path,
data_dir,
&error.to_string(),
);
}
}
} else {
// Probe said Present but load found nothing — treat as empty.
// Falls through to generate_and_persist below.
}
}
KeyringProbe::ReachableButEmpty => {
// One-time migration: import the legacy plaintext file, read-back
// verify, THEN delete it.
if legacy_path.exists() {
if let Some(keys) = migrate_identity_file(store, legacy_path, data_dir)? {
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
});
}
} else if migration_marker_path(data_dir).exists() {
// Marker present, keyring empty, no file — the key was previously
// durably stored in the keyring but is now gone (keyring cleared,
// new login session, or the entry was externally deleted). There
// is no plaintext fallback to recover from.
//
// Generate an ephemeral in-memory key so the app can boot, but
// surface a "lost" flag so the frontend prompts re-import rather
// than silently starting a fresh identity.
let ephemeral = Keys::generate();
eprintln!(
"buzz-desktop: identity lost — keyring was empty despite migration marker; \
using ephemeral key {}, awaiting user re-import",
ephemeral.public_key().to_hex()
);
return Ok(ResolvedIdentity {
keys: ephemeral,
recovery: RecoveryState::Lost,
});
}
}
KeyringProbe::Unreachable => {
// Keyring down this boot. If a recoverable file is present, use it
// (and do NOT migrate — re-importing later could resurrect a
// rotated key). With NO file, the marker disambiguates two states
// that are otherwise byte-identical (Unreachable + no file):
// - marker present → the key was migrated into the keyring and the
// file deleted. The real key is unreachable this boot but still
// exists in the keyring. Boot keyring-locked recovery (ephemeral
// key, all signing disabled) so the app can at least open; the
// frontend shows a "unlock the keyring and relaunch" screen.
// Fail-closed semantics are preserved: nothing is ever persisted
// under the ephemeral key, so no silent identity rotation occurs.
// - no marker → genuine first-ever launch with nothing to protect.
// Generate to the `0o600` file (legitimate first-run).
if !legacy_path.exists() && migration_marker_path(data_dir).exists() {
let ephemeral = Keys::generate();
eprintln!(
"buzz-desktop: keyring unreachable but migration marker present; \
booting keyring-locked recovery with ephemeral key {} — \
unlock the keyring and relaunch",
ephemeral.public_key().to_hex()
);
return Ok(ResolvedIdentity {
keys: ephemeral,
recovery: RecoveryState::KeyringLocked,
});
}
let keys = load_file_or_generate(legacy_path, data_dir)?;
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
});
}
}
let keys = generate_and_persist(store, legacy_path, data_dir)?;
Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
})
}
/// Recover from a corrupt nsec in the keyring (parse failed). Clear the bad
/// keyring value, then migrate a valid leftover `identity.key` if one exists.
/// If the migration marker is present but no valid file exists, the prior
/// identity is unrecoverable — return `Lost` recovery rather than silently
/// generating a new identity. Generating fresh is only correct when no prior
/// identity ever existed (no marker). The keyring delete is best-effort: a
/// delete failure logs and continues — it must never block startup.
fn recover_from_keyring(
store: &impl IdentityKeyStore,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
error: &str,
) -> Result<ResolvedIdentity, String> {
eprintln!("buzz-desktop: corrupt nsec in keyring ({error}), clearing and recovering from file");
if let Err(e) = store.delete(IDENTITY_KEY_NAME) {
eprintln!("buzz-desktop: failed to clear corrupt keyring value: {e}");
}
if legacy_path.exists() {
if let Some(keys) = migrate_identity_file(store, legacy_path, data_dir)? {
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
});
}
}
// No valid file to recover from. If the migration marker exists, a prior
// identity was stored in the keyring and is now corrupt AND gone — the key
// is unrecoverable. Enter Lost recovery instead of silently rotating.
if migration_marker_path(data_dir).exists() {
let ephemeral = Keys::generate();
eprintln!(
"buzz-desktop: identity lost — keyring had corrupt data and no valid identity.key \
backup; prior identity (migration marker present) is unrecoverable; \
using ephemeral key {}, awaiting user re-import",
ephemeral.public_key().to_hex()
);
return Ok(ResolvedIdentity {
keys: ephemeral,
recovery: RecoveryState::Lost,
});
}
// No marker: genuine first launch with a corrupt keyring. Generate fresh.
let keys = generate_and_persist(store, legacy_path, data_dir)?;
Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
})
}
/// Load the `0o600` identity file, quarantining corruption, else generate and
/// save a fresh key to the file. Used when no keyring is available.
fn load_file_or_generate(
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<Keys, String> {
if legacy_path.exists() {
match load_key_file(legacy_path) {
Ok(keys) => {
eprintln!(
"buzz-desktop: persisted identity pubkey {}",
keys.public_key().to_hex()
);
return Ok(keys);
}
Err(error) => quarantine_corrupt_key(legacy_path, data_dir, &error),
}
}
let keys = Keys::generate();
save_key_file(legacy_path, &keys)?;
eprintln!(
"buzz-desktop: generated and saved identity pubkey {}",
keys.public_key().to_hex()
);
Ok(keys)
}
/// Import the plaintext `identity.key` into the store, verify the round-trip,
/// then delete the file. Returns `Ok(None)` if the file was corrupt (caller
/// continues to generate-and-save).
fn migrate_identity_file(
store: &impl IdentityKeyStore,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<Option<Keys>, String> {
let keys = match load_key_file(legacy_path) {
Ok(keys) => keys,
Err(error) => {
eprintln!("buzz-desktop: corrupt identity.key during migration ({error}), skipping");
return Ok(None);
}
};
let nsec = keys
.secret_key()
.to_bech32()
.map_err(|e| format!("encode nsec: {e}"))?;
store.store(IDENTITY_KEY_NAME, &nsec)?;
// Read-back verify before deleting the plaintext file. Uses verify_stored()
// which bypasses the in-process cache and reads directly from the OS
// backend — proving the OS keyring round-trip, not just the cache.
let verify_ok = match store.verify_stored(IDENTITY_KEY_NAME, &nsec) {
Ok(b) => b,
Err(e) => return Err(format!("keyring read-back verify failed: {e}")),
};
if !verify_ok {
return Err("keyring read-back verify failed for identity key".to_string());
}
// Crash-safe ordering: record that the key now lives in the keyring
// (marker write + fsync) BEFORE deleting the file. A crash between
// the two must never leave "file gone, no marker" — that state is
// indistinguishable from a fresh install and would silently rotate
// the identity on the next keyring-unreachable boot. If the marker
// cannot be written, keep the file so the key is never stranded.
let marker_path = migration_marker_path(data_dir);
if let Err(e) = write_migration_marker(&marker_path) {
eprintln!(
"buzz-desktop: keyring import ok but failed to write migration marker ({e}); \
keeping identity.key so the key is not stranded"
);
return Ok(Some(keys));
}
if let Err(e) = std::fs::remove_file(legacy_path) {
eprintln!("buzz-desktop: keyring import ok but failed to delete identity.key: {e}");
} else {
eprintln!("buzz-desktop: migrated identity key into OS keyring");
}
Ok(Some(keys))
}
/// Persist `keys` into the keyring with read-back verification, write the
/// migration marker, and delete any leftover `identity.key`. Returns `Ok` on
/// success. Returns `Err` when the keyring write fails (availability error) —
/// the caller must fall back to `save_key_file` so the key survives the boot.
///
/// This is the shared kernel used by both one-time file migration and the
/// `import_identity` command. Crash-safe ordering: marker is written BEFORE
/// deleting the file.
fn persist_identity_to_keyring(
store: &impl IdentityKeyStore,
keys: &Keys,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), String> {
let nsec = keys
.secret_key()
.to_bech32()
.map_err(|e| format!("encode nsec: {e}"))?;
// Will error if the keyring is unavailable — caller falls back to the file.
store.store(IDENTITY_KEY_NAME, &nsec)?;
// Read-back verify before touching durable state. Uses verify_stored()
// which bypasses the in-process cache and reads directly from the OS
// backend — proving the OS keyring round-trip, not just the cache.
match store.verify_stored(IDENTITY_KEY_NAME, &nsec) {
Ok(true) => {}
Ok(false) => return Err("keyring read-back verify failed".to_string()),
Err(e) => return Err(format!("keyring read-back verify failed: {e}")),
}
// Write marker before deleting the file (crash-safe ordering).
let marker_path = migration_marker_path(data_dir);
if let Err(e) = write_migration_marker(&marker_path) {
// Keyring holds the key but no marker exists. Preserve the invariant
// "keyring-only implies marker exists" by ensuring identity.key is
// present as a fallback: write it if absent, leave it if already there.
// This prevents a later keyring-unreachable + no-marker boot from
// treating this as a fresh install and silently rotating identity.
if !legacy_path.exists() {
if let Err(write_err) = save_key_file(legacy_path, keys) {
eprintln!(
"buzz-desktop: keyring ok but marker write failed ({e}) and \
identity.key write also failed ({write_err}); key may be unrecoverable"
);
return Err(format!(
"keyring ok but neither migration marker nor identity.key fallback \
could be written (marker: {e}; file: {write_err}); \
identity must not be treated as durably persisted — retry the import"
));
} else {
eprintln!(
"buzz-desktop: keyring ok but marker write failed ({e}); \
wrote identity.key as fallback so the key is not stranded"
);
}
} else {
eprintln!(
"buzz-desktop: keyring ok but marker write failed ({e}); \
keeping existing identity.key so the key is not stranded"
);
}
return Ok(());
}
if legacy_path.exists() {
if let Err(e) = std::fs::remove_file(legacy_path) {
eprintln!("buzz-desktop: keyring write ok but failed to delete identity.key: {e}");
}
}
Ok(())
}
/// Core implementation of imported-identity persistence. Tries the OS keyring
/// first via [`persist_identity_to_keyring`]; if the keyring is unavailable,
/// falls back to the `0o600` identity.key file. Returns `Err` only when both
/// the keyring write and the file fallback fail.
fn persist_imported_identity_impl(
store: &impl IdentityKeyStore,
keys: &Keys,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), String> {
match persist_identity_to_keyring(store, keys, legacy_path, data_dir) {
Ok(()) => Ok(()),
Err(e) => {
eprintln!(
"buzz-desktop: keyring write failed during import ({e}), \
falling back to identity.key"
);
save_key_file(legacy_path, keys)
}
}
}
/// Public entry point binding [`persist_imported_identity_impl`] to the shared
/// [`crate::secret_store::SecretStore`]. See the impl for the persistence policy.
pub(crate) fn persist_imported_identity(
store: &crate::secret_store::SecretStore,
keys: &Keys,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), String> {
persist_imported_identity_impl(store, keys, legacy_path, data_dir)
}
/// Path of the migration-completed marker within `data_dir`.
fn migration_marker_path(data_dir: &std::path::Path) -> std::path::PathBuf {
data_dir.join(keyring_config::migration_marker_name(
keyring_service(),
MIGRATION_MARKER_NAME,
))
}
/// Atomically write (and fsync) the migration-completed marker. The content is
/// irrelevant — only the file's durable existence is the signal — so a single
/// byte keeps it minimal. Atomicity + fsync guarantee that once this returns
/// `Ok`, the marker survives a crash, which is what makes deleting the legacy
/// file afterward safe.
fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> {
use atomic_write_file::AtomicWriteFile;
let mut file = AtomicWriteFile::open(marker_path)
.map_err(|e| format!("open migration marker for atomic write: {e}"))?;
file.write_all(b"1")
.map_err(|e| format!("write migration marker: {e}"))?;
file.commit()
.map_err(|e| format!("commit migration marker: {e}"))
}
/// Which backend [`store_key_preferring_keyring`] wrote to. The caller writes
/// the migration marker only after a keyring success — on the file-fallback arm
/// the key is on disk and a marker would wrongly trip the next Unreachable boot
/// into failing closed.
enum PersistBackend {
Keyring,
File,
}
/// Generate a fresh identity, persist it through the store, return it.
///
/// On a keyring-backed persist no file is written, so a later
/// keyring-Unreachable boot would see "no file, no marker" (identical to a
/// fresh install) and silently rotate the identity. Writing the marker here
/// makes that boot fail closed. If the marker write fails, fall back to the
/// `0o600` file so the key is never keyring-only-without-marker.
fn generate_and_persist(
store: &impl IdentityKeyStore,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<Keys, String> {
let keys = Keys::generate();
if let PersistBackend::Keyring = store_key_preferring_keyring(store, &keys, legacy_path)? {
let marker_path = migration_marker_path(data_dir);
if let Err(e) = write_migration_marker(&marker_path) {
eprintln!(
"buzz-desktop: stored identity in keyring but failed to write migration marker \
({e}); saving identity.key fallback so the key is not stranded"
);
save_key_file(legacy_path, &keys)?;
}
}
eprintln!(
"buzz-desktop: generated and saved identity pubkey {}",
keys.public_key().to_hex()
);
Ok(keys)
}
/// Persist `keys` through the store, silently falling back to the `0o600` file
/// when the keyring write fails on an availability error. Reports which backend
/// held the key (no verify/marker/delete — those belong to callers that own the
/// full migration contract) so the caller can write the migration marker only on
/// keyring success.
fn store_key_preferring_keyring(
store: &impl IdentityKeyStore,
keys: &Keys,
legacy_path: &std::path::Path,
) -> Result<PersistBackend, String> {
let nsec = keys
.secret_key()
.to_bech32()
.map_err(|e| format!("encode nsec: {e}"))?;
match store.store(IDENTITY_KEY_NAME, &nsec) {
Ok(()) => Ok(PersistBackend::Keyring),
Err(keyring_err) => {
eprintln!("buzz-desktop: keyring write failed ({keyring_err}), using file fallback");
save_key_file(legacy_path, keys)?;
Ok(PersistBackend::File)
}
}
}
/// Ensure the migration marker exists (writing it if absent), then remove the
/// leftover `identity.key`. Crash-safe ordering: the marker is written and
/// fsync-committed before the file is deleted, so a crash between the two
/// leaves the marker on disk and the file intact — the invariant "keyring-only
/// implies marker exists" is preserved. If the marker write fails, the file is
/// kept so a later keyring-unreachable boot can use it as a fallback.
fn ensure_marker_then_cleanup(data_dir: &std::path::Path, legacy_path: &std::path::Path) {
let marker_path = migration_marker_path(data_dir);
let marker_ok = marker_path.exists()
|| write_migration_marker(&marker_path)
.map_err(|e| {
eprintln!(
"buzz-desktop: keyring present but marker missing; \
failed to write marker ({e}), keeping identity.key"
);
})
.is_ok();
if marker_ok {
cleanup_leftover_identity_file(legacy_path);
}
}
/// Best-effort removal of a leftover `identity.key` once the keyring is the
/// authoritative store. Idempotent: a missing file is success. Logs but does
/// not error on failure — a delete failure must never block startup.
fn cleanup_leftover_identity_file(legacy_path: &std::path::Path) {
if !legacy_path.exists() {
return;
}
match std::fs::remove_file(legacy_path) {
Ok(()) => eprintln!("buzz-desktop: removed leftover identity.key (key is in keyring)"),
Err(e) => eprintln!("buzz-desktop: failed to remove leftover identity.key: {e}"),
}
}
/// Quarantine a corrupt `identity.key` with a timestamp so prior backups are
/// never overwritten.
fn quarantine_corrupt_key(key_path: &std::path::Path, data_dir: &std::path::Path, error: &str) {
if !key_path.exists() {
return;
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let bad_name = format!("identity.key.bad.{ts}");
eprintln!("buzz-desktop: corrupt identity.key ({error}), quarantining to {bad_name}");
let bad_path = data_dir.join(bad_name);
if std::fs::rename(key_path, &bad_path).is_err() {
let _ = std::fs::remove_file(key_path);
}
}
fn load_key_file(path: &std::path::Path) -> Result<Keys, String> {
let content = std::fs::read_to_string(path).map_err(|e| format!("read identity.key: {e}"))?;
let trimmed = content.trim();
if trimmed.is_empty() {
return Err("empty identity.key".to_string());
}
Keys::parse(trimmed).map_err(|e| format!("parse identity.key: {e}"))
}
/// Atomically write the key to disk. Uses `atomic-write-file` which:
/// 1. Writes to a temp file in the same directory
/// 2. Calls fsync on the file
/// 3. Renames temp → target (atomic on POSIX, best-effort on Windows)
/// 4. Calls fsync on the parent directory
///
/// On Unix, the file is created with mode 0600 (owner read/write only).
/// On Windows, default ACLs apply — the app data directory is already
/// per-user, so the key is not world-readable in practice.
pub(crate) fn save_key_file(path: &std::path::Path, keys: &Keys) -> Result<(), String> {
use atomic_write_file::AtomicWriteFile;
let nsec = keys
.secret_key()
.to_bech32()
.map_err(|e| format!("encode nsec: {e}"))?;
let mut file = AtomicWriteFile::open(path)
.map_err(|e| format!("open identity.key for atomic write: {e}"))?;
// Set owner-only permissions before writing the secret.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("set identity.key permissions: {e}"))?;
}
file.write_all(nsec.as_bytes())
.map_err(|e| format!("write identity.key: {e}"))?;
file.commit()
.map_err(|e| format!("commit identity.key: {e}"))
}
#[cfg(test)]
#[path = "app_state_tests.rs"]
mod tests;