diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index b8eb57de..702c3455 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -947,7 +947,11 @@ async fn extract_rumor( let Some(device_signer) = device_signer else { return Err(error); }; - try_unwrap_with(device_signer, gift_wrap).await? + match try_unwrap_with(device_signer, gift_wrap).await { + Ok(unwrapped) => unwrapped, + // The wrap may be sealed to a key retired by a rotation + Err(_) => try_unwrap_archived(client, signer, gift_wrap).await?, + } } }; let mut rumor = unwrapped.rumor; @@ -1003,6 +1007,25 @@ async fn try_unwrap_with( }) } +/// Unwrap with an encryption key retired by a rotation; peers may still seal +/// to the previous key until their announcement cache refreshes +async fn try_unwrap_archived( + client: &Client, + signer: &UniversalSigner, + gift_wrap: &Event, +) -> Result { + for keys in DeviceRegistry::archived_keys(client, signer).await? { + let archived = UniversalSigner::new(keys); + if let Ok(unwrapped) = try_unwrap_with(&archived, gift_wrap).await { + return Ok(unwrapped); + } + } + + Err(anyhow!( + "No archived encryption key could unwrap the gift wrap" + )) +} + /// Stores an unwrapped event in local database with reference to original async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> { let room_id = rumor.uniq_id().to_string(); diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 42454a69..99006cbc 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -16,6 +16,7 @@ use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage}; const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet."; const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key yet. Set one up in the settings to message them."; +const KEY_STALE: &str = "Your encryption key is out of date; sync it from another device in the settings to keep messaging."; /// The outcome of delivering an encrypted rumor to a single receiver. #[derive(Debug, Clone)] @@ -553,6 +554,7 @@ impl Room { let config = self.config.clone(); let device = DeviceRegistry::global(cx); + let resyncing = device.read(cx).is_resyncing(); let encryption_signer = device.read(cx).signer(cx); let nostr = NostrRegistry::global(cx); @@ -574,6 +576,28 @@ impl Room { Some(cx.background_spawn(async move { let signer_kind = config.signer_kind(); + // Block sends while the local encryption key is stale against the + // current announcement; the replacement is being synced from + // another device + if resyncing && matches!(signer_kind, SignerKind::Auto | SignerKind::Encryption) { + let mut reports: Vec = receivers + .into_iter() + .map(|receiver| SendReport { + receiver: receiver.public_key(), + result: Err(KEY_STALE.into()), + }) + .collect(); + + if config.backup() { + reports.push(SendReport { + receiver: sender.public_key(), + result: Err(KEY_STALE.into()), + }); + } + + return reports; + } + // Deliver to every receiver concurrently let sends = receivers.into_iter().map(|receiver| { deliver( diff --git a/crates/nip4e/src/lib.rs b/crates/nip4e/src/lib.rs index 2aa90c2b..1209613f 100644 --- a/crates/nip4e/src/lib.rs +++ b/crates/nip4e/src/lib.rs @@ -1,9 +1,10 @@ use std::cell::Cell; -use std::collections::HashSet; +use std::collections::{HashSet, VecDeque}; use std::path::PathBuf; use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; use anyhow::{Context as AnyhowContext, Error, anyhow}; use gpui::{ @@ -16,12 +17,14 @@ use gpui_kit::component::notification::{Notification, NotificationType}; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use nostr_sdk::prelude::*; use person::Directory; -use settings::AppSettings; +use settings::{AppSettings, NIP4E_ROTATION_MAX_DAYS, NIP4E_ROTATION_MIN_DAYS}; use smallvec::{SmallVec, smallvec}; use state::{Announcement, NostrRegistry, UniversalSigner, credentials}; use ui::Avatar; -const IDENTIFIER: &str = "coop:device"; +mod storage; + +use storage::{SECONDS_PER_DAY, archive_key, get_keys, load_device_keys, set_keys}; pub fn init(cx: &mut App) { DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx); @@ -59,10 +62,18 @@ impl DeviceEvent { #[derive(Debug)] pub struct DeviceRegistry { signer: Entity>, - /// Whether there is a pending request for encryption key approval - pub(crate) pending_request: bool, + /// Ephemeral client key for the in-flight key request; never persisted + app_keys: Option, + /// The key request currently awaiting user approval on this device + pending_request: Option, + /// Handled key-sync request ids, bounded and persisted + processed_requests: VecDeque, /// Whether an announcement has been made for this device pub(crate) announcement_existed: Arc, + /// Whether the local key is stale against the current announcement. + resync: Arc, + /// Whether a key rotation is in flight, to coalesce overlapping requests + rotating: bool, /// Person directory for requesters shown in notifications directory: Entity, /// Relay notification listener @@ -108,6 +119,21 @@ impl DeviceRegistry { if event.signer_changed() && settings.read(cx).is_nip4e_enabled(cx) { // The old account's state must not leak into the new one this.announcement_existed.store(false, Ordering::Relaxed); + this.resync.store(false, Ordering::Relaxed); + // The in-flight request and client key belong to the old account + this.app_keys = None; + + if this.pending_request.take().is_some() { + // The old approval must not target the new account's signer + if let Some(window) = cx.windows().first().copied() { + window + .update(cx, |_view, window, cx| { + window.remove_notification::(cx); + }) + .ok(); + } + } + this.handle_notifications(cx); this.get_announcement(cx); } @@ -115,15 +141,19 @@ impl DeviceRegistry { ); cx.defer(move |cx| { - entity - .update(cx, |this, cx| this.handle_notifications(cx)) - .ok(); + let _ = entity.update(cx, |this, cx| { + this.handle_notifications(cx); + }); }); Self { signer, - pending_request: false, + app_keys: None, + pending_request: None, + processed_requests: VecDeque::new(), announcement_existed: Arc::new(AtomicBool::new(false)), + resync: Arc::new(AtomicBool::new(false)), + rotating: false, directory: Directory::new(cx), notification_listener: None, tasks: vec![], @@ -141,6 +171,8 @@ impl DeviceRegistry { let announcement_existed = self.announcement_existed.clone(); let (tx, rx) = flume::bounded::(100); + let read = credentials::read_processed_requests(cx); + self.notification_listener = Some(cx.background_spawn(async move { let mut notifications = client.notifications(); let mut processed_events = HashSet::new(); @@ -175,6 +207,21 @@ impl DeviceRegistry { })); self.tasks.push(cx.spawn(async move |this, cx| { + // Hydrate the processed-request log before routing any events + match read.await { + Ok(bytes) => { + let processed = bytes + .map(|bytes| parse_processed_requests(&bytes)) + .unwrap_or_default(); + + this.update(cx, |this, _cx| { + this.processed_requests = processed; + }) + .ok(); + } + Err(e) => log::error!("Failed to read processed key-sync requests: {e}"), + } + while let Ok(event) = rx.recv_async().await { match event.kind { Kind::Custom(10044) => { @@ -191,7 +238,7 @@ impl DeviceRegistry { // New response event from the master device Kind::Custom(4455) => { this.update(cx, |this, cx| { - this.extract_encryption(event, cx); + this.handle_transfer(event, cx); })?; } _ => {} @@ -201,12 +248,62 @@ impl DeviceRegistry { })); } - /// Set whether there is a pending request for encryption key approval - fn set_pending_request(&mut self, pending: bool, cx: &mut Context) { - self.pending_request = pending; + /// Record a handled key-sync request id, persisting a bounded log + fn mark_request_processed(&mut self, event_id: EventId, cx: &mut Context) { + if self.processed_requests.contains(&event_id) { + return; + } + self.processed_requests.push_back(event_id); + while self.processed_requests.len() > PROCESSED_REQUESTS_LIMIT { + self.processed_requests.pop_front(); + } + + let bytes: Vec = self + .processed_requests + .iter() + .flat_map(|id| id.to_hex().into_bytes()) + .collect(); + let write = credentials::write_processed_requests(cx, &bytes); + cx.background_spawn(async move { + if let Err(e) = write.await { + log::error!("Failed to persist processed key-sync requests: {e}"); + } + }) + .detach(); + } + + /// Withdraw the pending approval. + fn resolve_pending_request(&mut self, cx: &mut Context) { + let Some(pending) = self.pending_request.take() else { + return; + }; + self.mark_request_processed(pending.event.id, cx); + + if let Some(window) = cx.windows().first().copied() { + window + .update(cx, |_view, window, cx| { + window.remove_notification::(cx); + }) + .ok(); + } cx.notify(); } + /// Route a key transfer. + fn handle_transfer(&mut self, event: Event, cx: &mut Context) { + let resolved = self + .pending_request + .as_ref() + .is_some_and(|pending| transfer_addresses(&event, pending.client_key)); + + if resolved { + self.resolve_pending_request(cx); + return; + } + + self.extract_encryption(event, cx); + } + /// Get the signer pub fn signer(&self, cx: &App) -> Option { self.signer.read(cx).clone() @@ -218,9 +315,16 @@ impl DeviceRegistry { *this = Some(UniversalSigner::new(new_signer)); cx.notify(); }); + // The local key is now consistent with the announcement + self.resync.store(false, Ordering::Release); cx.emit(DeviceEvent::Set); } + /// Whether the local encryption key is stale against the current announcement. + pub fn is_resyncing(&self) -> bool { + self.resync.load(Ordering::Acquire) + } + /// Backup the encryption's secret key, returning the `nsec1...` content pub fn backup_bytes(&self, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); @@ -323,6 +427,88 @@ impl DeviceRegistry { })); } + /// Replace the encryption key with a new one, archiving the old key. + pub fn rotate(&mut self, cx: &mut Context) { + if self.rotating { + return; + } + self.rotating = true; + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result: Result = async { + // Archive the current key before replacing it + if let Ok(old) = get_keys(&client, &signer).await { + archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?; + } + + let created = this + .update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))? + .await?; + + this.update(cx, |this, cx| { + this.set_signer(created.clone(), cx); + this.wait_for_request(cx); + })?; + + Ok(created) + } + .await; + + this.update(cx, |this, cx| { + this.rotating = false; + if let Err(e) = result { + cx.emit(DeviceEvent::error(e.to_string())); + } + }) + .ok(); + + Ok(()) + })); + } + + /// Rotate the encryption key when the configured interval has elapsed. + /// + /// Runs after the announcement reconciled, outside the blocking boot path. + pub fn rotate_if_due(&mut self, cx: &mut Context) { + let Some(interval_days) = AppSettings::get_nip4e_rotation_days(cx) else { + return; + }; + let interval_days = interval_days.clamp(NIP4E_ROTATION_MIN_DAYS, NIP4E_ROTATION_MAX_DAYS); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + self.tasks.push(cx.spawn(async move |this, cx| { + let Ok(keys) = load_device_keys(&client, &signer).await else { + return Ok(()); + }; + + let elapsed = Timestamp::now() + .as_secs() + .saturating_sub(keys.created_at.as_secs()); + if elapsed < u64::from(interval_days) * SECONDS_PER_DAY { + return Ok(()); + } + + this.update(cx, |this, cx| this.rotate(cx)).ok(); + + Ok(()) + })); + } + + /// Load encryption keys retired by a rotation from the local database. + pub async fn archived_keys(client: &Client, signer: &S) -> Result, Error> + where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, + { + storage::archived_keys(client, signer).await + } + /// Create new encryption key and announce it to user's relay list fn create_encryption(&self, keys: Keys, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); @@ -331,6 +517,7 @@ impl DeviceRegistry { let secret = keys.secret_key().to_secret_hex(); let n = keys.public_key(); + let created_at = Timestamp::now(); cx.background_spawn(async move { // Construct an announcement event @@ -340,7 +527,7 @@ impl DeviceRegistry { .await?; // Persist the key locally before announcing it - set_keys(&client, &signer, &secret).await?; + set_keys(&client, &signer, &secret, created_at).await?; // Publish announcement client @@ -383,10 +570,12 @@ impl DeviceRegistry { this.update(cx, |this, cx| { this.set_signer(keys, cx); this.wait_for_request(cx); + this.rotate_if_due(cx); })?; } else { this.update(cx, |this, cx| { - this.request(cx); + this.resync.store(true, Ordering::Release); + this.request(Some(device_pubkey), cx); })?; } Ok(()) @@ -414,15 +603,17 @@ impl DeviceRegistry { } /// Request encryption keys from other device - pub(crate) fn request(&mut self, cx: &mut Context) { + pub(crate) fn request( + &mut self, + expected_encryption: Option, + cx: &mut Context, + ) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - - let app_keys_task = get_or_init_app_keys(cx); + let app_keys = self.app_keys.get_or_insert_with(Keys::generate).clone(); let task: Task, Error>> = cx.background_spawn(async move { - let app_keys = app_keys_task.await?; let app_pubkey = app_keys.public_key(); let public_key = signer.get_public_key_async().await?; @@ -434,8 +625,7 @@ impl DeviceRegistry { .limit(1); if let Some(event) = client.database().query(filter).await?.into_iter().next() { - // Only reuse the stored approval if its key is still valid, - // otherwise it's stale and a fresh request is needed + // Only reuse the stored approval if its key is still valid if decrypt_approval(&client, &app_keys, public_key, &event) .await .is_ok() @@ -444,9 +634,26 @@ impl DeviceRegistry { } } - // No usable approval event found, construct a request event + // No usable approval event found, construct a request event. + // + // The `pubkey` tag is a compatibility alias for the draft's `P`, + // `relay` hints tell other devices where the response is accepted, + // `n` names the exact key this device expects to be transferred + let mut tags = vec![ + Tag::custom("P", vec![app_pubkey]), + Tag::custom("pubkey", vec![app_pubkey]), + ]; + + for url in write_relay_hints(&client, public_key).await? { + tags.push(Tag::custom("relay", vec![url.to_string()])); + } + + if let Some(expected) = expected_encryption { + tags.push(Tag::custom("n", vec![expected])); + } + let event = EventBuilder::new(Kind::Custom(4454), "") - .tags(vec![Tag::custom("P", vec![app_pubkey])]) + .tags(tags) .finalize_async(&signer) .await?; @@ -483,19 +690,13 @@ impl DeviceRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - let app_keys_task = get_or_init_app_keys(cx); + + let Some(app_keys) = self.app_keys.clone() else { + log::warn!("Cannot wait for approval without a client key"); + return; + }; self.tasks.push(cx.spawn(async move |this, cx| { - let app_keys = match app_keys_task.await { - Ok(app_keys) => app_keys, - Err(e) => { - this.update(cx, |_this, cx| { - cx.emit(DeviceEvent::error(e.to_string())); - })?; - return Ok(()); - } - }; - this.update(cx, |_this, cx| { cx.emit(DeviceEvent::Requesting(authorization_code( &app_keys.public_key(), @@ -505,7 +706,7 @@ impl DeviceRegistry { let task: Task> = cx.background_spawn(async move { let public_key = signer.get_public_key_async().await?; - // Only approvals addressed to this device's app key matter + // Only approvals addressed to this device's client key matter let filter = Filter::new() .kind(Kind::Custom(4455)) .author(public_key) @@ -529,15 +730,18 @@ impl DeviceRegistry { /// Parse the approval event to get encryption key then set it fn extract_encryption(&mut self, event: Event, cx: &mut Context) { - let app_keys_task = get_or_init_app_keys(cx); let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); + let Some(app_keys) = self.app_keys.clone() else { + log::warn!("Ignoring key transfer addressed to another device"); + return; + }; + let approval_id = event.id; let task: Task> = cx.background_spawn(async move { - let app_keys = app_keys_task.await?; let identity = signer.get_public_key_async().await?; let keys = decrypt_approval(&client, &app_keys, identity, &event).await?; @@ -549,14 +753,14 @@ impl DeviceRegistry { Ok(keys) => { this.update(cx, |this, cx| { this.set_signer(keys, cx); + // The client key was consumed; start fresh on the next request + this.app_keys = None; // Clean up our request and the approval event from relays this.cleanup_sync_events(approval_id, cx); })?; } Err(e) => { - this.update(cx, |_this, cx| { - cx.emit(DeviceEvent::error(e.to_string())); - })?; + log::warn!("Ignoring key transfer: {e}"); } } Ok(()) @@ -610,40 +814,58 @@ impl DeviceRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - // Get user's write relays let event = event.clone(); + let request_id = event.id; let task: Task> = cx.background_spawn(async move { // Get device keys let keys = get_keys(&client, &signer).await?; let secret = keys.secret_key().to_secret_hex(); + let identity = signer.get_public_key_async().await?; // Extract the target public key from the event tags - let target = event - .tags - .iter() - .find(|tag| tag.kind() == "P") - .and_then(|tag| tag.content()) - .and_then(|content| PublicKey::parse(content).ok()) - .context("Target is not a valid public key")?; + let target = + client_pubkey_from_event(&event).context("Target is not a valid public key")?; - // Encrypt the device keys with the user's signer - let payload = keys.nip44_encrypt_async(&target, &secret).await?; + // The requester may pin the exact key it expects + if let Some(requested) = requested_encryption_key(&event) + && requested != keys.public_key() + { + return Err(anyhow!( + "The requesting device expects an encryption key this device no longer holds" + )); + } + + // Encrypt to a fresh one-time keypair so the transfer event + // can never be decrypted with the long-lived encryption key. + let sender = Keys::generate(); + let payload = sender.nip44_encrypt_async(&target, &secret).await?; // Construct the response event // - // P tag: the current device's public key - // p tag: the requester's public key - let event = EventBuilder::new(Kind::Custom(4455), payload) + // P tag: the one-time sender key used for the encryption + // p tag: the account identity, for inbox routing + // p tag: the requester's client key + let response = EventBuilder::new(Kind::Custom(4455), payload) .tags(vec![ - Tag::custom("P", vec![keys.public_key().to_hex()]), + Tag::custom("P", vec![sender.public_key().to_hex()]), + Tag::public_key(identity), Tag::public_key(target), ]) .finalize_async(&signer) .await?; - // Send the response event to the user's relay list - client.send_event(&event).to_nip65().await?; + let relays = merge_relays( + write_relay_hints(&client, identity).await?, + request_relay_hints(&event), + ); + + let send = client.send_event(&response); + if relays.is_empty() { + send.to_nip65().await?; + } else { + send.to(relays).await?; + } Ok(()) }); @@ -652,8 +874,11 @@ impl DeviceRegistry { match task.await { Ok(_) => { this.update(cx, |this, cx| { + // Record the request as handled so it cannot resurface + this.mark_request_processed(request_id, cx); // Allow future requests to surface again - this.set_pending_request(false, cx); + this.pending_request = None; + cx.notify(); })?; cx.update(|window, cx| { @@ -678,21 +903,70 @@ impl DeviceRegistry { /// Handle encryption request fn ask_for_approval(&mut self, event: Event, cx: &mut Context) { - // Ignore if there is already a pending request - if self.pending_request { + let Some(client_key) = client_pubkey_from_event(&event) else { + log::warn!("Ignoring invalid encryption key request: no valid requester key"); return; - } - self.set_pending_request(true, cx); + }; - let notification = match self.notification(event, cx) { + let read = credentials::read_processed_requests(cx); + cx.spawn(async move |this, cx| { + let processed = match read.await { + Ok(bytes) => bytes + .map(|bytes| parse_processed_requests(&bytes).contains(&event.id)) + .unwrap_or(false), + Err(e) => { + log::error!("Failed to read processed key-sync requests: {e}"); + false + } + }; + + this.update(cx, |this, cx| { + if processed || this.processed_requests.contains(&event.id) { + return; + } + + match this.pending_request.take() { + // A retry signs a new request id but keeps the same client key + Some(mut pending) if pending.client_key == client_key => { + this.mark_request_processed(event.id, cx); + pending.event = event; + this.pending_request = Some(pending); + } + + // A different client key is a remounted requester + other => { + if let Some(pending) = other { + this.mark_request_processed(pending.event.id, cx); + if let Some(window) = cx.windows().first().copied() { + window + .update(cx, |_view, window, cx| { + window.remove_notification::(cx); + }) + .ok(); + } + } + this.show_approval(event, client_key, cx); + } + } + }) + .ok(); + }) + .detach(); + } + + /// Build and surface the approval notification for a key request + fn show_approval(&mut self, event: Event, client_key: PublicKey, cx: &mut Context) { + let notification = match self.notification(event.clone(), cx) { Ok(notification) => notification, Err(e) => { log::warn!("Ignoring invalid encryption key request: {e}"); - self.set_pending_request(false, cx); return; } }; + self.pending_request = Some(PendingRequest { event, client_key }); + cx.notify(); + if let Some(window) = cx.windows().first().copied() { if let Err(error) = window.update(cx, |_view, window, cx| { window.push_notification(notification, cx); @@ -715,19 +989,17 @@ impl DeviceRegistry { compare the code with the one shown on the requesting \ device."; - let requester = event - .tags - .iter() - .find(|tag| tag.kind() == "P") - .and_then(|tag| tag.content()) - .and_then(|content| PublicKey::parse(content).ok()) - .context("Request has no valid requester key")?; + let requester = + client_pubkey_from_event(&event).context("Request has no valid requester key")?; let code = authorization_code(&requester); - let directory = self.directory.clone(); - directory.update(cx, |directory, cx| directory.track([requester], cx)); let code_label = code.clone(); + let directory = self.directory.clone(); + directory.update(cx, |directory, cx| { + directory.track([requester], cx); + }); + let entity = cx.entity().downgrade(); let loading = Rc::new(Cell::new(false)); @@ -831,6 +1103,111 @@ impl DeviceRegistry { struct DeviceNotification; +/// A key request awaiting user approval on this device +#[derive(Debug)] +struct PendingRequest { + event: Event, + client_key: PublicKey, +} + +/// Keep the durable processed-request log bounded +const PROCESSED_REQUESTS_LIMIT: usize = 256; + +/// Parse concatenated hex event ids +fn parse_processed_requests(bytes: &[u8]) -> VecDeque { + let Ok(hex) = std::str::from_utf8(bytes) else { + return VecDeque::new(); + }; + + hex.as_bytes() + .as_chunks::<64>() + .0 + .iter() + .filter_map(|chunk| EventId::from_hex(std::str::from_utf8(chunk).ok()?).ok()) + .collect() +} + +/// Extract the client public key from an event. +fn client_pubkey_from_event(event: &Event) -> Option { + let key = event + .tags + .iter() + .find(|tag| tag.kind() == "P") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()); + + key.or_else(|| { + event + .tags + .iter() + .find(|tag| tag.kind() == "pubkey") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) + }) +} + +/// Whether the transfer event is p-tagged to the given client key +fn transfer_addresses(event: &Event, client_key: PublicKey) -> bool { + event.tags.iter().any(|tag| { + tag.kind() == "p" + && tag + .content() + .and_then(|content| PublicKey::parse(content).ok()) + .is_some_and(|key| key == client_key) + }) +} + +/// Parse the `relay` hints attached to a key request. +fn request_relay_hints(event: &Event) -> Vec { + event + .tags + .iter() + .filter(|tag| tag.kind() == "relay") + .filter_map(|tag| { + tag.content() + .and_then(|content| RelayUrl::parse(content).ok()) + }) + .collect() +} + +/// Parse the optional `n` tag of a key request. +fn requested_encryption_key(event: &Event) -> Option { + event + .tags + .iter() + .find(|tag| tag.kind() == "n") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) +} + +/// Collect the user's NIP-65 write relays. +async fn write_relay_hints(client: &Client, identity: PublicKey) -> Result, Error> { + let filter = Filter::new() + .kind(Kind::RelayList) + .author(identity) + .limit(1); + + let Some(event) = client.database().query(filter).await?.into_iter().next() else { + return Ok(vec![]); + }; + + // Unmarked relays serve both reads and writes + Ok(nip65::extract_relay_list(&event) + .filter(|(_, metadata)| *metadata != Some(RelayMetadata::Read)) + .map(|(url, _)| url) + .collect()) +} + +/// Merge relay lists, dropping duplicates +fn merge_relays(first: Vec, second: Vec) -> Vec { + first + .into_iter() + .chain(second) + .collect::>() + .into_iter() + .collect() +} + /// The first eight hexadecimal characters of the client public key. fn authorization_code(public_key: &PublicKey) -> SharedString { let hex = public_key.to_hex(); @@ -838,35 +1215,6 @@ fn authorization_code(public_key: &PublicKey) -> SharedString { SharedString::from(format!("{} {}", &code[..4], &code[4..])) } -/// Get or create new app keys (async, returns a task) -fn get_or_init_app_keys(cx: &App) -> Task> { - let read = credentials::read_app_keys(cx); - - cx.spawn(async move |cx| { - if let Ok(Some(secret)) = read.await - && let Ok(keys) = SecretKey::from_slice(&secret).map(Keys::new) - { - return Ok(keys); - } - - // No stored keys found or invalid — generate new ones - let keys = Keys::generate(); - let secret = keys.secret_key().to_secret_bytes(); - - cx.update(|cx| { - let write = credentials::write_app_keys(cx, &secret); - cx.background_spawn(async move { - if let Err(e) = write.await { - log::error!("Failed to persist app keys: {e}") - } - }) - .detach(); - }); - - Ok(keys) - }) -} - /// Decrypt and validate an approval event against the current announcement. async fn decrypt_approval( client: &Client, @@ -874,13 +1222,7 @@ async fn decrypt_approval( identity: PublicKey, event: &Event, ) -> Result { - let master = event - .tags - .iter() - .find(|tag| tag.kind() == "P") - .and_then(|tag| tag.content()) - .and_then(|content| PublicKey::parse(content).ok()) - .context("Invalid event's tags")?; + let master = client_pubkey_from_event(event).context("Invalid event's tags")?; let decrypted = app_keys .nip44_decrypt_async(&master, event.content.as_str()) @@ -889,71 +1231,52 @@ async fn decrypt_approval( let secret = SecretKey::from_hex(&decrypted)?; let keys = Keys::new(secret); - // Reject approvals carrying a key that no longer matches the published announcement. + // Reject approvals carrying a key that no longer matches the published announcement let filter = Filter::new() .kind(Kind::Custom(10044)) .author(identity) .limit(1); - if let Some(announcement) = client.database().query(filter).await?.into_iter().next() { - let announced = announcement - .tags - .iter() - .find(|tag| tag.kind() == "n") - .and_then(|tag| tag.content()) - .and_then(|content| PublicKey::parse(content).ok()) - .context("Announcement has no valid encryption key")?; + let announcement = client + .database() + .query(filter.clone()) + .await? + .into_iter() + .next(); - if keys.public_key() != announced { - return Err(anyhow!("Approval key doesn't match the announcement")); + let announcement = match announcement { + Some(announcement) => Some(announcement), + None => { + let mut stream = client + .stream_events(filter) + .timeout(Duration::from_secs(3)) + .await?; + + stream + .next() + .await + .map(|(_url, result)| result) + .transpose()? } + }; + + let Some(announcement) = announcement else { + return Err(anyhow!( + "No encryption announcement available to validate the approval" + )); + }; + + let announced = announcement + .tags + .iter() + .find(|tag| tag.kind() == "n") + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) + .context("Announcement has no valid encryption key")?; + + if keys.public_key() != announced { + return Err(anyhow!("Approval key doesn't match the announcement")); } Ok(keys) } - -/// Encrypt and store device keys in the local database. -async fn set_keys(client: &Client, signer: &S, secret: &str) -> Result<(), Error> -where - S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, -{ - let public_key = signer.get_public_key_async().await?; - let content = signer.nip44_encrypt_async(&public_key, secret).await?; - - // Construct the application data event - let event = EventBuilder::new(Kind::ApplicationSpecificData, content) - .tag(Tag::identifier(IDENTIFIER)) - .finalize_async(signer) - .await?; - - // Save the event to the database - client.database().save_event(&event).await?; - - Ok(()) -} - -/// Get device keys from the local database. -async fn get_keys(client: &Client, signer: &S) -> Result -where - S: AsyncGetPublicKey + AsyncNip44 + ?Sized, -{ - let public_key = signer.get_public_key_async().await?; - - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(IDENTIFIER) - .author(public_key); - - if let Some(event) = client.database().query(filter).await?.first() { - let content = signer - .nip44_decrypt_async(&public_key, &event.content) - .await?; - - let secret = SecretKey::parse(&content)?; - let keys = Keys::new(secret); - - Ok(keys) - } else { - Err(anyhow!("Key not found")) - } -} diff --git a/crates/nip4e/src/storage.rs b/crates/nip4e/src/storage.rs new file mode 100644 index 00000000..194f0d62 --- /dev/null +++ b/crates/nip4e/src/storage.rs @@ -0,0 +1,174 @@ +use anyhow::{Error, anyhow}; +use nostr_sdk::prelude::*; + +/// The storage identifier for the device encryption key +const IDENTIFIER: &str = "coop:device"; +/// The storage identifier for encryption keys retired by a rotation +const ARCHIVE_IDENTIFIER: &str = "coop:device-archive"; +/// Keep the archived key list bounded +const ARCHIVE_LIMIT: usize = 10; +/// Seconds in a day, for rotation intervals +pub(crate) const SECONDS_PER_DAY: u64 = 24 * 60 * 60; + +/// Device encryption key material with its creation time +#[derive(Debug, Clone)] +pub(crate) struct DeviceKeys { + pub(crate) keys: Keys, + pub(crate) created_at: Timestamp, +} + +/// Encode the stored key content: secret hex and creation time +fn encode_keys_content(secret: &str, created_at: &Timestamp) -> String { + format!("{secret}\n{}", created_at.as_secs()) +} + +/// Parse stored key content. +fn parse_keys_content(content: &str) -> Result<(SecretKey, Timestamp), Error> { + match content.split_once('\n') { + Some((secret, created_at)) => { + let created_at = created_at + .trim() + .parse::() + .unwrap_or_else(|_| Timestamp::now().as_secs()); + Ok(( + SecretKey::from_hex(secret)?, + Timestamp::from_secs(created_at), + )) + } + None => Ok((SecretKey::from_hex(content)?, Timestamp::now())), + } +} + +/// Parse archived secrets from stored content, one hex secret per line +fn parse_archived_keys(content: &str) -> Vec { + content + .lines() + .filter_map(|line| SecretKey::from_hex(line.trim()).ok()) + .map(Keys::new) + .collect() +} + +/// Load the device keys with their creation time from the local database. +pub(crate) async fn load_device_keys(client: &Client, signer: &S) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ + let public_key = signer.get_public_key_async().await?; + + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(IDENTIFIER) + .author(public_key); + + let Some(event) = client.database().query(filter).await?.into_iter().next() else { + return Err(anyhow!("Key not found")); + }; + + let content = signer + .nip44_decrypt_async(&public_key, &event.content) + .await?; + let (secret, created_at) = parse_keys_content(&content)?; + + Ok(DeviceKeys { + keys: Keys::new(secret), + created_at, + }) +} + +/// Encrypt and store device keys in the local database. +pub(crate) async fn set_keys( + client: &Client, + signer: &S, + secret: &str, + created_at: Timestamp, +) -> Result<(), Error> +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ + let public_key = signer.get_public_key_async().await?; + let content = signer + .nip44_encrypt_async(&public_key, &encode_keys_content(secret, &created_at)) + .await?; + + // Construct the application data event + let event = EventBuilder::new(Kind::ApplicationSpecificData, content) + .tag(Tag::identifier(IDENTIFIER)) + .finalize_async(signer) + .await?; + + // Save the event to the database + client.database().save_event(&event).await?; + + Ok(()) +} + +/// Append an encryption secret to the bounded archive of retired keys. +pub(crate) async fn archive_key(client: &Client, signer: &S, secret: &str) -> Result<(), Error> +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ + let public_key = signer.get_public_key_async().await?; + + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(ARCHIVE_IDENTIFIER) + .author(public_key); + + let mut archived: Vec = match client.database().query(filter).await?.into_iter().next() + { + Some(event) => { + let content = signer + .nip44_decrypt_async(&public_key, &event.content) + .await?; + content.lines().map(str::to_string).collect() + } + None => vec![], + }; + + archived.insert(0, secret.to_string()); + archived.truncate(ARCHIVE_LIMIT); + + let content = signer + .nip44_encrypt_async(&public_key, &archived.join("\n")) + .await?; + + let event = EventBuilder::new(Kind::ApplicationSpecificData, content) + .tag(Tag::identifier(ARCHIVE_IDENTIFIER)) + .finalize_async(signer) + .await?; + + client.database().save_event(&event).await?; + + Ok(()) +} + +/// Get device keys from the local database. +pub(crate) async fn get_keys(client: &Client, signer: &S) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ + Ok(load_device_keys(client, signer).await?.keys) +} + +/// Load encryption keys retired by a rotation from the local database. +pub(crate) async fn archived_keys(client: &Client, signer: &S) -> Result, Error> +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ + let public_key = signer.get_public_key_async().await?; + + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(ARCHIVE_IDENTIFIER) + .author(public_key); + + let Some(event) = client.database().query(filter).await?.into_iter().next() else { + return Ok(vec![]); + }; + + let content = signer + .nip44_decrypt_async(&public_key, &event.content) + .await?; + + Ok(parse_archived_keys(&content)) +} diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 39a04cc5..1bf0d447 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -54,7 +54,7 @@ impl EventEmitter for PersonRegistry {} #[derive(Debug, Clone)] enum Dispatch { - Person(Person), + Person(Box), Announcement(Event), Relays(Event), Emojis(Event), @@ -111,7 +111,7 @@ impl PersonRegistry { this.update(cx, |this, cx| { match event { Dispatch::Person(person) => { - this.insert(person, cx); + this.insert(*person, cx); } Dispatch::Announcement(event) => { this.set_announcement(&event, cx); @@ -175,7 +175,11 @@ impl PersonRegistry { let metadata = Metadata::from_json(&event.content).unwrap_or_default(); let person = Person::new(event.pubkey, metadata); - if tx.send_async(Dispatch::Person(person)).await.is_err() { + if tx + .send_async(Dispatch::Person(Box::new(person))) + .await + .is_err() + { log::warn!("Channel closed, dropping metadata event"); } } diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index 1cee8030..f5e9de77 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -144,6 +144,13 @@ impl Person { /// Set profile encryption keys announcement pub(crate) fn set_announcement(&mut self, announcement: Announcement) { + // A delayed or repeated announcement must not roll the key back + if let Some(current) = &self.announcement + && !announcement.is_newer_than(current) + { + return; + } + self.announcement = Some(announcement); } diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 8e16d781..714add2c 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -100,7 +100,12 @@ pub fn persisted_theme() -> ThemeSettings { const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; const LEGACY_FILE_SERVER: &str = "blossom.band"; - +/// Minimum days between automatic encryption key rotations +pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1; +/// Maximum days between automatic encryption key rotations +pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90; +/// Days between automatic encryption key rotations when enabled +pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30; /// Built-in image shown behind chat and group panels pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg"; @@ -147,6 +152,7 @@ setting_accessors! { pub screening: bool, pub wot_auto_trust: bool, pub nip4e: bool, + pub nip4e_rotation_days: Option, pub hide_group_notice: bool, pub file_server: Url, pub panel_background: bool, @@ -222,6 +228,9 @@ pub struct Settings { pub wot_auto_trust: bool, /// Enable decoupling encryption key pub nip4e: bool, + /// Days between automatic encryption key rotations; None disables rotation + #[serde(default)] + pub nip4e_rotation_days: Option, /// Hide the notice that messages in public groups are not encrypted #[serde(default)] pub hide_group_notice: bool, @@ -254,6 +263,7 @@ impl Default for Settings { screening: true, wot_auto_trust: true, nip4e: false, + nip4e_rotation_days: None, hide_group_notice: false, theme: ThemeSettings::default(), panel_background: true, diff --git a/crates/state/src/credentials.rs b/crates/state/src/credentials.rs index d9fade1f..8bf332a6 100644 --- a/crates/state/src/credentials.rs +++ b/crates/state/src/credentials.rs @@ -5,8 +5,8 @@ use gpui::{App, AppContext as _, Task}; use crate::USER_KEYRING; -/// The storage key for the NIP-4e app keys -const APP_KEYS_KEY: &str = "coop/nip4e/app-keys"; +/// The storage key for the log of handled NIP-4e key-sync request ids +const PROCESSED_REQUESTS_KEY: &str = "coop/nip4e/processed-requests"; /// Read the stored credential as `(username, secret)` #[allow(clippy::type_complexity)] @@ -94,12 +94,12 @@ fn write_named(cx: &App, key: &str, secret: &[u8]) -> Task> { } } -/// Load the NIP-4e app keys from storage, as raw secret bytes -pub fn read_app_keys(cx: &App) -> Task>>> { - read_named(cx, APP_KEYS_KEY) +/// Read the log of handled key-sync request ids, as concatenated hex ids +pub fn read_processed_requests(cx: &App) -> Task>>> { + read_named(cx, PROCESSED_REQUESTS_KEY) } -/// Persist the NIP-4e app keys, as raw secret bytes -pub fn write_app_keys(cx: &App, secret: &[u8]) -> Task> { - write_named(cx, APP_KEYS_KEY, secret) +/// Persist the log of handled key-sync request ids, as concatenated hex ids +pub fn write_processed_requests(cx: &App, ids: &[u8]) -> Task> { + write_named(cx, PROCESSED_REQUESTS_KEY, ids) } diff --git a/crates/state/src/nip4e.rs b/crates/state/src/nip4e.rs index e0d53583..6c0a0b65 100644 --- a/crates/state/src/nip4e.rs +++ b/crates/state/src/nip4e.rs @@ -5,6 +5,10 @@ use nostr_sdk::prelude::*; pub struct Announcement { /// The public key of the device that created this announcement. public_key: PublicKey, + /// The id of the announcement event, for ordering + event_id: EventId, + /// The creation time of the announcement event + created_at: Timestamp, } impl Announcement { @@ -16,11 +20,20 @@ impl Announcement { .and_then(|tag| tag.content()) .and_then(|c| PublicKey::parse(c).ok())?; - Some(Self { public_key }) + Some(Self { + public_key, + event_id: event.id, + created_at: event.created_at, + }) } /// Returns the public key of the device that created this announcement. pub fn public_key(&self) -> PublicKey { self.public_key } + + pub fn is_newer_than(&self, other: &Self) -> bool { + self.created_at > other.created_at + || (self.created_at == other.created_at && self.event_id < other.event_id) + } } diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs index 53a79811..4800a987 100644 --- a/crates/workspace/src/dialogs/settings.rs +++ b/crates/workspace/src/dialogs/settings.rs @@ -22,7 +22,7 @@ use gpui_kit::component::progress::Progress; use gpui_kit::component::separator::Separator; use gpui_kit::component::switch::Switch; use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex}; -use settings::{AppSettings, AppearanceMode}; +use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS}; #[cfg(not(target_arch = "wasm32"))] use speech::model::{self as speech_model}; use ui::{settings_block, settings_row}; @@ -337,6 +337,7 @@ impl Render for Preferences { "Corner radius for large elements like dialogs. Empty follows the OS theme."; const FOCUS_RING: &str = "Draw a ring around focused controls."; const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages."; + const NIP4E_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it."; const BACKGROUND: &str = "Show an image behind chat and group panels."; const MEDIA: &str = "Server for media attachments. Only blossom services are supported."; @@ -344,6 +345,7 @@ impl Render for Preferences { let wot_auto_trust = AppSettings::get_wot_auto_trust(cx); let hide_avatar = AppSettings::get_hide_avatar(cx); let nip4e = AppSettings::get_nip4e(cx); + let nip4e_rotation_days = AppSettings::get_nip4e_rotation_days(cx); let appearance_mode = AppSettings::get_appearance_mode(cx); let theme = AppSettings::get_theme(cx); let panel_background = AppSettings::get_panel_background(cx); @@ -355,7 +357,7 @@ impl Render for Preferences { }; v_flex() - .mt_2() + .my_2() .gap_4() .w_full() .child( @@ -507,6 +509,22 @@ impl Render for Preferences { }), cx, )) + .child(settings_row( + "Rotate Encryption Key", + NIP4E_ROTATION, + Switch::new("nip4e_rotation") + .checked(nip4e_rotation_days.is_some()) + .on_click(move |_, _window, cx| { + let enabled = AppSettings::get_nip4e_rotation_days(cx).is_some(); + let days = if enabled { + None + } else { + Some(NIP4E_ROTATION_DEFAULT_DAYS) + }; + AppSettings::update_nip4e_rotation_days(days, cx); + }), + cx, + )) .child(Separator::horizontal()) .child(self.render_speech(cx)) .child(self.render_dictation_shortcut(window, cx)) diff --git a/crates/workspace/src/sidebar/header.rs b/crates/workspace/src/sidebar/header.rs index 470d1c81..d90fd917 100644 --- a/crates/workspace/src/sidebar/header.rs +++ b/crates/workspace/src/sidebar/header.rs @@ -1,18 +1,25 @@ #[cfg(not(target_arch = "wasm32"))] use auto_update::AutoUpdater; use gpui::prelude::FluentBuilder; +use gpui::{ + Anchor, App, Context, Div, InteractiveElement, ParentElement, SharedString, Stateful, Styled, + Window, div, px, +}; #[cfg(not(target_arch = "wasm32"))] use gpui::{AnyElement, Entity, IntoElement}; -use gpui::{ - App, Context, Div, InteractiveElement, ParentElement, Stateful, Styled, Window, div, px, -}; +use gpui_kit::base::StyledExt as _; #[cfg(not(target_arch = "wasm32"))] use gpui_kit::component::Disableable; use gpui_kit::component::button::{Button, ButtonVariant, ButtonVariants}; +use gpui_kit::component::clipboard::Clipboard; use gpui_kit::component::dialog::DialogButtonProps; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; +use gpui_kit::component::popover::Popover; +use gpui_kit::component::separator::Separator; use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex, v_flex}; use nostr_sdk::prelude::*; +use person::shorten_pubkey; +use settings::AppSettings; use state::{CLIENT_NAME, NostrRegistry}; use ui::{Avatar, IconName}; @@ -31,6 +38,10 @@ impl Sidebar { let updater = AutoUpdater::try_global(cx); let profile = self.directory.read(cx).person(current_user, cx); let avatar = profile.avatar(); + let encryption_key = profile + .announcement() + .map(|announcement| announcement.public_key()); + let encryption_enabled = AppSettings::get_nip4e(cx); h_flex() .id("sidebar-user") @@ -71,6 +82,11 @@ impl Sidebar { }); actions + .when(encryption_enabled, |actions| { + actions.when_some(encryption_key, |actions, encryption_key| { + actions.child(self.render_encryption(encryption_key)) + }) + }) .child( Button::new("sidebar-search") .icon(IconName::Search) @@ -111,6 +127,89 @@ impl Sidebar { }) } + /// The encryption key popover: public key with copy, backup and reset + fn render_encryption(&self, encryption_key: PublicKey) -> impl IntoElement { + Popover::new("sidebar-encryption") + .anchor(Anchor::TopRight) + .trigger( + Button::new("encryption-key") + .icon(IconName::Lock) + .tooltip("Encryption Key") + .ghost() + .small(), + ) + .content(move |_, _, cx| { + let popover = cx.entity(); + let Ok(npub) = encryption_key.to_bech32(); + + v_flex() + .w_64() + .gap_2() + .text_sm() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Public key"), + ) + .child( + h_flex() + .items_center() + .gap_1() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(shorten_pubkey(encryption_key, 12))), + ) + .child( + Clipboard::new("encryption-key-copy") + .tooltip("Copy public key") + .value(npub), + ), + ) + .child(Separator::horizontal()) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("encryption-backup") + .label("Backup") + .outline() + .small() + .on_click({ + let popover = popover.clone(); + move |_, window, cx| { + popover + .update(cx, |state, cx| state.dismiss(window, cx)); + window.dispatch_action( + Box::new(Command::BackupEncryption), + cx, + ); + } + }), + ) + .child( + Button::new("encryption-reset") + .label("Reset") + .ghost() + .small() + .on_click(move |_, window, cx| { + popover.update(cx, |state, cx| state.dismiss(window, cx)); + window.dispatch_action( + Box::new(Command::ResetEncryption), + cx, + ); + }), + ), + ) + }) + } + pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context) -> Div { v_flex() .p_2() diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 83b21fe5..5fc32c41 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -21,6 +21,7 @@ use nostr_sdk::prelude::*; use person::{Directory, PersonRegistry}; pub(crate) use rows::open_room; use search::SearchResult; +use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::{FIND_DELAY, NostrRegistry, StateEvent}; use ui::title_bar_drag_handlers; @@ -104,6 +105,8 @@ impl Sidebar { let chat = ChatRegistry::global(cx); let groups = GroupsRegistry::global(cx); let nostr = NostrRegistry::global(cx); + let settings = AppSettings::global(cx); + let directory = Directory::new(cx); let find_input = cx.new(|cx| { InputState::new(window, cx) @@ -206,6 +209,11 @@ impl Sidebar { }, )); + // The user header reads settings and the tracked profiles on render, so + // the encryption button reacts to toggles and announcement arrivals + subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify())); + subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify())); + tasks.push(cx.spawn(async move |this, cx| { let timer = Duration::from_millis(PROGRESS_TICK_MS); loop { @@ -229,7 +237,7 @@ impl Sidebar { chats_open: None, contacts_open: false, contacts: None, - directory: Directory::new(cx), + directory, selected_contacts: HashSet::new(), requests_open: false, new_requests: false,