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 0541747d..1209613f 100644 --- a/crates/nip4e/src/lib.rs +++ b/crates/nip4e/src/lib.rs @@ -17,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); @@ -60,7 +62,7 @@ impl DeviceEvent { #[derive(Debug)] pub struct DeviceRegistry { signer: Entity>, - /// Ephemeral client key for the in-flight key request. never persisted + /// 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, @@ -68,6 +70,10 @@ pub struct DeviceRegistry { 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 @@ -113,6 +119,7 @@ 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; @@ -145,6 +152,8 @@ impl DeviceRegistry { 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![], @@ -306,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); @@ -411,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); @@ -419,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 @@ -428,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 @@ -471,9 +570,11 @@ 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.resync.store(true, Ordering::Release); this.request(Some(device_pubkey), cx); })?; } @@ -1179,220 +1280,3 @@ async fn decrypt_approval( 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")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Compose a minimal event whose validity doesn't matter for the helpers - fn event_with_tags(kind: Kind, tags: Vec) -> Event { - let keys = Keys::generate(); - let public_key = keys.public_key(); - let created_at = Timestamp::now(); - let tags = Tags::from_list(tags); - let id = EventId::compute(&public_key, &created_at, &kind, &tags, ""); - - Event::new( - id, - public_key, - created_at, - kind, - tags, - "", - Signature::from_slice(&[0u8; 64]).expect("signature should parse"), - ) - } - - #[test] - fn parse_processed_requests_roundtrip() { - let ids: Vec = (0u8..4) - .map(|i| { - let mut bytes = [0u8; 32]; - bytes[0] = i; - EventId::from_byte_array(bytes) - }) - .collect(); - - let bytes: Vec = ids.iter().flat_map(|id| id.to_hex().into_bytes()).collect(); - let parsed = parse_processed_requests(&bytes); - - assert_eq!(parsed.len(), 4); - assert!(ids.iter().all(|id| parsed.contains(id))); - } - - #[test] - fn parse_processed_requests_ignores_garbage() { - assert!(parse_processed_requests(&[]).is_empty()); - assert!(parse_processed_requests(b"not hex at all").is_empty()); - assert!(parse_processed_requests(&[b'a'; 63]).is_empty()); - } - - #[test] - fn client_pubkey_from_event_reads_p_tag() { - let client_key = Keys::generate().public_key(); - let event = event_with_tags( - Kind::Custom(4454), - vec![Tag::custom("P", vec![client_key.to_hex()])], - ); - - assert_eq!(client_pubkey_from_event(&event), Some(client_key)); - } - - #[test] - fn client_pubkey_from_event_rejects_missing_or_invalid() { - let event = event_with_tags(Kind::Custom(4454), vec![]); - assert_eq!(client_pubkey_from_event(&event), None); - - let event = event_with_tags(Kind::Custom(4454), vec![Tag::custom("P", vec!["nope"])]); - assert_eq!(client_pubkey_from_event(&event), None); - } - - #[test] - fn client_pubkey_from_event_falls_back_to_alias() { - let client_key = Keys::generate().public_key(); - - let event = event_with_tags( - Kind::Custom(4454), - vec![Tag::custom("pubkey", vec![client_key.to_hex()])], - ); - - assert_eq!(client_pubkey_from_event(&event), Some(client_key)); - } - - #[test] - fn client_pubkey_from_event_prefers_p_tag() { - let client_key = Keys::generate().public_key(); - let alias = Keys::generate().public_key(); - - let event = event_with_tags( - Kind::Custom(4454), - vec![ - Tag::custom("P", vec![client_key.to_hex()]), - Tag::custom("pubkey", vec![alias.to_hex()]), - ], - ); - - assert_eq!(client_pubkey_from_event(&event), Some(client_key)); - } - - #[test] - fn transfer_addresses_matches_client_key_p_tag() { - let client_key = Keys::generate().public_key(); - let other = Keys::generate().public_key(); - let identity = Keys::generate().public_key(); - - let event = event_with_tags( - Kind::Custom(4455), - vec![ - Tag::custom("P", vec![other.to_hex()]), - Tag::public_key(client_key), - ], - ); - - assert!(transfer_addresses(&event, client_key)); - assert!(!transfer_addresses(&event, other)); - assert!(!transfer_addresses(&event, identity)); - } - - #[test] - fn transfer_addresses_ignores_uppercase_p_tag() { - let client_key = Keys::generate().public_key(); - - let event = event_with_tags( - Kind::Custom(4455), - vec![Tag::custom("P", vec![client_key.to_hex()])], - ); - - assert!(!transfer_addresses(&event, client_key)); - } - - #[test] - fn request_relay_hints_parses_and_drops_invalid() { - let valid = RelayUrl::parse("wss://relay.example.com").expect("relay should parse"); - - let event = event_with_tags( - Kind::Custom(4454), - vec![ - Tag::custom("relay", vec!["wss://relay.example.com"]), - Tag::custom("relay", vec!["not a relay"]), - Tag::custom("relay", vec!["wss://other.example.com"]), - ], - ); - - let hints = request_relay_hints(&event); - assert_eq!(hints.len(), 2); - assert_eq!(hints[0], valid); - } - - #[test] - fn requested_encryption_key_reads_n_tag() { - let encryption_key = Keys::generate().public_key(); - - let event = event_with_tags( - Kind::Custom(4454), - vec![Tag::custom("n", vec![encryption_key.to_hex()])], - ); - assert_eq!(requested_encryption_key(&event), Some(encryption_key)); - - let event = event_with_tags(Kind::Custom(4454), vec![]); - assert_eq!(requested_encryption_key(&event), None); - } - - #[test] - fn merge_relays_deduplicates() { - let a = RelayUrl::parse("wss://a.example.com").expect("relay should parse"); - let b = RelayUrl::parse("wss://b.example.com").expect("relay should parse"); - - let merged = merge_relays(vec![a.clone(), b.clone()], vec![a.clone()]); - - assert_eq!(merged.len(), 2); - assert!(merged.contains(&a)); - assert!(merged.contains(&b)); - } -} 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/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/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs index 645c88e1..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); @@ -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/docs/nip4e-psstpsst-comparison.md b/docs/nip4e-psstpsst-comparison.md deleted file mode 100644 index 8fd3b6ca..00000000 --- a/docs/nip4e-psstpsst-comparison.md +++ /dev/null @@ -1,134 +0,0 @@ -# NIP-4e: psstpsst implementation comparison - -A comparison of Coop's NIP-4e implementation (`crates/nip4e`) against the -[psstpsst](https://github.com/CodyTseng/psstpsst) client, which implements the -draft [NIP-4e (per-device keys)](https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md) -with a documented [client profile](https://github.com/CodyTseng/psstpsst/blob/master/docs/protocols/nip4e.md). - -## What Coop already covers (well) - -| Capability | Coop | psstpsst | -|---|---|---| -| kind `10044` announcement (fetch + publish, `n` tag) | yes | yes | -| kind `4454` request with `P` tag + pairing code | yes | yes | -| kind `4455` transfer, NIP-44 encrypted | yes | yes | -| Requester key derivation & local storage | encrypted-to-self event in local DB | OS vault | -| Announcement-match validation on import | yes (`decrypt_approval`) | yes | -| Delete `4454` + consumed `4455` after success (draft step 12) | yes — psstpsst does *not* do this | no | -| Backup/export of encryption key (nsec) | yes, incl. wasm path | no | -| Requester identity shown to approver (avatar + name) | yes | no (code only) | -| Account-switch state reset | basic | stronger | - -## Gaps - -### A. Security-critical - -1. **Long-lived client key instead of ephemeral per-exchange keys.** - psstpsst generates a throwaway client keypair for every `4454`/`4455` - exchange and never persists it: a `4455` lives on relays forever, so reusing - one client key means a single leak decrypts every key transfer ever addressed - to it. Coop's `get_or_init_app_keys` persists one app key in `credentials` - and reuses it forever. - -2. **The `4455` is encrypted with the long-lived encryption key, not a fresh - sender keypair.** The draft (step 10) has the responder generate a fresh - keypair `c` and encrypt to `ecdh(c, T)`, with `P` carrying `C`. psstpsst - follows this. Coop encrypts with the encryption key itself and its `P` tag - carries `E`, so anyone holding the encryption private key — today or after a - future rotation/compromise — can decrypt all historical `4455`s. - -3. **No durable processed-request log.** Coop's dedup is an in-memory - `HashSet` plus a single `pending_request: bool`. psstpsst persists every - handled `4454` id so approvals don't re-fire after restart and a device can - recognize its own requests even after its client key is gone. - -4. **Announcement-match is skipped when the local DB has no announcement.** - `decrypt_approval` silently accepts a transfer when the local DB query - returns nothing. psstpsst keeps waiting until the transfer matches a known - announcement; the match is the replay-safety mechanism. - -5. **No cross-device resolution detection.** psstpsst lets a second existing - device see a `4455` already addressed to the requester's client key and - withdraw its redundant approval, avoiding publishing the secret twice. - Coop's `pending_request` bool means two devices can both approve. - -### B. Protocol/interop - -6. **Missing `4454` tags psstpsst sends:** `relay` hints (the responder replies - to *shared* relays; without them the `4455` may never reach the requester), - optional `n` (the exact requested key), and the `pubkey` compatibility - alias (psstpsst accepts both `P` and `pubkey`; Coop reads only `P`). - -7. **Missing identity `p` tag on `4455`.** psstpsst emits `p` = identity plus - `p` = requester client key; Coop has only the client-key `p`. - -8. **No peer announcement caching/freshness model.** psstpsst caches per-peer - announcements in SQLite with tie-break rules (newer `created_at`, then - lexicographically smaller id), a one-day TTL freshness check, and relay - resolution (peer's write + capped DM relays, fallback to discovery). Coop - fetches peer `10044` via a plain subscription in `crates/chat`. - -### C. Features - -9. **Key rotation.** psstpsst supports scheduled automatic rotation - (1-90 days, default 30, off by default) with single-flight rotation, - startup due-checks, old keys retained in a bounded list for the transition, - and send-readiness gating. Coop has no rotation path. - -10. **Resync gating.** psstpsst drops into a full-screen gate that blocks - messaging until a valid key arrives after observing a key change. Coop - re-requests on mismatch but doesn't gate messaging meanwhile. - -### D. Engineering - -11. **No tests** for `crates/nip4e`. psstpsst has dedicated suites for the - transfer flow, watcher, session, store, and approval sheet. - -12. **No client profile doc** pinning down tag MUSTs, code format, and - compatibility. Coop's deviations (#2, #7) are implicit. - -## Status - -### Section A (items 1-5) — implemented - -Implemented in `crates/nip4e/src/lib.rs` and `crates/state/src/credentials.rs`: - -- Ephemeral per-request client key, held in memory for the lifetime of one - request (retries reuse it so an open approval sheet keeps targeting a key the - requester still listens for), cleared after import or account switch. -- `4455` responses encrypt to a fresh one-time responder keypair carried in the - `P` tag, matching the draft and psstpsst. -- Bounded, persisted log of handled `4454` ids; duplicate requests (same client - key) are recorded without re-prompting, replaced requests (different client - key) withdraw the stale sheet. -- `decrypt_approval` falls back to a live relay query when the local database - has no announcement and rejects when none is available. -- An open approval is withdrawn when a `4455` addressed to the same client key - arrives (another device already answered). - -### Section B (items 6-8) — implemented - -- **Interop tags on `4454`** (`crates/nip4e/src/lib.rs`): requests now carry - the draft's `P` tag plus the `pubkey` compatibility alias, `relay` hints (the - requester's NIP-65 write relays), and an optional `n` tag pinning the exact - encryption key the requester observed in its announcement. Responder readers - accept `P` or `pubkey` on both `4454` and `4455`. -- **Responder honors the request** (`approve`): the identity `p` tag is added - to `4455` for account inbox routing; the response is published to the union - of the responder's NIP-65 write relays and the request's normalized `relay` - hints (each hint parsed as untrusted input), falling back to the NIP-65 - policy when no relays are known; when the request pins an `n` key this device - no longer holds, the transfer is refused instead of sending a key the - requester would reject. -- **Peer announcement ordering** (`crates/state/src/nip4e.rs`, - `crates/person/src/person.rs`): `Announcement` now carries the event id and - creation time, and `is_newer_than` implements the psstpsst ordering (newer - `created_at` wins, ties go to the lexicographically smaller event id). - `Person::set_announcement` only replaces an existing announcement with a - newer one, so delayed or out-of-order `10044` events cannot roll a peer's - key back. Freshness remains subscription-based: peer `10044` is re-subscribed - whenever a room opens, over the connected relay set that includes - discovery/bootstrap relays — push-based and stronger than psstpsst's one-day - TTL polling, so no separate announcement cache was added. - -Items 9-12 remain open.