feat: nip4e (#188)

* encryption keys

* .

* .

* move nip4e to device crate

* .

* .

* use i18n for device crate

* refactor

* refactor

* .

* add reset button

* send message with encryption keys

* clean up

* .

* choose signer

* fix

* update i18n

* fix sending
This commit is contained in:
reya
2025-10-26 18:10:40 +07:00
committed by GitHub
parent 83687e5448
commit 15bbe82a87
29 changed files with 1856 additions and 851 deletions

390
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -64,7 +64,7 @@ pub trait RenderedTimestamp {
impl RenderedTimestamp for Timestamp {
fn to_human_time(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return SharedString::from("9999"),
};
@@ -85,7 +85,7 @@ impl RenderedTimestamp for Timestamp {
}
fn to_ago(&self) -> SharedString {
let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) {
let input_time = match Local.timestamp_opt(self.as_secs() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return SharedString::from("1m"),
};

View File

@@ -33,6 +33,7 @@ title_bar = { path = "../title_bar" }
theme = { path = "../theme" }
common = { path = "../common" }
states = { path = "../states" }
key_store = { path = "../key_store" }
registry = { path = "../registry" }
settings = { path = "../settings" }
auto_update = { path = "../auto_update" }

View File

@@ -1,8 +1,9 @@
use std::sync::Mutex;
use gpui::{actions, App, AppContext};
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_connect::prelude::*;
use registry::keystore::KeyItem;
use registry::Registry;
use states::app_state;
@@ -48,7 +49,7 @@ pub fn load_embedded_fonts(cx: &App) {
pub fn reset(cx: &mut App) {
let registry = Registry::global(cx);
let keystore = registry.read(cx).keystore();
let backend = KeyStore::global(cx).read(cx).backend();
cx.spawn(async move |cx| {
cx.background_spawn(async move {
@@ -57,12 +58,12 @@ pub fn reset(cx: &mut App) {
})
.await;
keystore
backend
.delete_credentials(&KeyItem::User.to_string(), cx)
.await
.ok();
keystore
backend
.delete_credentials(&KeyItem::Bunker.to_string(), cx)
.await
.ok();

View File

@@ -4,7 +4,7 @@ use std::sync::Arc;
use anyhow::{anyhow, Error};
use auto_update::AutoUpdater;
use common::display::RenderedProfile;
use common::display::{shorten_pubkey, RenderedProfile};
use common::event::EventUtils;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -14,14 +14,15 @@ use gpui::{
};
use i18n::{shared_t, t};
use itertools::Itertools;
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_connect::prelude::*;
use nostr_sdk::prelude::*;
use registry::keystore::KeyItem;
use registry::{Registry, RegistryEvent};
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use states::constants::{BOOTSTRAP_RELAYS, DEFAULT_SIDEBAR_WIDTH};
use states::state::{AuthRequest, SignalKind, UnwrappingStatus};
use states::state::{Announcement, AuthRequest, Response, SignalKind, UnwrappingStatus};
use states::{app_state, default_nip17_relays, default_nip65_relays};
use theme::{ActiveTheme, Theme, ThemeMode};
use title_bar::TitleBar;
@@ -74,7 +75,7 @@ pub struct ChatSpace {
nip65_ready: bool,
/// All subscriptions for observing the app state
_subscriptions: SmallVec<[Subscription; 4]>,
_subscriptions: SmallVec<[Subscription; 3]>,
/// All long running tasks
_tasks: SmallVec<[Task<()>; 5]>,
@@ -83,7 +84,7 @@ pub struct ChatSpace {
impl ChatSpace {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let registry = Registry::global(cx);
let status = registry.read(cx).unwrapping_status.clone();
let keystore = KeyStore::global(cx);
let title_bar = cx.new(|_| TitleBar::new());
let dock = cx.new(|cx| DockArea::new(window, cx));
@@ -100,21 +101,18 @@ impl ChatSpace {
);
subscriptions.push(
// Observe the keystore
cx.observe_in(&registry, window, |this, registry, window, cx| {
let has_keyring = registry.read(cx).initialized_keystore;
let use_filestore = registry.read(cx).is_using_file_keystore();
let not_logged_in = registry.read(cx).signer_pubkey().is_none();
// Observe device changes
cx.observe_in(&keystore, window, move |this, state, window, cx| {
if state.read(cx).initialized {
let backend = state.read(cx).backend();
if use_filestore && not_logged_in {
if state.read(cx).initialized {
if state.read(cx).is_using_file_keystore() {
this.render_keyring_installation(window, cx);
}
if has_keyring && not_logged_in {
let keystore = registry.read(cx).keystore();
cx.spawn_in(window, async move |this, cx| {
let result = keystore
let result = backend
.read_credentials(&KeyItem::User.to_string(), cx)
.await;
@@ -123,6 +121,7 @@ impl ChatSpace {
Ok(Some((user, secret))) => {
let public_key = PublicKey::parse(&user).unwrap();
let secret = String::from_utf8(secret).unwrap();
this.set_account_layout(public_key, secret, window, cx);
}
_ => {
@@ -134,23 +133,6 @@ impl ChatSpace {
})
.detach();
}
}),
);
subscriptions.push(
// Observe the global registry's events
cx.observe_in(&status, window, move |this, status, window, cx| {
let status = status.read(cx);
let all_panels = this.get_all_panel_ids(cx);
if matches!(
status,
UnwrappingStatus::Processing | UnwrappingStatus::Complete
) {
Registry::global(cx).update(cx, |this, cx| {
this.load_rooms(window, cx);
this.refresh_rooms(all_panels, cx);
});
}
}),
);
@@ -238,9 +220,21 @@ impl ChatSpace {
let settings = AppSettings::global(cx);
match signal {
SignalKind::EncryptionNotSet => {
this.init_encryption(window, cx);
}
SignalKind::EncryptionSet(announcement) => {
this.load_encryption(announcement, window, cx);
}
SignalKind::EncryptionRequest(announcement) => {
this.render_request(announcement, window, cx);
}
SignalKind::EncryptionResponse(response) => {
this.receive_encryption(response, window, cx);
}
SignalKind::SignerSet(public_key) => {
// Close the latest modal if it exists
window.close_modal(cx);
// Close all opened modals
window.close_all_modals(cx);
// Load user's settings
settings.update(cx, |this, cx| {
@@ -256,15 +250,6 @@ impl ChatSpace {
// Setup the default layout for current workspace
this.set_default_layout(window, cx);
}
SignalKind::SignerUnset => {
// Clear all current chat rooms
registry.update(cx, |this, cx| {
this.reset(cx);
});
// Setup the onboarding layout for current workspace
this.set_onboarding_layout(window, cx);
}
SignalKind::Auth(req) => {
let url = &req.url;
let auto_auth = AppSettings::get_auto_auth(cx);
@@ -281,11 +266,20 @@ impl ChatSpace {
this.open_auth_request(req, window, cx);
}
}
SignalKind::GiftWrapStatus(status) => {
SignalKind::GiftWrapStatus(s) => {
if matches!(s, UnwrappingStatus::Processing | UnwrappingStatus::Complete) {
let all_panels = this.get_all_panel_ids(cx);
registry.update(cx, |this, cx| {
this.set_unwrapping_status(status, cx);
this.load_rooms(window, cx);
this.refresh_rooms(all_panels, cx);
if s == UnwrappingStatus::Complete {
this.set_loading(false, cx);
}
});
}
}
SignalKind::NewProfile(profile) => {
registry.update(cx, |this, cx| {
this.insert_or_update_person(profile, cx);
@@ -309,6 +303,92 @@ impl ChatSpace {
}
}
fn init_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let result = app_state().init_encryption_keys().await;
this.update_in(cx, |_, window, cx| {
match result {
Ok(_) => {
window.push_notification(t!("encryption.notice"), cx);
}
Err(e) => {
// TODO: ask user to confirm re-running if failed
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn load_encryption(&self, ann: Announcement, window: &Window, cx: &Context<Self>) {
log::info!("Loading encryption keys: {ann:?}");
cx.spawn_in(window, async move |this, cx| {
let state = app_state();
let result = state.load_encryption_keys(&ann).await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(_) => {
window.push_notification(t!("encryption.reinit"), cx);
}
Err(_) => {
this.request_encryption(ann, window, cx);
}
};
})
.ok();
})
.detach();
}
fn request_encryption(&self, ann: Announcement, window: &Window, cx: &Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let result = app_state().request_encryption_keys().await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(wait_for_approval) => {
if wait_for_approval {
this.render_pending(ann, window, cx);
} else {
window.push_notification(t!("encryption.success"), cx);
}
}
Err(e) => {
// TODO: ask user to confirm re-running if failed
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn receive_encryption(&self, res: Response, window: &Window, cx: &Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let result = app_state().receive_encryption_keys(res).await;
this.update_in(cx, |_, window, cx| {
match result {
Ok(_) => {
window.push_notification(t!("encryption.success"), cx);
}
Err(e) => {
// TODO: ask user to confirm re-running if failed
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn auth(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context<Self>) {
let settings = AppSettings::global(cx);
@@ -730,6 +810,132 @@ impl ChatSpace {
});
}
fn render_request(&mut self, ann: Announcement, window: &mut Window, cx: &mut Context<Self>) {
let client_name = SharedString::from(ann.client().to_string());
let target = ann.public_key();
let note = Notification::new()
.custom_id(SharedString::from(ann.id().to_hex()))
.autohide(false)
.icon(IconName::Info)
.title(shared_t!("request_encryption.label"))
.content(move |_window, cx| {
v_flex()
.gap_2()
.text_sm()
.child(shared_t!("request_encryption.body"))
.child(
v_flex()
.py_1()
.px_1p5()
.rounded_sm()
.text_xs()
.bg(cx.theme().warning_background)
.text_color(cx.theme().warning_foreground)
.child(client_name.clone()),
)
.into_any_element()
})
.action(move |_window, _cx| {
Button::new("approve")
.label(t!("common.approve"))
.small()
.primary()
.loading(false)
.disabled(false)
.on_click(move |_ev, _window, cx| {
cx.background_spawn(async move {
let state = app_state();
state.response_encryption_keys(target).await.ok();
})
.detach();
})
});
window.push_notification(note, cx);
}
fn render_pending(&mut self, ann: Announcement, window: &mut Window, cx: &mut Context<Self>) {
let client_name = SharedString::from(ann.client().to_string());
let public_key = shorten_pubkey(ann.public_key(), 8);
let view = cx.entity().downgrade();
window.open_modal(cx, move |this, _window, cx| {
let view = view.clone();
this.overlay_closable(false)
.show_close(false)
.keyboard(false)
.confirm()
.width(px(460.))
.button_props(
ModalButtonProps::default()
.cancel_text(t!("common.reset"))
.ok_text(t!("common.hide")),
)
.title(shared_t!("pending_encryption.label"))
.child(
v_flex()
.gap_2()
.text_sm()
.child(
v_flex()
.justify_center()
.items_center()
.text_center()
.h_16()
.w_full()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.font_semibold()
.child(client_name.clone())
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from(&public_key)),
),
)
.child(shared_t!("pending_encryption.body_1", c = client_name))
.child(shared_t!("pending_encryption.body_2"))
.child(
div()
.text_xs()
.text_color(cx.theme().warning_foreground)
.child(shared_t!("pending_encryption.body_3")),
),
)
.on_cancel(move |_ev, window, cx| {
_ = view.update(cx, |this, cx| {
this.render_reset(window, cx);
});
// false to keep modal open
false
})
});
}
fn render_reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| {
let state = app_state();
let result = state.init_encryption_keys().await;
this.update_in(cx, |_, window, cx| {
match result {
Ok(_) => {
window.push_notification(t!("encryption.success"), cx);
window.close_all_modals(cx);
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
}
fn render_setup_gossip_relays_modal(&mut self, window: &mut Window, cx: &mut App) {
let relays = default_nip65_relays();
@@ -937,15 +1143,15 @@ impl ChatSpace {
_window: &mut Window,
cx: &Context<Self>,
) -> impl IntoElement {
let registry = Registry::read_global(cx);
let status = registry.unwrapping_status.read(cx);
let registry = Registry::global(cx);
let status = registry.read(cx).loading;
h_flex()
.gap_2()
.h_6()
.w_full()
.child(compose_button())
.when(status != &UnwrappingStatus::Complete, |this| {
.when(status, |this| {
this.child(deferred(
h_flex()
.px_2()

View File

@@ -7,7 +7,7 @@ use gpui::{
WindowOptions,
};
use states::app_state;
use states::constants::{APP_ID, APP_NAME};
use states::constants::{APP_ID, CLIENT_NAME};
use ui::Root;
use crate::actions::{load_embedded_fonts, quit, Quit};
@@ -63,7 +63,7 @@ fn main() {
kind: WindowKind::Normal,
app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(APP_NAME)),
title: Some(SharedString::new_static(CLIENT_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))),
appears_transparent: true,
}),
@@ -86,6 +86,9 @@ fn main() {
// Initialize app registry
registry::init(cx);
// Initialize backend for credentials storage
key_store::init(cx);
// Initialize settings
settings::init(cx);

View File

@@ -9,8 +9,9 @@ use gpui::{
Window,
};
use i18n::{shared_t, t};
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_connect::prelude::*;
use registry::keystore::KeyItem;
use registry::Registry;
use smallvec::{smallvec, SmallVec};
use states::app_state;
@@ -116,7 +117,7 @@ impl Account {
window: &mut Window,
cx: &mut Context<Self>,
) {
let keystore = Registry::global(cx).read(cx).keystore();
let keystore = KeyStore::global(cx).read(cx).backend();
// Handle connection in the background
cx.spawn_in(window, async move |this, cx| {

View File

@@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::time::Duration;
use common::display::{RenderedProfile, RenderedTimestamp};
@@ -17,7 +17,7 @@ use indexset::{BTreeMap, BTreeSet};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use registry::message::{Message, RenderedMessage};
use registry::room::{Room, RoomKind, RoomSignal, SendReport};
use registry::room::{Room, RoomKind, RoomSignal, SendOptions, SendReport, SignerKind};
use registry::Registry;
use serde::Deserialize;
use settings::AppSettings;
@@ -47,6 +47,10 @@ mod subject;
#[action(namespace = chat, no_json)]
pub struct SeenOn(pub EventId);
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
pub struct SetSigner(pub SignerKind);
pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Chat> {
cx.new(|cx| Chat::new(room, window, cx))
}
@@ -54,7 +58,6 @@ pub fn init(room: Entity<Room>, window: &mut Window, cx: &mut App) -> Entity<Cha
pub struct Chat {
// Chat Room
room: Entity<Room>,
relays: Entity<HashMap<PublicKey, Vec<RelayUrl>>>,
// Messages
list_state: ListState,
@@ -64,6 +67,7 @@ pub struct Chat {
// New Message
input: Entity<InputState>,
options: Entity<SendOptions>,
replies_to: Entity<HashSet<EventId>>,
// Media Attachment
@@ -75,20 +79,12 @@ pub struct Chat {
focus_handle: FocusHandle,
image_cache: Entity<RetainAllImageCache>,
_subscriptions: SmallVec<[Subscription; 4]>,
_subscriptions: SmallVec<[Subscription; 3]>,
_tasks: SmallVec<[Task<()>; 2]>,
}
impl Chat {
pub fn new(room: Entity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let attachments = cx.new(|_| vec![]);
let replies_to = cx.new(|_| HashSet::new());
let relays = cx.new(|_| {
let this: HashMap<PublicKey, Vec<RelayUrl>> = HashMap::new();
this
});
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("chat.placeholder"))
@@ -97,11 +93,16 @@ impl Chat {
.clean_on_escape()
});
let attachments = cx.new(|_| vec![]);
let replies_to = cx.new(|_| HashSet::new());
let options = cx.new(|_| SendOptions::default());
let id = room.read(cx).id.to_string().into();
let messages = BTreeSet::from([Message::system()]);
let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.));
let connect = room.read(cx).connect(cx);
let load_messages = room.read(cx).load_messages(cx);
let get_messages = room.read(cx).get_messages(cx);
let mut subscriptions = smallvec![];
let mut tasks = smallvec![];
@@ -109,7 +110,7 @@ impl Chat {
tasks.push(
// Load all messages belonging to this room
cx.spawn_in(window, async move |this, cx| {
let result = load_messages.await;
let result = get_messages.await;
this.update_in(cx, |this, window, cx| {
match result {
@@ -126,24 +127,11 @@ impl Chat {
);
tasks.push(
// Get messaging relays for all members
cx.spawn_in(window, async move |this, cx| {
let result = connect.await;
this.update_in(cx, |this, _window, cx| {
match result {
Ok(relays) => {
this.relays.update(cx, |this, cx| {
this.extend(relays);
cx.notify();
});
// Get messaging relays and encryption keys announcement for all members
cx.background_spawn(async move {
if let Err(e) = connect.await {
log::error!("Failed to initialize room: {e}");
}
Err(e) => {
this.insert_warning(e.to_string(), cx);
}
};
})
.ok();
}),
);
@@ -189,23 +177,6 @@ impl Chat {
}),
);
subscriptions.push(
// Observe the messaging relays of the room's members
cx.observe_in(&relays, window, |this, entity, _window, cx| {
let registry = Registry::global(cx);
let relays = entity.read(cx).clone();
for (public_key, urls) in relays.iter() {
if urls.is_empty() {
let profile = registry.read(cx).get_person(public_key, cx);
let content = t!("chat.nip17_not_found", u = profile.name());
this.insert_warning(content, cx);
}
}
}),
);
subscriptions.push(
// Observe when user close chat panel
cx.on_release_in(window, move |this, window, cx| {
@@ -219,19 +190,19 @@ impl Chat {
);
Self {
id: room.read(cx).id.to_string().into(),
image_cache: RetainAllImageCache::new(cx),
focus_handle: cx.focus_handle(),
rendered_texts_by_id: BTreeMap::new(),
reports_by_id: BTreeMap::new(),
relays,
id,
messages,
room,
list_state,
input,
replies_to,
attachments,
options,
rendered_texts_by_id: BTreeMap::new(),
reports_by_id: BTreeMap::new(),
uploading: false,
image_cache: RetainAllImageCache::new(cx),
focus_handle: cx.focus_handle(),
_subscriptions: subscriptions,
_tasks: tasks,
}
@@ -239,12 +210,12 @@ impl Chat {
/// Load all messages belonging to this room
fn load_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let load_messages = self.room.read(cx).load_messages(cx);
let get_messages = self.room.read(cx).get_messages(cx);
self._tasks.push(
// Run the task in the background
cx.spawn_in(window, async move |this, cx| {
let result = load_messages.await;
let result = get_messages.await;
this.update_in(cx, |this, window, cx| {
match result {
@@ -303,9 +274,6 @@ impl Chat {
this.set_value("", window, cx);
});
// Get the backup setting
let backup = AppSettings::get_backup_messages(cx);
// Get replies_to if it's present
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
@@ -317,14 +285,17 @@ impl Chat {
let rumor_id = rumor.id.unwrap();
// Create a task for sending the message in the background
let send_message = room.send_message(rumor.clone(), backup, cx);
let opts = self.options.read(cx);
let send_message = room.send_message(&rumor, opts, cx);
// Optimistically update message list
cx.spawn_in(window, async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(100))
.await;
let delay = Duration::from_millis(100);
// Wait for the delay
cx.background_executor().timer(delay).await;
// Update the message list and reset the states
this.update_in(cx, |this, window, cx| {
this.insert_message(Message::user(rumor), true, cx);
this.remove_all_replies(cx);
@@ -339,6 +310,7 @@ impl Chat {
})
.detach();
self._tasks.push(
// Continue sending the message in the background
cx.spawn_in(window, async move |this, cx| {
let result = send_message.await;
@@ -346,10 +318,11 @@ impl Chat {
this.update_in(cx, |this, window, cx| {
match result {
Ok(reports) => {
// Update room's status
this.room.update(cx, |this, cx| {
if this.kind != RoomKind::Ongoing {
// Update the room kind to ongoing
// But keep the room kind if send failed
// Update the room kind to ongoing,
// but keep the room kind if send failed
if reports.iter().all(|r| !r.is_sent_success()) {
this.kind = RoomKind::Ongoing;
cx.notify();
@@ -368,8 +341,8 @@ impl Chat {
}
})
.ok();
})
.detach();
}),
);
}
/// Resend a failed message
@@ -432,6 +405,7 @@ impl Chat {
}
/// Insert a warning message into the chat panel
#[allow(dead_code)]
fn insert_warning(&mut self, content: impl Into<String>, cx: &mut Context<Self>) {
let m = Message::warning(content.into());
self.insert_message(m, true, cx);
@@ -473,6 +447,10 @@ impl Chat {
registry.get_person(public_key, cx)
}
fn signer_kind(&self, cx: &App) -> SignerKind {
self.options.read(cx).signer_kind
}
fn scroll_to(&self, id: EventId) {
if let Some(ix) = self.messages.iter().position(|m| {
if let Message::User(msg) = m {
@@ -543,30 +521,25 @@ impl Chat {
})
.ok();
match Flatten::flatten(task.await.map_err(|e| e.into())) {
let result = Flatten::flatten(task.await.map_err(|e| e.into()));
this.update_in(cx, |this, window, cx| {
match result {
Ok(Some(url)) => {
this.update(cx, |this, cx| {
this.add_attachment(url, cx);
this.set_uploading(false, cx);
})
.ok();
}
Ok(None) => {
this.update_in(cx, |this, window, cx| {
window.push_notification("Failed to upload file", cx);
this.set_uploading(false, cx);
})
.ok();
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
this.set_uploading(false, cx);
}
};
})
.ok();
}
}
}
Some(())
})
@@ -911,6 +884,27 @@ impl Chat {
),
)
})
.when(report.device_not_found, |this| {
this.child(
h_flex()
.flex_wrap()
.justify_center()
.p_2()
.h_20()
.w_full()
.text_sm()
.rounded(cx.theme().radius)
.bg(cx.theme().danger_background)
.text_color(cx.theme().danger_foreground)
.child(
div()
.flex_1()
.w_full()
.text_center()
.child(shared_t!("chat.device_not_found", u = name)),
),
)
})
.when_some(report.error.clone(), |this, error| {
this.child(
h_flex()
@@ -1291,6 +1285,13 @@ impl Chat {
})
.detach();
}
fn on_set_encryption(&mut self, ev: &SetSigner, _: &mut Window, cx: &mut Context<Self>) {
self.options.update(cx, move |this, cx| {
this.signer_kind = ev.0;
cx.notify();
});
}
}
impl Panel for Chat {
@@ -1334,8 +1335,11 @@ impl Focusable for Chat {
impl Render for Chat {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let kind = self.signer_kind(cx);
v_flex()
.on_action(cx.listener(Self::on_open_seen_on))
.on_action(cx.listener(Self::on_set_encryption))
.image_cache(self.image_cache.clone())
.size_full()
.child(
@@ -1384,9 +1388,7 @@ impl Render for Chat {
.items_end()
.gap_2p5()
.child(
div()
.flex()
.items_center()
h_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child(
@@ -1408,7 +1410,31 @@ impl Render for Chat {
.large(),
),
)
.child(TextInput::new(&self.input)),
.child(TextInput::new(&self.input))
.child(
Button::new("options")
.icon(IconName::Settings)
.ghost()
.large()
.popup_menu(move |this, _window, _cx| {
this.title("Encrypt by:")
.menu_with_check(
"Encryption Key",
matches!(kind, SignerKind::Encryption),
Box::new(SetSigner(SignerKind::Encryption)),
)
.menu_with_check(
"User's Identity",
matches!(kind, SignerKind::User),
Box::new(SetSigner(SignerKind::User)),
)
.menu_with_check(
"Auto",
matches!(kind, SignerKind::Auto),
Box::new(SetSigner(SignerKind::Auto)),
)
}),
),
),
),
)

View File

@@ -7,9 +7,9 @@ use gpui::{
Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window,
};
use i18n::{shared_t, t};
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_connect::prelude::*;
use registry::keystore::KeyItem;
use registry::Registry;
use smallvec::{smallvec, SmallVec};
use states::app_state;
use states::constants::BUNKER_TIMEOUT;
@@ -174,7 +174,7 @@ impl Login {
window: &mut Window,
cx: &mut Context<Self>,
) {
let keystore = Registry::global(cx).read(cx).keystore();
let keystore = KeyStore::global(cx).read(cx).backend();
let username = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_bytes();
let mut clean_uri = uri.to_string();
@@ -263,7 +263,7 @@ impl Login {
}
pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context<Self>) {
let keystore = Registry::global(cx).read(cx).keystore();
let keystore = KeyStore::global(cx).read(cx).backend();
let username = keys.public_key().to_hex();
let secret = keys.secret_key().to_secret_hex().into_bytes();

View File

@@ -7,9 +7,9 @@ use gpui::{
};
use gpui_tokio::Tokio;
use i18n::{shared_t, t};
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_sdk::prelude::*;
use registry::keystore::KeyItem;
use registry::Registry;
use settings::AppSettings;
use smol::fs;
use states::constants::BOOTSTRAP_RELAYS;
@@ -106,7 +106,7 @@ impl NewAccount {
}
pub fn set_signer(&mut self, cx: &mut Context<Self>) {
let keystore = Registry::global(cx).read(cx).keystore();
let keystore = KeyStore::global(cx).read(cx).backend();
let keys = self.temp_keys.read(cx).clone();
let username = keys.public_key().to_hex();

View File

@@ -9,12 +9,12 @@ use gpui::{
SharedString, StatefulInteractiveElement, Styled, Task, Window,
};
use i18n::{shared_t, t};
use key_store::backend::KeyItem;
use key_store::KeyStore;
use nostr_connect::prelude::*;
use registry::keystore::KeyItem;
use registry::Registry;
use smallvec::{smallvec, SmallVec};
use states::app_state;
use states::constants::{APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use states::constants::{CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
@@ -81,7 +81,7 @@ impl Onboarding {
let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT);
let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap();
let uri = NostrConnectURI::client(app_keys.public_key(), vec![relay], APP_NAME);
let uri = NostrConnectURI::client(app_keys.public_key(), vec![relay], CLIENT_NAME);
let qr_code = uri.to_string().to_qr();
// NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md
@@ -126,7 +126,7 @@ impl Onboarding {
window: &mut Window,
cx: &mut Context<Self>,
) {
let keystore = Registry::global(cx).read(cx).keystore();
let keystore = KeyStore::global(cx).read(cx).backend();
let username = self.app_keys.public_key().to_hex();
let secret = self.app_keys.secret_key().to_secret_bytes();
let mut clean_uri = uri.to_string();

View File

@@ -52,8 +52,8 @@ impl RoomListItem {
self
}
pub fn public_key(mut self, public_key: PublicKey) -> Self {
self.public_key = Some(public_key);
pub fn public_key(mut self, public_key: &PublicKey) -> Self {
self.public_key = Some(public_key.to_owned());
self
}

View File

@@ -22,7 +22,6 @@ use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use states::app_state;
use states::constants::{BOOTSTRAP_RELAYS, SEARCH_RELAYS};
use states::state::UnwrappingStatus;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent};
@@ -627,7 +626,7 @@ impl Sidebar {
.name(this.display_name(cx))
.avatar(this.display_image(proxy, cx))
.created_at(this.created_at.to_ago())
.public_key(this.members[0])
.public_key(this.members.iter().nth(0).unwrap().0)
.kind(this.kind)
.on_click(handler),
)
@@ -669,7 +668,7 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let registry = Registry::read_global(cx);
let loading = registry.unwrapping_status.read(cx) != &UnwrappingStatus::Complete;
let loading = registry.loading;
// Get rooms from either search results or the chat registry
let rooms = if let Some(results) = self.local_result.read(cx).as_ref() {

View File

@@ -0,0 +1,28 @@
[package]
name = "key_store"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
states = { path = "../states" }
ui = { path = "../ui" }
theme = { path = "../theme" }
settings = { path = "../settings" }
rust-i18n.workspace = true
i18n.workspace = true
gpui.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -14,8 +14,6 @@ use states::paths::config_dir;
pub enum KeyItem {
User,
Bunker,
Client,
Encryption,
}
impl Display for KeyItem {
@@ -23,8 +21,6 @@ impl Display for KeyItem {
match self {
Self::User => write!(f, "coop-user"),
Self::Bunker => write!(f, "coop-bunker"),
Self::Client => write!(f, "coop-client"),
Self::Encryption => write!(f, "coop-encryption"),
}
}
}
@@ -35,7 +31,7 @@ impl From<KeyItem> for String {
}
}
pub trait KeyStore: Any + Send + Sync {
pub trait KeyBackend: Any + Send + Sync {
fn name(&self) -> &str;
/// Reads the credentials from the provider.
@@ -66,7 +62,7 @@ pub trait KeyStore: Any + Send + Sync {
/// A credentials provider that stores credentials in the system keychain.
pub struct KeyringProvider;
impl KeyStore for KeyringProvider {
impl KeyBackend for KeyringProvider {
fn name(&self) -> &str {
"keyring"
}
@@ -139,7 +135,7 @@ impl Default for FileProvider {
}
}
impl KeyStore for FileProvider {
impl KeyBackend for FileProvider {
fn name(&self) -> &str {
"file"
}

View File

@@ -0,0 +1,96 @@
use std::sync::{Arc, LazyLock};
use gpui::{App, AppContext, Context, Entity, Global, Task};
use smallvec::{smallvec, SmallVec};
use crate::backend::{FileProvider, KeyBackend, KeyringProvider};
pub mod backend;
static DISABLE_KEYRING: LazyLock<bool> =
LazyLock::new(|| std::env::var("DISABLE_KEYRING").is_ok_and(|value| !value.is_empty()));
pub fn init(cx: &mut App) {
KeyStore::set_global(cx.new(KeyStore::new), cx);
}
struct GlobalKeyStore(Entity<KeyStore>);
impl Global for GlobalKeyStore {}
pub struct KeyStore {
/// Key Store for storing credentials
pub backend: Arc<dyn KeyBackend>,
/// Whether the keystore has been initialized
pub initialized: bool,
/// Tasks for asynchronous operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl KeyStore {
/// Retrieve the global keys state
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalKeyStore>().0.clone()
}
/// Set the global keys instance
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalKeyStore(state));
}
/// Create a new keys instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
// Use the file system for keystore in development or when the user specifies it
let use_file_keystore = cfg!(debug_assertions) || *DISABLE_KEYRING;
// Construct the key backend
let backend: Arc<dyn KeyBackend> = if use_file_keystore {
Arc::new(FileProvider::default())
} else {
Arc::new(KeyringProvider)
};
// Only used for testing keyring availability on the user's system
let read_credential = cx.read_credentials("Coop");
let mut tasks = smallvec![];
tasks.push(
// Verify the keyring availability
cx.spawn(async move |this, cx| {
let result = read_credential.await;
this.update(cx, |this, cx| {
if let Err(e) = result {
log::error!("Keyring error: {e}");
// For Linux:
// The user has not installed secret service on their system
// Fall back to the file provider
this.backend = Arc::new(FileProvider::default());
}
this.initialized = true;
cx.notify();
})
.ok();
}),
);
Self {
backend,
initialized: false,
_tasks: tasks,
}
}
/// Returns the key backend.
pub fn backend(&self) -> Arc<dyn KeyBackend> {
Arc::clone(&self.backend)
}
/// Returns true if the keystore is a file key backend.
pub fn is_using_file_keystore(&self) -> bool {
self.backend.name() == "file"
}
}

View File

@@ -12,16 +12,13 @@ settings = { path = "../settings" }
gpui.workspace = true
nostr.workspace = true
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
anyhow.workspace = true
itertools.workspace = true
smallvec.workspace = true
smol.workspace = true
log.workspace = true
flume.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
fuzzy-matcher = "0.3.7"
rustls = "0.23.23"

View File

@@ -1,6 +1,5 @@
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock};
use anyhow::Error;
use common::event::EventUtils;
@@ -14,19 +13,12 @@ use room::RoomKind;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use states::app_state;
use states::constants::KEYRING_URL;
use states::state::UnwrappingStatus;
use crate::keystore::{FileProvider, KeyStore, KeyringProvider};
use crate::room::Room;
pub mod keystore;
pub mod message;
pub mod room;
pub static DISABLE_KEYRING: LazyLock<bool> =
LazyLock::new(|| std::env::var("DISABLE_KEYRING").is_ok_and(|value| !value.is_empty()));
pub fn init(cx: &mut App) {
Registry::set_global(cx.new(Registry::new), cx);
}
@@ -49,14 +41,8 @@ pub struct Registry {
/// Collection of all persons (user profiles)
pub persons: HashMap<PublicKey, Entity<Profile>>,
/// Status of the unwrapping process
pub unwrapping_status: Entity<UnwrappingStatus>,
/// Key Store for storing credentials
pub keystore: Arc<dyn KeyStore>,
/// Whether the keystore has been initialized
pub initialized_keystore: bool,
/// Loading status of the registry
pub loading: bool,
/// Public Key of the currently activated signer
signer_pubkey: Option<PublicKey>,
@@ -85,39 +71,8 @@ impl Registry {
/// Create a new registry instance
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let unwrapping_status = cx.new(|_| UnwrappingStatus::default());
let read_credential = cx.read_credentials(KEYRING_URL);
let initialized_keystore = cfg!(debug_assertions) || *DISABLE_KEYRING;
let keystore: Arc<dyn KeyStore> = if cfg!(debug_assertions) || *DISABLE_KEYRING {
Arc::new(FileProvider::default())
} else {
Arc::new(KeyringProvider)
};
let mut tasks = smallvec![];
if !(cfg!(debug_assertions) || *DISABLE_KEYRING) {
tasks.push(
// Verify the keyring access
cx.spawn(async move |this, cx| {
let result = read_credential.await;
this.update(cx, |this, cx| {
if let Err(e) = result {
log::error!("Keyring error: {e}");
// For Linux:
// The user has not installed secret service on their system
// Fall back to the file provider
this.keystore = Arc::new(FileProvider::default());
}
this.initialized_keystore = true;
cx.notify();
})
.ok();
}),
);
}
tasks.push(
// Load all user profiles from the database
cx.spawn(async move |this, cx| {
@@ -136,12 +91,10 @@ impl Registry {
);
Self {
unwrapping_status,
keystore,
initialized_keystore,
rooms: vec![],
persons: HashMap::new(),
signer_pubkey: None,
loading: true,
_tasks: tasks,
}
}
@@ -165,16 +118,6 @@ impl Registry {
})
}
/// Returns the keystore.
pub fn keystore(&self) -> Arc<dyn KeyStore> {
Arc::clone(&self.keystore)
}
/// Returns true if the keystore is a file keystore.
pub fn is_using_file_keystore(&self) -> bool {
self.keystore.name() == "file"
}
/// Returns the public key of the currently activated signer.
pub fn signer_pubkey(&self) -> Option<PublicKey> {
self.signer_pubkey
@@ -233,6 +176,11 @@ impl Registry {
}
}
pub fn set_loading(&mut self, loading: bool, cx: &mut Context<Self>) {
self.loading = loading;
cx.notify();
}
/// Get a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
self.rooms
@@ -297,24 +245,13 @@ impl Registry {
pub fn search_by_public_key(&self, public_key: PublicKey, cx: &App) -> Vec<Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).members.contains(&public_key))
.filter(|room| room.read(cx).members.contains_key(&public_key))
.cloned()
.collect()
}
/// Set the loading status of the registry.
pub fn set_unwrapping_status(&mut self, status: UnwrappingStatus, cx: &mut Context<Self>) {
self.unwrapping_status.update(cx, |this, cx| {
*this = status;
cx.notify();
});
}
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
// Reset the unwrapping status
self.set_unwrapping_status(UnwrappingStatus::default(), cx);
// Clear the current identity
self.signer_pubkey = None;
@@ -339,10 +276,7 @@ impl Registry {
let authored_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(
SingleLetterTag::lowercase(Alphabet::A),
public_key.to_hex(),
);
.custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key);
let addressed_filter = Filter::new()
.kind(Kind::ApplicationSpecificData)

View File

@@ -7,20 +7,57 @@ use anyhow::{anyhow, Error};
use common::display::RenderedProfile;
use common::event::EventUtils;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use states::app_state;
use states::constants::SEND_RETRY;
use crate::Registry;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Deserialize, Serialize)]
pub enum SignerKind {
Encryption,
User,
#[default]
Auto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct SendOptions {
pub backup: bool,
pub signer_kind: SignerKind,
}
impl SendOptions {
pub fn new() -> Self {
Self {
backup: true,
signer_kind: SignerKind::default(),
}
}
pub fn backup(&self) -> bool {
self.backup
}
}
impl Default for SendOptions {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct SendReport {
pub receiver: PublicKey,
pub status: Option<Output<EventId>>,
pub error: Option<SharedString>,
pub on_hold: Option<Event>,
pub relays_not_found: bool,
pub device_not_found: bool,
pub on_hold: Option<Event>,
}
impl SendReport {
@@ -31,18 +68,17 @@ impl SendReport {
error: None,
on_hold: None,
relays_not_found: false,
device_not_found: false,
}
}
pub fn status(mut self, output: Output<EventId>) -> Self {
self.status = Some(output);
self.relays_not_found = false;
self
}
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
self.error = Some(error.into());
self.relays_not_found = false;
self
}
@@ -51,11 +87,16 @@ impl SendReport {
self
}
pub fn not_found(mut self) -> Self {
pub fn relays_not_found(mut self) -> Self {
self.relays_not_found = true;
self
}
pub fn device_not_found(mut self) -> Self {
self.device_not_found = true;
self
}
pub fn is_relay_error(&self) -> bool {
self.error.is_some() || self.relays_not_found
}
@@ -82,6 +123,8 @@ pub enum RoomKind {
Request,
}
type DevicePublicKey = PublicKey;
#[derive(Debug)]
pub struct Room {
pub id: u64,
@@ -89,7 +132,7 @@ pub struct Room {
/// Subject of the room
pub subject: Option<String>,
/// All members of the room
pub members: Vec<PublicKey>,
pub members: HashMap<PublicKey, Option<DevicePublicKey>>,
/// Kind
pub kind: RoomKind,
}
@@ -128,7 +171,11 @@ impl From<&Event> for Room {
let created_at = val.created_at;
// Get the members from the event's tags and event's pubkey
let members = val.all_pubkeys();
let members: HashMap<PublicKey, Option<DevicePublicKey>> = val
.all_pubkeys()
.into_iter()
.map(|public_key| (public_key, None))
.collect();
// Get subject from tags
let subject = val
@@ -152,7 +199,11 @@ impl From<&UnsignedEvent> for Room {
let created_at = val.created_at;
// Get the members from the event's tags and event's pubkey
let members = val.all_pubkeys();
let members: HashMap<PublicKey, Option<DevicePublicKey>> = val
.all_pubkeys()
.into_iter()
.map(|public_key| (public_key, None))
.collect();
// Get subject from tags
let subject = val
@@ -233,8 +284,8 @@ impl Room {
}
/// Returns the members of the room
pub fn members(&self) -> &Vec<PublicKey> {
&self.members
pub fn members(&self) -> Vec<PublicKey> {
self.members.keys().cloned().collect()
}
/// Checks if the room has more than two members (group)
@@ -264,17 +315,17 @@ impl Room {
///
/// This member is always different from the current user.
fn display_member(&self, cx: &App) -> Profile {
let registry = Registry::read_global(cx);
let registry = Registry::global(cx);
let signer_pubkey = registry.read(cx).signer_pubkey();
if let Some(public_key) = registry.signer_pubkey() {
for member in self.members() {
if member != &public_key {
return registry.get_person(member, cx);
}
}
}
let target_member = self
.members
.keys()
.find(|&member| Some(member) != signer_pubkey.as_ref())
.or_else(|| self.members.keys().next())
.expect("Room should have at least one member");
registry.get_person(&self.members[0], cx)
registry.read(cx).get_person(target_member, cx)
}
/// Merge the names of the first two members of the room.
@@ -284,7 +335,7 @@ impl Room {
if self.is_group() {
let profiles: Vec<Profile> = self
.members
.iter()
.keys()
.map(|public_key| registry.get_person(public_key, cx))
.collect();
@@ -305,91 +356,9 @@ impl Room {
}
}
/// Connects to all members's messaging relays
pub fn connect(&self, cx: &App) -> Task<Result<HashMap<PublicKey, Vec<RelayUrl>>, Error>> {
let members = self.members.clone();
cx.background_spawn(async move {
let client = app_state().client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let mut relays = HashMap::new();
let mut processed = HashSet::new();
for member in members.into_iter() {
if member == public_key {
continue;
};
relays.insert(member, vec![]);
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(member)
.limit(1);
let mut stream = client
.stream_events(filter, Duration::from_secs(10))
.await?;
if let Some(event) = stream.next().await {
if processed.insert(event.id) {
let public_key = event.pubkey;
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
// Check if at least one URL exists
if urls.is_empty() {
continue;
}
// Connect to relays
for url in urls.iter() {
client.add_relay(url).await?;
client.connect_relay(url).await?;
}
relays.entry(public_key).and_modify(|v| v.extend(urls));
}
}
}
Ok(relays)
})
}
/// Loads all messages for this room from the database
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
let conversation_id = self.id.to_string();
cx.background_spawn(async move {
let client = app_state().client();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(
SingleLetterTag::lowercase(Alphabet::C),
conversation_id.as_str(),
);
let stored = client.database().query(filter).await?;
let mut messages = Vec::with_capacity(stored.len());
for event in stored {
match UnsignedEvent::from_json(&event.content) {
Ok(rumor) => messages.push(rumor),
Err(e) => log::warn!("Failed to parse stored rumor: {e}"),
}
}
messages.sort_by_key(|message| message.created_at);
Ok(messages)
})
}
/// Emits a new message signal to the current room
pub fn emit_message(&self, gift_wrap_id: EventId, event: UnsignedEvent, cx: &mut Context<Self>) {
cx.emit(RoomSignal::NewMessage((gift_wrap_id, event)));
pub fn emit_message(&self, id: EventId, event: UnsignedEvent, cx: &mut Context<Self>) {
cx.emit(RoomSignal::NewMessage((id, event)));
}
/// Emits a signal to refresh the current room's messages.
@@ -397,9 +366,69 @@ impl Room {
cx.emit(RoomSignal::Refresh);
}
/// Get messaging relays and encryption keys announcement for each member
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let members = self.members();
cx.background_spawn(async move {
let client = app_state().client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
for member in members.into_iter() {
if member == public_key {
continue;
};
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(member)
.limit(1);
// Subscribe to get members messaging relays
client.subscribe(filter, Some(opts)).await?;
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(member)
.limit(1);
// Subscribe to get members encryption keys announcement
client.subscribe(filter, Some(opts)).await?;
}
Ok(())
})
}
/// Get all messages belonging to the room
pub fn get_messages(&self, cx: &App) -> Task<Result<Vec<UnsignedEvent>, Error>> {
let conversation_id = self.id.to_string();
cx.background_spawn(async move {
let client = app_state().client();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id);
let stored = client.database().query(filter).await?;
let mut messages: Vec<UnsignedEvent> = stored
.into_iter()
.filter_map(|event| UnsignedEvent::from_json(&event.content).ok())
.collect();
messages.sort_by_key(|message| message.created_at);
Ok(messages)
})
}
/// Create a new message event (unsigned)
pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent {
let public_key = Registry::read_global(cx).signer_pubkey().unwrap();
let registry = Registry::global(cx);
let public_key = registry.read(cx).signer_pubkey().unwrap();
let subject = self.subject.clone();
let mut tags = vec![];
@@ -407,7 +436,7 @@ impl Room {
// Add receivers
//
// NOTE: current user will be removed from the list of receivers
for member in self.members.iter() {
for (member, _) in self.members.iter() {
tags.push(Tag::public_key(member.to_owned()));
}
@@ -447,34 +476,42 @@ impl Room {
/// Create a task to send a message to all room members
pub fn send_message(
&self,
rumor: UnsignedEvent,
backup: bool,
rumor: &UnsignedEvent,
opts: &SendOptions,
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
let mut members = self.members.clone();
let rumor = rumor.to_owned();
let opts = opts.to_owned();
cx.background_spawn(async move {
let states = app_state();
let client = states.client();
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let device = states.device.read().await.encryption_keys.clone();
let user_signer = client.signer().await?;
let user_pubkey = user_signer.get_public_key().await?;
// Collect relay hints for all participants (including current user)
let mut participants = members.clone();
if !participants.contains(&public_key) {
participants.push(public_key);
let mut participants: Vec<PublicKey> = members.keys().cloned().collect();
if !participants.contains(&user_pubkey) {
participants.push(user_pubkey);
}
// Initialize relay cache
let mut relay_cache: HashMap<PublicKey, Vec<RelayUrl>> = HashMap::new();
for participant in participants.iter().cloned() {
let urls = Self::messaging_relays(participant).await;
let urls = states.messaging_relays(participant).await;
relay_cache.insert(participant, urls);
}
// Update rumor with relay hints for each receiver
let mut rumor = rumor;
let mut tags_with_hints = Vec::new();
for tag in rumor.tags.to_vec() {
for tag in rumor.tags.into_iter() {
if let Some(standard) = tag.as_standardized().cloned() {
match standard {
TagStandard::PublicKey {
@@ -483,18 +520,18 @@ impl Room {
uppercase,
..
} => {
let relay_url =
relay_cache
let relay_url = relay_cache
.get(&public_key)
.and_then(|urls| urls.first().cloned());
let updated = TagStandard::PublicKey {
public_key,
relay_url,
alias,
uppercase,
};
tags_with_hints
.push(Tag::from_standardized_without_cell(updated));
tags_with_hints.push(Tag::from_standardized_without_cell(updated));
}
_ => tags_with_hints.push(tag),
}
@@ -506,29 +543,42 @@ impl Room {
// Remove the current user's public key from the list of receivers
// Current user will be handled separately
members.retain(|&pk| pk != public_key);
let (public_key, device_pubkey) = members.remove_entry(&user_pubkey).unwrap();
// Determine the signer will be used based on the provided options
let signer = Self::select_signer(&opts.signer_kind, device, user_signer)?;
// Collect the send reports
let mut reports: Vec<SendReport> = vec![];
for receiver in members.into_iter() {
let rumor = rumor.clone();
let event = EventBuilder::gift_wrap(&signer, &receiver, rumor, vec![]).await?;
for (receiver, device_pubkey) in members.into_iter() {
let urls = relay_cache.get(&receiver).cloned().unwrap_or_default();
// Check if there are any relays to send the event to
// Check if there are any relays to send the message to
if urls.is_empty() {
reports.push(SendReport::new(receiver).not_found());
reports.push(SendReport::new(receiver).relays_not_found());
continue;
}
// Skip sending if using encryption keys but device not found
if device_pubkey.is_none() && matches!(opts.signer_kind, SignerKind::Encryption) {
reports.push(SendReport::new(receiver).device_not_found());
continue;
}
// Determine the receiver based on the signer kind
let rumor = rumor.clone();
let target = Self::select_receiver(&opts.signer_kind, receiver, device_pubkey);
let event = EventBuilder::gift_wrap(&signer, &target, rumor, vec![]).await?;
// Send the event to the messaging relays
match client.send_event_to(urls, &event).await {
Ok(output) => {
let id = output.id().to_owned();
let auth_required = output.failed.iter().any(|m| m.1.starts_with("auth-"));
let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-"));
let report = SendReport::new(receiver).status(output);
if auth_required {
if auth {
// Wait for authenticated and resent event successfully
for attempt in 0..=SEND_RETRY {
let retry_manager = states.tracker().read().await;
@@ -561,15 +611,16 @@ impl Room {
// Construct a gift wrap to back up to current user's owned messaging relays
let rumor = rumor.clone();
let event = EventBuilder::gift_wrap(&signer, &public_key, rumor, vec![]).await?;
let target = Self::select_receiver(&opts.signer_kind, public_key, device_pubkey);
let event = EventBuilder::gift_wrap(&signer, &target, rumor, vec![]).await?;
// Only send a backup message to current user if sent successfully to others
if reports.iter().all(|r| r.is_sent_success()) && backup {
if opts.backup() && reports.iter().all(|r| r.is_sent_success()) {
let urls = relay_cache.get(&public_key).cloned().unwrap_or_default();
// Check if there are any relays to send the event to
if urls.is_empty() {
reports.push(SendReport::new(public_key).not_found());
reports.push(SendReport::new(public_key).relays_not_found());
} else {
// Send the event to the messaging relays
match client.send_event_to(urls, &event).await {
@@ -596,7 +647,8 @@ impl Room {
cx: &App,
) -> Task<Result<Vec<SendReport>, Error>> {
cx.background_spawn(async move {
let client = app_state().client();
let states = app_state();
let client = states.client();
let mut resend_reports = vec![];
for report in reports.into_iter() {
@@ -625,11 +677,11 @@ impl Room {
// Process the on hold event if it exists
if let Some(event) = report.on_hold {
let urls = Self::messaging_relays(receiver).await;
let urls = states.messaging_relays(receiver).await;
// Check if there are any relays to send the event to
if urls.is_empty() {
resend_reports.push(SendReport::new(receiver).not_found());
resend_reports.push(SendReport::new(receiver).relays_not_found());
} else {
// Send the event to the messaging relays
match client.send_event_to(urls, &event).await {
@@ -648,36 +700,24 @@ impl Room {
})
}
/// Gets messaging relays for public key
async fn messaging_relays(public_key: PublicKey) -> Vec<RelayUrl> {
let client = app_state().client();
let mut relay_urls = vec![];
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Ok(events) = client.database().query(filter).await {
if let Some(event) = events.first_owned() {
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
// Connect to relays
for url in urls.iter() {
client.add_relay(url).await.ok();
client.connect_relay(url).await.ok();
fn select_signer<T>(kind: &SignerKind, device: Option<T>, user: T) -> Result<T, Error>
where
T: NostrSigner,
{
match kind {
SignerKind::Encryption => {
Ok(device.ok_or_else(|| anyhow!("No encryption keys found"))?)
}
relay_urls.extend(urls.into_iter().take(3).unique());
SignerKind::User => Ok(user),
SignerKind::Auto => Ok(device.unwrap_or(user)),
}
}
relay_urls
fn select_receiver(kind: &SignerKind, user: PublicKey, device: Option<PublicKey>) -> PublicKey {
match kind {
SignerKind::Encryption => device.unwrap(),
SignerKind::User => user,
SignerKind::Auto => device.unwrap_or(user),
}
}
#[cfg(test)]
mod tests {
use super::*;
}

View File

@@ -7,11 +7,12 @@ publish.workspace = true
[dependencies]
nostr-sdk.workspace = true
nostr-lmdb.workspace = true
dirs.workspace = true
smol.workspace = true
flume.workspace = true
log.workspace = true
anyhow.workspace = true
whoami = "1.5.2"
whoami = "1.6.1"
rustls = "0.23.23"

View File

@@ -1,9 +1,8 @@
pub const APP_NAME: &str = "Coop";
pub const CLIENT_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
pub const APP_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc4MkNFRkQ2RkVGQURGNzUKUldSMTMvcisxdThzZUZraHc4Vno3NVNJek81VkJFUEV3MkJweGFxQXhpekdSU1JIekpqMG4yemMK";
pub const APP_UPDATER_ENDPOINT: &str = "https://coop-updater.reya.su/";
pub const KEYRING_URL: &str = "Coop Safe Storage";
pub const SETTINGS_IDENTIFIER: &str = "coop:settings";
/// Bootstrap Relays.
@@ -33,6 +32,9 @@ pub const NOSTR_CONNECT_TIMEOUT: u64 = 200;
/// Default timeout (in seconds) for Nostr Connect (Bunker)
pub const BUNKER_TIMEOUT: u64 = 30;
/// Default timeout (in seconds) for fetching events
pub const QUERY_TIMEOUT: u64 = 3;
/// Total metadata requests will be grouped.
pub const METADATA_BATCH_LIMIT: usize = 100;

View File

@@ -1,7 +1,9 @@
use std::sync::OnceLock;
use nostr_sdk::prelude::*;
use whoami::{devicename, platform};
use crate::constants::CLIENT_NAME;
use crate::state::AppState;
pub mod constants;
@@ -9,6 +11,7 @@ pub mod paths;
pub mod state;
static APP_STATE: OnceLock<AppState> = OnceLock::new();
static APP_NAME: OnceLock<String> = OnceLock::new();
static NIP65_RELAYS: OnceLock<Vec<(RelayUrl, Option<RelayMetadata>)>> = OnceLock::new();
static NIP17_RELAYS: OnceLock<Vec<RelayUrl>> = OnceLock::new();
@@ -17,6 +20,15 @@ pub fn app_state() -> &'static AppState {
APP_STATE.get_or_init(AppState::new)
}
pub fn app_name() -> &'static String {
APP_NAME.get_or_init(|| {
let devicename = devicename();
let platform = platform();
format!("{CLIENT_NAME} on {platform} ({devicename})")
})
}
/// Default NIP-65 Relays. Used for new account
pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option<RelayMetadata>)> {
NIP65_RELAYS.get_or_init(|| {

View File

@@ -0,0 +1,25 @@
use std::sync::Arc;
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, Default)]
pub struct Device {
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
///
/// The client keys that used for communication between devices
pub client_keys: Option<Arc<dyn NostrSigner>>,
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
///
/// The encryption keys that used for encryption and decryption
pub encryption_keys: Option<Arc<dyn NostrSigner>>,
}
impl Device {
pub fn new() -> Self {
Self {
client_keys: None,
encryption_keys: None,
}
}
}

View File

@@ -0,0 +1,31 @@
use flume::{Receiver, Sender};
use nostr_sdk::prelude::*;
#[derive(Debug, Clone)]
pub struct Ingester {
rx: Receiver<PublicKey>,
tx: Sender<PublicKey>,
}
impl Default for Ingester {
fn default() -> Self {
Self::new()
}
}
impl Ingester {
pub fn new() -> Self {
let (tx, rx) = flume::bounded::<PublicKey>(1024);
Self { rx, tx }
}
pub fn receiver(&self) -> &Receiver<PublicKey> {
&self.rx
}
pub async fn send(&self, public_key: PublicKey) {
if let Err(e) = self.tx.send_async(public_key).await {
log::error!("Failed to send public key: {e}");
}
}
}

View File

@@ -1,169 +1,31 @@
use std::borrow::Cow;
use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
use std::collections::hash_map::DefaultHasher;
use std::collections::HashSet;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Error};
use flume::{Receiver, Sender};
use anyhow::{anyhow, Context, Error};
use nostr_lmdb::NostrLMDB;
use nostr_sdk::prelude::*;
use smol::lock::RwLock;
use crate::app_name;
use crate::constants::{
BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, METADATA_BATCH_TIMEOUT, SEARCH_RELAYS,
BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, METADATA_BATCH_TIMEOUT, QUERY_TIMEOUT, SEARCH_RELAYS,
};
use crate::paths::config_dir;
use crate::state::device::Device;
use crate::state::ingester::Ingester;
use crate::state::tracker::EventTracker;
const TIMEOUT: u64 = 5;
mod device;
mod ingester;
mod signal;
mod tracker;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AuthRequest {
pub url: RelayUrl,
pub challenge: String,
pub sending: bool,
}
impl AuthRequest {
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
Self {
challenge: challenge.into(),
sending: false,
url,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum UnwrappingStatus {
#[default]
Initialized,
Processing,
Complete,
}
/// Signals sent through the global event channel to notify UI
#[derive(Debug)]
pub enum SignalKind {
/// A signal to notify UI that the client's signer has been set
SignerSet(PublicKey),
/// A signal to notify UI that the client's signer has been unset
SignerUnset,
/// A signal to notify UI that the relay requires authentication
Auth(AuthRequest),
/// A signal to notify UI that a new profile has been received
NewProfile(Profile),
/// A signal to notify UI that a new gift wrap event has been received
NewMessage((EventId, UnsignedEvent)),
/// A signal to notify UI that no messaging relays for current user was found
MessagingRelaysNotFound,
/// A signal to notify UI that no gossip relays for current user was found
GossipRelaysNotFound,
/// A signal to notify UI that gift wrap status has changed
GiftWrapStatus(UnwrappingStatus),
}
#[derive(Debug, Clone)]
pub struct Signal {
rx: Receiver<SignalKind>,
tx: Sender<SignalKind>,
}
impl Default for Signal {
fn default() -> Self {
Self::new()
}
}
impl Signal {
pub fn new() -> Self {
let (tx, rx) = flume::bounded::<SignalKind>(2048);
Self { rx, tx }
}
pub fn receiver(&self) -> &Receiver<SignalKind> {
&self.rx
}
pub fn sender(&self) -> &Sender<SignalKind> {
&self.tx
}
pub async fn send(&self, kind: SignalKind) {
if let Err(e) = self.tx.send_async(kind).await {
log::error!("Failed to send signal: {e}");
}
}
}
#[derive(Debug, Clone)]
pub struct Ingester {
rx: Receiver<PublicKey>,
tx: Sender<PublicKey>,
}
impl Default for Ingester {
fn default() -> Self {
Self::new()
}
}
impl Ingester {
pub fn new() -> Self {
let (tx, rx) = flume::bounded::<PublicKey>(1024);
Self { rx, tx }
}
pub fn receiver(&self) -> &Receiver<PublicKey> {
&self.rx
}
pub async fn send(&self, public_key: PublicKey) {
if let Err(e) = self.tx.send_async(public_key).await {
log::error!("Failed to send public key: {e}");
}
}
}
#[derive(Debug, Clone, Default)]
pub struct EventTracker {
/// Tracking events that have been resent by Coop in the current session
pub resent_ids: Vec<Output<EventId>>,
/// Temporarily store events that need to be resent later
pub resend_queue: HashMap<EventId, RelayUrl>,
/// Tracking events sent by Coop in the current session
pub sent_ids: HashSet<EventId>,
/// Tracking events seen on which relays in the current session
pub seen_on_relays: HashMap<EventId, HashSet<RelayUrl>>,
}
impl EventTracker {
pub fn resent_ids(&self) -> &Vec<Output<EventId>> {
&self.resent_ids
}
pub fn resend_queue(&self) -> &HashMap<EventId, RelayUrl> {
&self.resend_queue
}
pub fn sent_ids(&self) -> &HashSet<EventId> {
&self.sent_ids
}
pub fn seen_on_relays(&self) -> &HashMap<EventId, HashSet<RelayUrl>> {
&self.seen_on_relays
}
}
pub use signal::*;
#[derive(Debug)]
pub struct AppState {
@@ -179,6 +41,9 @@ pub struct AppState {
/// Ingester channel for processing public keys
ingester: Ingester,
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub device: RwLock<Device>,
/// The timestamp when the application was initialized.
pub initialized_at: Timestamp,
@@ -213,6 +78,7 @@ impl AppState {
});
let client = ClientBuilder::default().database(lmdb).opts(opts).build();
let device = RwLock::new(Device::default());
let event_tracker = RwLock::new(EventTracker::default());
let signal = Signal::default();
@@ -220,6 +86,7 @@ impl AppState {
Self {
client,
device,
event_tracker,
signal,
ingester,
@@ -233,6 +100,11 @@ impl AppState {
&self.client
}
/// Returns a reference to the device
pub fn device(&'static self) -> &'static RwLock<Device> {
&self.device
}
/// Returns a reference to the event tracker
pub fn tracker(&'static self) -> &'static RwLock<EventTracker> {
&self.event_tracker
@@ -262,7 +134,10 @@ impl AppState {
// Get user's gossip relays
self.get_nip65(pk).await.ok();
// Exit the current loop
// Initialize client keys
self.init_client_keys().await.ok();
// Exit the loop
break;
}
}
@@ -355,6 +230,36 @@ impl AppState {
}
match event.kind {
// Encryption Keys announcement event
Kind::Custom(10044) => {
if let Ok(true) = self.is_self_authored(&event).await {
if let Ok(announcement) = self.extract_announcement(&event) {
self.signal
.send(SignalKind::EncryptionSet(announcement))
.await;
}
}
}
// Encryption Keys request event
Kind::Custom(4454) => {
if let Ok(true) = self.is_self_authored(&event).await {
if let Ok(announcement) = self.extract_announcement(&event) {
self.signal
.send(SignalKind::EncryptionRequest(announcement))
.await;
}
}
}
// Encryption Keys response event
Kind::Custom(4455) => {
if let Ok(true) = self.is_self_authored(&event).await {
if let Ok(response) = self.extract_response(&event) {
self.signal
.send(SignalKind::EncryptionResponse(response))
.await;
}
}
}
Kind::RelayList => {
// Get events if relay list belongs to current user
if let Ok(true) = self.is_self_authored(&event).await {
@@ -370,6 +275,11 @@ impl AppState {
log::error!("Failed to subscribe to contact list event: {e}");
}
// Fetch user's encryption announcement event
if let Err(e) = self.get_announcement(author).await {
log::error!("Failed to fetch encryption event: {e}");
}
// Fetch user's messaging relays event
if let Err(e) = self.get_nip17(author).await {
log::error!("Failed to fetch messaging relays event: {e}");
@@ -404,7 +314,9 @@ impl AppState {
self.signal.send(SignalKind::NewProfile(profile)).await;
}
Kind::GiftWrap => {
self.extract_rumor(&event).await;
if let Err(e) = self.extract_rumor(&event).await {
log::error!("Failed to extract rumor: {e}");
}
}
_ => {}
}
@@ -428,14 +340,14 @@ impl AppState {
event_id, message, ..
} => {
let msg = MachineReadablePrefix::parse(&message);
let mut event_tracker = self.event_tracker.write().await;
let mut tracker = self.event_tracker.write().await;
// Keep track of events sent by Coop
event_tracker.sent_ids.insert(event_id);
tracker.sent_ids.insert(event_id);
// Keep track of events that need to be resend after auth
if let Some(MachineReadablePrefix::AuthRequired) = msg {
event_tracker.resend_queue.insert(event_id, relay_url);
tracker.resend_queue.insert(event_id, relay_url);
}
}
_ => {}
@@ -504,6 +416,47 @@ impl AppState {
}
}
/// Encrypt and store a key in the local database.
pub async fn set_keys(&self, kind: impl Into<String>, value: String) -> Result<(), Error> {
let signer = self.client.signer().await?;
let public_key = signer.get_public_key().await?;
// Encrypt the value
let content = signer.nip44_encrypt(&public_key, value.as_ref()).await?;
// Construct the application data event
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
.tag(Tag::identifier(format!("coop:{}", kind.into())))
.build(public_key)
.sign(&Keys::generate())
.await?;
// Save the event to the database
self.client.database().save_event(&event).await?;
Ok(())
}
/// Get and decrypt a key from the local database.
pub async fn get_keys(&self, kind: impl Into<String>) -> Result<Keys, Error> {
let signer = self.client.signer().await?;
let public_key = signer.get_public_key().await?;
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(format!("coop:{}", kind.into()));
if let Some(event) = self.client.database().query(filter).await?.first() {
let content = signer.nip44_decrypt(&public_key, &event.content).await?;
let secret = SecretKey::parse(&content)?;
let keys = Keys::new(secret);
Ok(keys)
} else {
Err(anyhow!("Key not found"))
}
}
/// Check if event is published by current user
async fn is_self_authored(&self, event: &Event) -> Result<bool, Error> {
let signer = self.client.signer().await?;
@@ -552,7 +505,7 @@ impl AppState {
/// Get and verify NIP-65 relays for a given public key
pub async fn get_nip65(&self, public_key: PublicKey) -> Result<(), Error> {
let timeout = Duration::from_secs(TIMEOUT);
let timeout = Duration::from_secs(QUERY_TIMEOUT);
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
@@ -608,9 +561,269 @@ impl AppState {
Ok(())
}
/// Initialize the client keys to communicate between clients
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn init_client_keys(&self) -> Result<(), Error> {
// Get the keys from the database or generate new ones
let keys = self
.get_keys("client")
.await
.unwrap_or_else(|_| Keys::generate());
// Initialize the client keys
let mut device = self.device.write().await;
device.client_keys = Some(Arc::new(keys));
Ok(())
}
/// Get and verify encryption announcement for a given public key
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn get_announcement(&self, public_key: PublicKey) -> Result<(), Error> {
let timeout = Duration::from_secs(QUERY_TIMEOUT);
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
.kind(Kind::Custom(10044))
.author(public_key)
.limit(1);
// Subscribe to events from user's nip65 relays
self.client.subscribe(filter.clone(), Some(opts)).await?;
let tx = self.signal.sender().clone();
let database = self.client.database().clone();
// Verify the received data after a timeout
smol::spawn(async move {
smol::Timer::after(timeout).await;
if database.count(filter).await.unwrap_or(0) < 1 {
tx.send_async(SignalKind::EncryptionNotSet).await.ok();
}
})
.detach();
Ok(())
}
/// Generate encryption keys and announce them
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn init_encryption_keys(&self) -> Result<(), Error> {
let signer = self.client.signer().await?;
let keys = Keys::generate();
let public_key = keys.public_key();
let secret = keys.secret_key().to_secret_hex();
// Initialize the encryption keys
let mut device = self.device.write().await;
device.encryption_keys = Some(Arc::new(keys));
// Store the encryption keys for future use
self.set_keys("encryption", secret).await?;
// Construct the announcement event
let event = EventBuilder::new(Kind::Custom(10044), "")
.tags(vec![
Tag::client(app_name()),
Tag::custom(TagKind::custom("n"), vec![public_key]),
])
.sign(&signer)
.await?;
// Send the announcement event to the relays
self.client.send_event(&event).await?;
Ok(())
}
/// User has previously set encryption keys, load them from storage
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn load_encryption_keys(&self, announcement: &Announcement) -> Result<(), Error> {
let keys = self.get_keys("encryption").await?;
// Check if the encryption keys match the announcement
if announcement.public_key() == keys.public_key() {
let mut device = self.device.write().await;
device.encryption_keys = Some(Arc::new(keys));
Ok(())
} else {
Err(anyhow!("Not found"))
}
}
/// Request encryption keys from other clients
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn request_encryption_keys(&self) -> Result<bool, Error> {
let mut wait_for_approval = false;
let device = self.device.read().await;
// Client Keys are always known at this point
let Some(client_keys) = device.client_keys.as_ref() else {
return Err(anyhow!("Client Keys is required"));
};
let signer = self.client.signer().await?;
let public_key = signer.get_public_key().await?;
let client_pubkey = client_keys.get_public_key().await?;
// Get the encryption keys response from the database first
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.pubkey(client_pubkey)
.limit(1);
match self.client.database().query(filter).await?.first_owned() {
// Found encryption keys that shared by other clients
Some(event) => {
let root_device = event
.tags
.find(TagKind::custom("P"))
.and_then(|tag| tag.content())
.and_then(|content| PublicKey::parse(content).ok())
.context("Invalid event's tags")?;
let payload = event.content.as_str();
let decrypted = client_keys.nip44_decrypt(&root_device, payload).await?;
let secret = SecretKey::from_hex(&decrypted)?;
let keys = Keys::new(secret);
// No longer need to hold the reader for device
drop(device);
let mut device = self.device.write().await;
device.encryption_keys = Some(Arc::new(keys));
}
None => {
// Construct encryption keys request event
let event = EventBuilder::new(Kind::Custom(4454), "")
.tags(vec![
Tag::client(app_name()),
Tag::custom(TagKind::custom("pubkey"), vec![client_pubkey]),
])
.sign(&signer)
.await?;
// Send a request for encryption keys from other devices
self.client.send_event(&event).await?;
// Create a unique ID to control the subscription later
let subscription_id = SubscriptionId::new("request");
let filter = Filter::new()
.kind(Kind::Custom(4455))
.author(public_key)
.pubkey(client_pubkey)
.since(Timestamp::now());
// Subscribe to the approval response event
self.client
.subscribe_with_id(subscription_id, filter, None)
.await?;
wait_for_approval = true;
}
}
Ok(wait_for_approval)
}
/// Receive the encryption keys from other clients
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn receive_encryption_keys(&self, res: Response) -> Result<(), Error> {
let device = self.device.read().await;
// Client Keys are always known at this point
let Some(client_keys) = device.client_keys.as_ref() else {
return Err(anyhow!("Client Keys is required"));
};
let public_key = res.public_key();
let payload = res.payload();
// Decrypt the payload using the client keys
let decrypted = client_keys.nip44_decrypt(&public_key, payload).await?;
let secret = SecretKey::parse(&decrypted)?;
let keys = Keys::new(secret);
// No longer need to hold the reader for device
drop(device);
let mut device = self.device.write().await;
device.encryption_keys = Some(Arc::new(keys));
Ok(())
}
/// Response the encryption keys request from other clients
///
/// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
pub async fn response_encryption_keys(&self, target: PublicKey) -> Result<(), Error> {
let device = self.device.read().await;
// Client Keys are always known at this point
let Some(client_keys) = device.client_keys.as_ref() else {
return Err(anyhow!("Client Keys is required"));
};
let encryption = self.get_keys("encryption").await?;
let client_pubkey = client_keys.get_public_key().await?;
// Encrypt the encryption keys with the client's signer
let payload = client_keys
.nip44_encrypt(&target, &encryption.secret_key().to_secret_hex())
.await?;
// Construct the response event
//
// P tag: the current client's public key
// p tag: the requester's public key
let event = EventBuilder::new(Kind::Custom(4455), payload)
.tags(vec![
Tag::custom(TagKind::custom("P"), vec![client_pubkey]),
Tag::public_key(target),
])
.sign(client_keys)
.await?;
// Get the current user's signer and public key
let signer = self.client.signer().await?;
let public_key = signer.get_public_key().await?;
// Get the current user's relay list
let urls: Vec<RelayUrl> = self
.client
.database()
.relay_list(public_key)
.await?
.into_iter()
.filter_map(|(url, metadata)| {
if metadata.is_none() || metadata == Some(RelayMetadata::Read) {
Some(url)
} else {
None
}
})
.collect();
// Send the response event to the user's relay list
self.client.send_event_to(urls, &event).await?;
Ok(())
}
/// Get and verify NIP-17 relays for a given public key
pub async fn get_nip17(&self, public_key: PublicKey) -> Result<(), Error> {
let timeout = Duration::from_secs(TIMEOUT);
let timeout = Duration::from_secs(QUERY_TIMEOUT);
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new()
@@ -685,33 +898,87 @@ impl AppState {
Ok(())
}
/// Gets messaging relays for public key
pub async fn messaging_relays(&self, public_key: PublicKey) -> Vec<RelayUrl> {
let mut relay_urls = vec![];
let filter = Filter::new()
.kind(Kind::InboxRelays)
.author(public_key)
.limit(1);
if let Ok(events) = self.client.database().query(filter).await {
if let Some(event) = events.first_owned() {
let urls: Vec<RelayUrl> = nip17::extract_owned_relay_list(event).collect();
// Connect to relays
for url in urls.iter() {
self.client.add_relay(url).await.ok();
self.client.connect_relay(url).await.ok();
}
relay_urls.extend(urls.into_iter().take(3));
}
}
relay_urls
}
/// Re-subscribes to gift wrap events
pub async fn resubscribe_messages(&self) -> Result<(), Error> {
let signer = self.client.signer().await?;
let public_key = signer.get_public_key().await?;
let urls = self.messaging_relays(public_key).await;
let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
let id = SubscriptionId::new("inbox");
// Unsubscribe the previous subscription
self.client.unsubscribe(&id).await;
// Subscribe to gift wrap events
self.client
.subscribe_with_id_to(urls, id, filter, None)
.await?;
Ok(())
}
/// Stores an unwrapped event in local database with reference to original
async fn set_rumor(&self, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> {
let rumor_id = rumor
.id
.ok_or_else(|| anyhow!("Rumor is missing an event id"))?;
let author_hex = rumor.pubkey.to_hex();
let conversation = Self::conversation_id(rumor).to_string();
let rumor_id = rumor.id.context("Rumor is missing an event id")?;
let author = rumor.pubkey;
let conversation = self.conversation_id(rumor).to_string();
let mut tags = rumor.tags.clone().to_vec();
// Add a unique identifier
tags.push(Tag::identifier(id));
// Add a reference to the rumor's author
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)),
[author_hex],
[author],
));
// Add a conversation id
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)),
[conversation],
));
// Add a reference to the rumor's id
tags.push(Tag::event(rumor_id));
// Add references to the rumor's participants
for receiver in rumor.tags.public_keys().copied() {
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
[receiver.to_hex()],
[receiver],
));
}
// Convert rumor to json
let content = rumor.as_json();
let event = EventBuilder::new(Kind::ApplicationSpecificData, content)
@@ -739,34 +1006,55 @@ impl AppState {
}
// Unwraps a gift-wrapped event and processes its contents.
async fn extract_rumor(&self, gift_wrap: &Event) {
let mut rumor: Option<UnsignedEvent> = None;
async fn extract_rumor(&self, gift_wrap: &Event) -> Result<(), Error> {
// Try to get cached rumor first
if let Ok(event) = self.get_rumor(gift_wrap.id).await {
rumor = Some(event);
} else if let Ok(unwrapped) = self.client.unwrap_gift_wrap(gift_wrap).await {
self.process_rumor(gift_wrap.id, event).await?;
return Ok(());
}
// Try to unwrap with the available signer
if let Ok(unwrapped) = self.try_unwrap_gift(gift_wrap).await {
let sender = unwrapped.sender;
let mut rumor_unsigned = unwrapped.rumor;
if !Self::verify_rumor_sender(sender, &rumor_unsigned) {
log::warn!(
"Ignoring gift wrap {}: seal pubkey {} mismatches rumor pubkey {}",
gift_wrap.id,
sender,
rumor_unsigned.pubkey
);
} else {
if !self.verify_rumor_sender(sender, &rumor_unsigned) {
return Err(anyhow!("Invalid rumor"));
};
// Generate event id for the rumor if it doesn't have one
rumor_unsigned.ensure_id();
if let Err(e) = self.set_rumor(gift_wrap.id, &rumor_unsigned).await {
log::warn!("Failed to cache unwrapped event: {e}")
} else {
rumor = Some(rumor_unsigned);
self.set_rumor(gift_wrap.id, &rumor_unsigned).await?;
self.process_rumor(gift_wrap.id, rumor_unsigned).await?;
return Ok(());
}
Ok(())
}
// Helper method to try unwrapping with different signers
async fn try_unwrap_gift(&self, gift_wrap: &Event) -> Result<UnwrappedGift, Error> {
// Try to unwrap with the device's encryption keys first
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
if let Some(signer) = self.device.read().await.encryption_keys.as_ref() {
if let Ok(unwrapped) = UnwrappedGift::from_gift_wrap(signer, gift_wrap).await {
return Ok(unwrapped);
}
}
if let Some(event) = rumor {
// Try to unwrap with the user's signer
let signer = self.client.signer().await?;
if let Ok(unwrapped) = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await {
return Ok(unwrapped);
}
Err(anyhow!("No signer available"))
}
/// Process a rumor event.
async fn process_rumor(&self, id: EventId, event: UnsignedEvent) -> Result<(), Error> {
// Send all pubkeys to the metadata batch to sync data
for public_key in event.tags.public_keys().copied() {
self.ingester.send(public_key).await;
@@ -775,53 +1063,92 @@ impl AppState {
match event.created_at >= self.initialized_at {
// New message: send a signal to notify the UI
true => {
self.signal
.send(SignalKind::NewMessage((gift_wrap.id, event)))
.await;
self.signal.send(SignalKind::NewMessage((id, event))).await;
}
// Old message: Coop is probably processing the user's messages during initial load
false => {
self.gift_wrap_processing.store(true, Ordering::Release);
}
}
}
Ok(())
}
fn conversation_id(rumor: &UnsignedEvent) -> u64 {
/// Get the conversation ID for a given rumor (message).
fn conversation_id(&self, rumor: &UnsignedEvent) -> u64 {
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<PublicKey> = rumor.tags.public_keys().copied().collect();
pubkeys.push(rumor.pubkey);
pubkeys.sort();
pubkeys.dedup();
pubkeys.hash(&mut hasher);
hasher.finish()
}
fn verify_rumor_sender(sender: PublicKey, rumor: &UnsignedEvent) -> bool {
/// Verify that the sender of a rumor is the same as the sender of the event.
fn verify_rumor_sender(&self, sender: PublicKey, rumor: &UnsignedEvent) -> bool {
rumor.pubkey == sender
}
/// Extract an encryption keys announcement from an event.
fn extract_announcement(&self, event: &Event) -> Result<Announcement, Error> {
let public_key = event
.tags
.iter()
.find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "pubkey")
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())
.context("Cannot parse public key from the event's tags")?;
let client_name = event
.tags
.find(TagKind::Client)
.and_then(|tag| tag.content())
.map(|c| c.to_string())
.context("Cannot parse client name from the event's tags")?;
Ok(Announcement::new(event.id, client_name, public_key))
}
/// Extract an encryption keys response from an event.
fn extract_response(&self, event: &Event) -> Result<Response, Error> {
let payload = event.content.clone();
let root_device = event
.tags
.find(TagKind::custom("P"))
.and_then(|tag| tag.content())
.and_then(|c| PublicKey::parse(c).ok())
.context("Cannot parse public key from the event's tags")?;
Ok(Response::new(payload, root_device))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_state;
#[test]
fn verify_rumor_sender_accepts_matching_sender() {
let state = app_state();
let keys = Keys::generate();
let public_key = keys.public_key();
let rumor = EventBuilder::text_note("hello").build(public_key);
assert!(AppState::verify_rumor_sender(public_key, &rumor));
assert!(state.verify_rumor_sender(public_key, &rumor));
}
#[test]
fn verify_rumor_sender_rejects_mismatched_sender() {
let state = app_state();
let sender_keys = Keys::generate();
let rumor_keys = Keys::generate();
let rumor = EventBuilder::text_note("spoof").build(rumor_keys.public_key());
assert!(!AppState::verify_rumor_sender(
sender_keys.public_key(),
&rumor
));
assert!(!state.verify_rumor_sender(sender_keys.public_key(), &rumor));
}
}

View File

@@ -0,0 +1,157 @@
use flume::{Receiver, Sender};
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AuthRequest {
pub url: RelayUrl,
pub challenge: String,
pub sending: bool,
}
impl AuthRequest {
pub fn new(challenge: impl Into<String>, url: RelayUrl) -> Self {
Self {
challenge: challenge.into(),
sending: false,
url,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum UnwrappingStatus {
#[default]
Initialized,
Processing,
Complete,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Announcement {
id: EventId,
client: String,
public_key: PublicKey,
}
impl Announcement {
pub fn new(id: EventId, client_name: String, public_key: PublicKey) -> Self {
Self {
id,
client: client_name,
public_key,
}
}
pub fn id(&self) -> EventId {
self.id
}
pub fn public_key(&self) -> PublicKey {
self.public_key
}
pub fn client(&self) -> &str {
self.client.as_str()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Response {
payload: String,
public_key: PublicKey,
}
impl Response {
pub fn new(payload: String, public_key: PublicKey) -> Self {
Self {
payload,
public_key,
}
}
pub fn public_key(&self) -> PublicKey {
self.public_key
}
pub fn payload(&self) -> &str {
self.payload.as_str()
}
}
/// Signals sent through the global event channel to notify UI
#[derive(Debug)]
pub enum SignalKind {
/// NIP-4e
///
/// A signal to notify UI that the user has not set encryption keys yet
EncryptionNotSet,
/// NIP-4e
///
/// A signal to notify UI that the user has set encryption keys
EncryptionSet(Announcement),
/// NIP-4e
///
/// A signal to notify UI that the user has responded to an encryption request
EncryptionResponse(Response),
/// NIP-4e
///
/// A signal to notify UI that the user has requested encryption keys from other devices
EncryptionRequest(Announcement),
/// A signal to notify UI that the client's signer has been set
SignerSet(PublicKey),
/// A signal to notify UI that the relay requires authentication
Auth(AuthRequest),
/// A signal to notify UI that a new profile has been received
NewProfile(Profile),
/// A signal to notify UI that a new gift wrap event has been received
NewMessage((EventId, UnsignedEvent)),
/// A signal to notify UI that no messaging relays for current user was found
MessagingRelaysNotFound,
/// A signal to notify UI that no gossip relays for current user was found
GossipRelaysNotFound,
/// A signal to notify UI that gift wrap status has changed
GiftWrapStatus(UnwrappingStatus),
}
#[derive(Debug, Clone)]
pub struct Signal {
rx: Receiver<SignalKind>,
tx: Sender<SignalKind>,
}
impl Default for Signal {
fn default() -> Self {
Self::new()
}
}
impl Signal {
pub fn new() -> Self {
let (tx, rx) = flume::bounded::<SignalKind>(2048);
Self { rx, tx }
}
pub fn receiver(&self) -> &Receiver<SignalKind> {
&self.rx
}
pub fn sender(&self) -> &Sender<SignalKind> {
&self.tx
}
pub async fn send(&self, kind: SignalKind) {
if let Err(e) = self.tx.send_async(kind).await {
log::error!("Failed to send signal: {e}");
}
}
}

View File

@@ -0,0 +1,36 @@
use std::collections::{HashMap, HashSet};
use nostr_sdk::prelude::*;
#[derive(Debug, Clone, Default)]
pub struct EventTracker {
/// Tracking events that have been resent by Coop in the current session
pub resent_ids: Vec<Output<EventId>>,
/// Temporarily store events that need to be resent later
pub resend_queue: HashMap<EventId, RelayUrl>,
/// Tracking events sent by Coop in the current session
pub sent_ids: HashSet<EventId>,
/// Tracking events seen on which relays in the current session
pub seen_on_relays: HashMap<EventId, HashSet<RelayUrl>>,
}
impl EventTracker {
pub fn resent_ids(&self) -> &Vec<Output<EventId>> {
&self.resent_ids
}
pub fn resend_queue(&self) -> &HashMap<EventId, RelayUrl> {
&self.resend_queue
}
pub fn sent_ids(&self) -> &HashSet<EventId> {
&self.sent_ids
}
pub fn seen_on_relays(&self) -> &HashMap<EventId, HashSet<RelayUrl>> {
&self.seen_on_relays
}
}

View File

@@ -76,6 +76,7 @@ pub trait PopupMenuExt: Styled + Selectable + InteractiveElement + IntoElement +
impl PopupMenuExt for Button {}
enum PopupMenuItem {
Title(SharedString),
Separator,
Item {
icon: Option<Icon>,
@@ -314,6 +315,20 @@ impl PopupMenu {
self
}
/// Add a title menu item
pub fn title(mut self, label: impl Into<SharedString>) -> Self {
if self.menu_items.is_empty() {
return self;
}
if let Some(PopupMenuItem::Title(_)) = self.menu_items.last() {
return self;
}
self.menu_items.push(PopupMenuItem::Title(label.into()));
self
}
/// Add a separator Menu Item
pub fn separator(mut self) -> Self {
if self.menu_items.is_empty() {
@@ -588,6 +603,15 @@ impl Render for PopupMenu {
}));
match item {
PopupMenuItem::Title(label) => {
this.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(label.clone())
)
},
PopupMenuItem::Separator => this.h_auto().p_0().disabled(true).child(
div()
.rounded_none()

View File

@@ -59,6 +59,10 @@ common:
en: "Use default"
configure:
en: "Configure"
hide:
en: "Hide"
reset:
en: "Reset"
keyring_disable:
label:
@@ -74,6 +78,30 @@ keyring_disable:
body_5:
en: "By clicking continue, you agree to store your credentials as plain text."
pending_encryption:
label:
en: "Wait for Approval"
body_1:
en: "Please open %{c} and approve the request for sharing encryption keys. Without access to them, Coop cannot decrypt your messages that are encrypted with encryption keys."
body_2:
en: "Or you can click the 'Reset' button to reset the encryption keys."
body_3:
en: "By resetting the encryption keys, you will not be able to view your messages that were encrypted with the old encryption keys."
request_encryption:
label:
en: "Encryption Keys Request"
body:
en: "You've requested for the encryption keys from:"
encryption:
notice:
en: "Encryption keys are being generated"
success:
en: "Encryption keys have been successfully set up"
reinit:
en: "Encryption keys are being reinitialized"
auto_update:
updating:
en: "Installing the new update..."
@@ -381,6 +409,8 @@ chat:
en: "Sent Reports"
nip17_not_found:
en: "%{u} has not set up Messaging Relays, so they won't receive your message."
device_not_found:
en: "You're sending with an encryption key, but %{u} has not set up an encryption key yet. Try sending with your identity instead."
sidebar:
reload_menu: