diff --git a/src-common/src/lib.rs b/src-common/src/lib.rs index 2f65640..3873d5e 100644 --- a/src-common/src/lib.rs +++ b/src-common/src/lib.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use specta::Type; -pub const VERSION: u8 = 1; +pub const VERSION: u8 = 2; pub const MAX_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024; pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024; pub const MAX_IMAGE_DIMENSION: u32 = 480; @@ -32,6 +32,10 @@ pub enum ClientMessage { }, SyncFriendProfiles, SyncFriendStatuses, + ResolveProfile { + request_id: String, + user_id: String, + }, Signed { payload: String, signature: String, @@ -107,6 +111,10 @@ pub enum ServerMessage { FriendProfiles { profiles: Vec, }, + ProfileResolved { + request_id: String, + profile: Option, + }, FriendStatusChanged { friend_id: String, online: bool, diff --git a/src-server/src/network/mod.rs b/src-server/src/network/mod.rs index 479245b..c7161dd 100644 --- a/src-server/src/network/mod.rs +++ b/src-server/src/network/mod.rs @@ -13,19 +13,17 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use futures_util::{SinkExt, StreamExt}; use tokio::sync::{Mutex, mpsc}; use uuid::Uuid; -use wyd_common::{ - ClientMessage, Profile, ServerMessage, message_bytes, profile_bytes, register_bytes, -}; +use wyd_common::{ClientMessage, Profile, ServerMessage, message_bytes, register_bytes}; mod interactions; mod presence; +mod profiles; type Clients = Arc>>; struct Client { connection_id: Uuid, key: VerifyingKey, - #[allow(dead_code)] // Used when presence and profile lookup are exposed. profile: Profile, friends: Vec, sender: mpsc::Sender, @@ -102,6 +100,7 @@ async fn connected(mut socket: WebSocket, clients: Clients) { presence::disconnected(&clients, &public_key, connection_id).await; return; } + profiles::broadcast(&clients, &public_key, connection_id).await; presence::connected(&clients, &public_key, previous_friends).await; let (mut writer, mut reader) = socket.split(); @@ -148,7 +147,7 @@ async fn connected(mut socket: WebSocket, clients: Clients) { } } Ok(ClientMessage::ProfileUpdated { profile, signature }) => { - if !update_profile(&clients, &public_key, connection_id, profile, &signature).await { + if !profiles::update(&clients, &public_key, connection_id, profile, &signature).await { break; } } @@ -166,9 +165,16 @@ async fn connected(mut socket: WebSocket, clients: Clients) { if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() { break; } + let Some(profiles) = profiles::snapshot(&clients, &public_key, connection_id).await else { + break; + }; + let message = ServerMessage::FriendProfiles { profiles }; + if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() { + break; + } } Ok(ClientMessage::SyncFriendProfiles) => { - let Some(profiles) = friend_profiles(&clients, &public_key, connection_id).await else { + let Some(profiles) = profiles::snapshot(&clients, &public_key, connection_id).await else { break; }; let message = ServerMessage::FriendProfiles { profiles }; @@ -185,6 +191,23 @@ async fn connected(mut socket: WebSocket, clients: Clients) { break; } } + Ok(ClientMessage::ResolveProfile { request_id, user_id }) => { + let Some(profile) = profiles::resolve( + &clients, + &public_key, + connection_id, + &user_id, + ).await else { + break; + }; + let message = ServerMessage::ProfileResolved { + request_id, + profile, + }; + if writer.send(Message::Text(serde_json::to_string(&message).unwrap().into())).await.is_err() { + break; + } + } _ => break, } Some(Ok(Message::Ping(data))) => { @@ -199,63 +222,6 @@ async fn connected(mut socket: WebSocket, clients: Clients) { presence::disconnected(&clients, &public_key, connection_id).await; } -async fn update_profile( - clients: &Clients, - public_key: &str, - connection_id: Uuid, - profile: Profile, - signature: &str, -) -> bool { - let mut clients = clients.lock().await; - { - let Some(client) = clients - .get_mut(public_key) - .filter(|client| client.connection_id == connection_id) - else { - return false; - }; - if profile.id != public_key || !verify(&client.key, &profile_bytes(&profile), signature) { - return false; - } - client.profile = profile.clone(); - } - - let recipients: Vec<_> = clients - .values() - .filter(|client| client.friends.iter().any(|friend| friend == public_key)) - .map(|client| client.sender.clone()) - .collect(); - drop(clients); - - let message = Message::Text( - serde_json::to_string(&ServerMessage::FriendProfileUpdated { profile }) - .unwrap() - .into(), - ); - for recipient in recipients { - let _ = recipient.send(message.clone()).await; - } - true -} - -async fn friend_profiles( - clients: &Clients, - public_key: &str, - connection_id: Uuid, -) -> Option> { - let clients = clients.lock().await; - let client = clients - .get(public_key) - .filter(|client| client.connection_id == connection_id)?; - let mut profiles: Vec<_> = client - .friends - .iter() - .filter_map(|friend| clients.get(friend).map(|client| client.profile.clone())) - .collect(); - profiles.sort_by(|a, b| a.id.cmp(&b.id)); - Some(profiles) -} - async fn relay_live_data( clients: &Clients, public_key: &str, @@ -366,105 +332,6 @@ mod tests { )); } - #[tokio::test] - async fn authenticated_profile_update_changes_the_registered_client() { - let signing_key = SigningKey::from_bytes(&[7; 32]); - let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()); - let connection_id = Uuid::new_v4(); - let (sender, _receiver) = mpsc::channel(1); - let (friend_sender, mut friend_receiver) = mpsc::channel(1); - let clients = Clients::default(); - { - let mut clients = clients.lock().await; - clients.insert( - public_key.clone(), - Client { - connection_id, - key: signing_key.verifying_key(), - profile: Profile { - id: public_key.clone(), - display_name: "Old".to_owned(), - }, - friends: Vec::new(), - sender, - }, - ); - clients.insert( - "friend".to_owned(), - Client { - connection_id: Uuid::new_v4(), - key: signing_key.verifying_key(), - profile: Profile { - id: "friend".to_owned(), - display_name: "Friend".to_owned(), - }, - friends: vec![public_key.clone()], - sender: friend_sender, - }, - ); - } - let profile = Profile { - id: public_key.clone(), - display_name: "New".to_owned(), - }; - let signature = - URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&profile)).to_bytes()); - - assert!(update_profile(&clients, &public_key, connection_id, profile, &signature,).await); - assert_eq!( - clients - .lock() - .await - .get(&public_key) - .unwrap() - .profile - .display_name, - "New" - ); - let announcement = friend_receiver - .recv() - .await - .expect("friend receives update"); - let Message::Text(announcement) = announcement else { - panic!("expected text announcement"); - }; - let ServerMessage::FriendProfileUpdated { profile } = - serde_json::from_str(&announcement).expect("decode announcement") - else { - panic!("expected friend profile update"); - }; - assert_eq!(profile.id, public_key); - assert_eq!(profile.display_name, "New"); - - let stale_profile = Profile { - id: public_key.clone(), - display_name: "Stale".to_owned(), - }; - let stale_signature = - URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&stale_profile)).to_bytes()); - assert!( - !update_profile( - &clients, - &public_key, - Uuid::new_v4(), - stale_profile, - &stale_signature, - ) - .await - ); - assert!(friend_receiver.try_recv().is_err()); - assert_eq!( - clients - .lock() - .await - .get(&public_key) - .unwrap() - .profile - .display_name, - "New" - ); - } - #[tokio::test] async fn authenticated_friend_update_changes_only_ids() { let signing_key = SigningKey::from_bytes(&[7; 32]); @@ -506,64 +373,6 @@ mod tests { ); } - #[tokio::test] - async fn profile_sync_returns_only_connected_friends_for_the_current_session() { - let signing_key = SigningKey::from_bytes(&[7; 32]); - let connection_id = Uuid::new_v4(); - let (sender, _receiver) = mpsc::channel(1); - let clients = Clients::default(); - let mut registry = clients.lock().await; - registry.insert( - "requester".to_owned(), - Client { - connection_id, - key: signing_key.verifying_key(), - profile: Profile { - id: "requester".to_owned(), - display_name: "Requester".to_owned(), - }, - friends: vec![ - "friend-b".to_owned(), - "offline".to_owned(), - "friend-a".to_owned(), - ], - sender: sender.clone(), - }, - ); - for (id, display_name) in [("friend-a", "Alice"), ("friend-b", "Bob")] { - registry.insert( - id.to_owned(), - Client { - connection_id: Uuid::new_v4(), - key: signing_key.verifying_key(), - profile: Profile { - id: id.to_owned(), - display_name: display_name.to_owned(), - }, - friends: Vec::new(), - sender: sender.clone(), - }, - ); - } - drop(registry); - - let profiles = friend_profiles(&clients, "requester", connection_id) - .await - .expect("current session"); - assert_eq!( - profiles - .iter() - .map(|profile| (profile.id.as_str(), profile.display_name.as_str())) - .collect::>(), - [("friend-a", "Alice"), ("friend-b", "Bob")] - ); - assert!( - friend_profiles(&clients, "requester", Uuid::new_v4()) - .await - .is_none() - ); - } - #[tokio::test] async fn presence_sync_and_disconnect_broadcast_require_mutual_friendship() { let signing_key = SigningKey::from_bytes(&[7; 32]); diff --git a/src-server/src/network/profiles.rs b/src-server/src/network/profiles.rs new file mode 100644 index 0000000..9fbf6d3 --- /dev/null +++ b/src-server/src/network/profiles.rs @@ -0,0 +1,374 @@ +use axum::extract::ws::Message; +use tokio::sync::mpsc; +use uuid::Uuid; +use wyd_common::{Profile, ServerMessage, profile_bytes}; + +use super::{Client, Clients, verify}; + +pub(super) async fn update( + clients: &Clients, + public_key: &str, + connection_id: Uuid, + profile: Profile, + signature: &str, +) -> bool { + let mut clients = clients.lock().await; + let Some(client) = clients + .get_mut(public_key) + .filter(|client| client.connection_id == connection_id) + else { + return false; + }; + if profile.id != public_key || !verify(&client.key, &profile_bytes(&profile), signature) { + return false; + } + client.profile = profile.clone(); + let recipients = subscribers(&clients, public_key); + drop(clients); + + send_update(profile, recipients).await; + true +} + +pub(super) async fn broadcast(clients: &Clients, public_key: &str, connection_id: Uuid) { + let clients = clients.lock().await; + let Some(profile) = clients + .get(public_key) + .filter(|client| client.connection_id == connection_id) + .map(|client| client.profile.clone()) + else { + return; + }; + let recipients = subscribers(&clients, public_key); + drop(clients); + + send_update(profile, recipients).await; +} + +pub(super) async fn snapshot( + clients: &Clients, + public_key: &str, + connection_id: Uuid, +) -> Option> { + let clients = clients.lock().await; + let client = clients + .get(public_key) + .filter(|client| client.connection_id == connection_id)?; + let mut profiles: Vec<_> = client + .friends + .iter() + .filter_map(|friend| clients.get(friend).map(|client| client.profile.clone())) + .collect(); + profiles.sort_by(|a, b| a.id.cmp(&b.id)); + Some(profiles) +} + +pub(super) async fn resolve( + clients: &Clients, + requester_id: &str, + connection_id: Uuid, + user_id: &str, +) -> Option> { + let clients = clients.lock().await; + clients + .get(requester_id) + .filter(|client| client.connection_id == connection_id)?; + Some(clients.get(user_id).map(|client| client.profile.clone())) +} + +fn subscribers( + clients: &std::collections::HashMap, + user_id: &str, +) -> Vec> { + clients + .iter() + .filter(|(id, client)| { + id.as_str() != user_id && client.friends.iter().any(|friend| friend == user_id) + }) + .map(|(_, client)| client.sender.clone()) + .collect() +} + +async fn send_update(profile: Profile, recipients: Vec>) { + let message = Message::Text( + serde_json::to_string(&ServerMessage::FriendProfileUpdated { profile }) + .expect("friend profile update serializes") + .into(), + ); + for recipient in recipients { + let _ = recipient.send(message.clone()).await; + } +} + +#[cfg(test)] +mod tests { + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use ed25519_dalek::{Signer, SigningKey}; + + use super::*; + + fn client( + signing_key: &SigningKey, + id: &str, + display_name: &str, + connection_id: Uuid, + friends: Vec, + sender: mpsc::Sender, + ) -> Client { + Client { + connection_id, + key: signing_key.verifying_key(), + profile: Profile { + id: id.to_owned(), + display_name: display_name.to_owned(), + }, + friends, + sender, + } + } + + #[tokio::test] + async fn authenticated_update_changes_the_registered_client() { + let signing_key = SigningKey::from_bytes(&[7; 32]); + let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()); + let connection_id = Uuid::new_v4(); + let (sender, _receiver) = mpsc::channel(1); + let (friend_sender, mut friend_receiver) = mpsc::channel(1); + let clients = Clients::default(); + { + let mut clients = clients.lock().await; + clients.insert( + public_key.clone(), + client( + &signing_key, + &public_key, + "Old", + connection_id, + Vec::new(), + sender, + ), + ); + clients.insert( + "friend".to_owned(), + client( + &signing_key, + "friend", + "Friend", + Uuid::new_v4(), + vec![public_key.clone()], + friend_sender, + ), + ); + } + let profile = Profile { + id: public_key.clone(), + display_name: "New".to_owned(), + }; + let signature = + URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&profile)).to_bytes()); + + assert!(update(&clients, &public_key, connection_id, profile, &signature).await); + assert_eq!( + clients + .lock() + .await + .get(&public_key) + .unwrap() + .profile + .display_name, + "New" + ); + let Message::Text(announcement) = friend_receiver + .recv() + .await + .expect("friend receives update") + else { + panic!("expected text announcement"); + }; + let ServerMessage::FriendProfileUpdated { profile } = + serde_json::from_str(&announcement).expect("decode announcement") + else { + panic!("expected friend profile update"); + }; + assert_eq!(profile.id, public_key); + assert_eq!(profile.display_name, "New"); + + let stale_profile = Profile { + id: public_key.clone(), + display_name: "Stale".to_owned(), + }; + let stale_signature = + URL_SAFE_NO_PAD.encode(signing_key.sign(&profile_bytes(&stale_profile)).to_bytes()); + assert!( + !update( + &clients, + &public_key, + Uuid::new_v4(), + stale_profile, + &stale_signature, + ) + .await + ); + assert!(friend_receiver.try_recv().is_err()); + } + + #[tokio::test] + async fn first_connection_announces_to_existing_subscribers() { + let signing_key = SigningKey::from_bytes(&[7; 32]); + let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()); + let connection_id = Uuid::new_v4(); + let (source_sender, _source_receiver) = mpsc::channel(1); + let (friend_sender, mut friend_receiver) = mpsc::channel(1); + let (unrelated_sender, mut unrelated_receiver) = mpsc::channel(1); + let clients = Clients::default(); + { + let mut clients = clients.lock().await; + clients.insert( + public_key.clone(), + client( + &signing_key, + &public_key, + "Newly connected", + connection_id, + Vec::new(), + source_sender, + ), + ); + clients.insert( + "friend".to_owned(), + client( + &signing_key, + "friend", + "Friend", + Uuid::new_v4(), + vec![public_key.clone()], + friend_sender, + ), + ); + clients.insert( + "unrelated".to_owned(), + client( + &signing_key, + "unrelated", + "Unrelated", + Uuid::new_v4(), + Vec::new(), + unrelated_sender, + ), + ); + } + + broadcast(&clients, &public_key, connection_id).await; + + let Message::Text(announcement) = friend_receiver + .recv() + .await + .expect("subscriber receives connected profile") + else { + panic!("expected text announcement"); + }; + let ServerMessage::FriendProfileUpdated { profile } = + serde_json::from_str(&announcement).expect("decode announcement") + else { + panic!("expected friend profile update"); + }; + assert_eq!(profile.id, public_key); + assert_eq!(profile.display_name, "Newly connected"); + assert!(unrelated_receiver.try_recv().is_err()); + } + + #[tokio::test] + async fn snapshot_returns_only_connected_friends_for_the_current_session() { + let signing_key = SigningKey::from_bytes(&[7; 32]); + let connection_id = Uuid::new_v4(); + let (sender, _receiver) = mpsc::channel(1); + let clients = Clients::default(); + let mut registry = clients.lock().await; + registry.insert( + "requester".to_owned(), + client( + &signing_key, + "requester", + "Requester", + connection_id, + vec![ + "friend-b".to_owned(), + "offline".to_owned(), + "friend-a".to_owned(), + ], + sender.clone(), + ), + ); + for (id, display_name) in [("friend-a", "Alice"), ("friend-b", "Bob")] { + registry.insert( + id.to_owned(), + client( + &signing_key, + id, + display_name, + Uuid::new_v4(), + Vec::new(), + sender.clone(), + ), + ); + } + drop(registry); + + let profiles = snapshot(&clients, "requester", connection_id) + .await + .expect("current session"); + assert_eq!( + profiles + .iter() + .map(|profile| (profile.id.as_str(), profile.display_name.as_str())) + .collect::>(), + [("friend-a", "Alice"), ("friend-b", "Bob")] + ); + assert!( + snapshot(&clients, "requester", Uuid::new_v4()) + .await + .is_none() + ); + } + + #[tokio::test] + async fn lookup_resolves_connected_users_for_only_the_current_session() { + let signing_key = SigningKey::from_bytes(&[7; 32]); + let connection_id = Uuid::new_v4(); + let (sender, _receiver) = mpsc::channel(1); + let clients = Clients::default(); + { + let mut clients = clients.lock().await; + for (id, name, session) in [ + ("requester", "Requester", connection_id), + ("target", "Resolved name", Uuid::new_v4()), + ] { + clients.insert( + id.to_owned(), + client(&signing_key, id, name, session, Vec::new(), sender.clone()), + ); + } + } + + assert_eq!( + resolve(&clients, "requester", connection_id, "target") + .await + .expect("current requester session") + .expect("connected target") + .display_name, + "Resolved name" + ); + assert!( + resolve(&clients, "requester", connection_id, "offline") + .await + .expect("current requester session") + .is_none() + ); + assert!( + resolve(&clients, "requester", Uuid::new_v4(), "target") + .await + .is_none() + ); + } +} diff --git a/src-tauri/migrations/20260827000000_allow_unknown_friend_profiles.sql b/src-tauri/migrations/20260827000000_allow_unknown_friend_profiles.sql new file mode 100644 index 0000000..1b67b12 --- /dev/null +++ b/src-tauri/migrations/20260827000000_allow_unknown_friend_profiles.sql @@ -0,0 +1,13 @@ +CREATE TABLE friends_with_optional_profiles ( + id TEXT PRIMARY KEY NOT NULL, + display_name TEXT +); + +-- Existing names were entered locally and cannot be distinguished from names +-- learned from a signed remote profile. Keep the relationship, then learn the +-- authoritative display name again from the remote. +INSERT INTO friends_with_optional_profiles (id) +SELECT id FROM friends; + +DROP TABLE friends; +ALTER TABLE friends_with_optional_profiles RENAME TO friends; diff --git a/src-tauri/src/friends/mod.rs b/src-tauri/src/friends/mod.rs index 00ad284..5a31910 100644 --- a/src-tauri/src/friends/mod.rs +++ b/src-tauri/src/friends/mod.rs @@ -1,19 +1,30 @@ use crate::db::{self, AppDatabase}; -use crate::user::User; use serde::{Deserialize, Serialize}; use specta::Type; use tauri::{AppHandle, State}; use tauri_specta::Event; +use crate::keypair::AppKeypair; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, sqlx::FromRow)] +#[serde(rename_all = "camelCase")] +/// A configured public-key relationship with optional cached remote metadata. +/// The display name remains absent until learned from a signed remote profile. +pub struct Friend { + pub id: String, + pub display_name: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[serde(rename_all = "camelCase")] pub struct FriendsChanged { - pub friends: Vec, + pub friends: Vec, } -pub(crate) async fn all(database: &AppDatabase) -> Result, sqlx::Error> { - sqlx::query_as::<_, User>( - "SELECT id, display_name FROM friends ORDER BY display_name COLLATE NOCASE, id", +pub(crate) async fn all(database: &AppDatabase) -> Result, sqlx::Error> { + sqlx::query_as::<_, Friend>( + "SELECT id, display_name FROM friends \ + ORDER BY display_name IS NULL, display_name COLLATE NOCASE, id", ) .fetch_all(database.pool()) .await @@ -35,7 +46,7 @@ async fn update_display_names( let mut changed = false; for profile in profiles { let result = sqlx::query( - "UPDATE friends SET display_name = ?1 WHERE id = ?2 AND display_name != ?1", + "UPDATE friends SET display_name = ?1 WHERE id = ?2 AND display_name IS NOT ?1", ) .bind(&profile.display_name) .bind(&profile.id) @@ -74,11 +85,21 @@ pub(crate) async fn apply_profile_sync( pub async fn create_friend( handle: AppHandle, database: State<'_, AppDatabase>, - friend: User, -) -> Result { - sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, ?2)") + keypair: State<'_, AppKeypair>, + id: String, +) -> Result { + let id = id.trim().to_owned(); + crate::user::validate_id(&id)?; + if id == keypair.public_key() { + return Err("You cannot add your own identification key.".to_owned()); + } + + let friend = Friend { + id, + display_name: None, + }; + sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, NULL)") .bind(&friend.id) - .bind(&friend.display_name) .execute(database.pool()) .await .map_err(db::command_error)?; @@ -93,7 +114,7 @@ pub async fn create_friend( pub async fn list_friends( handle: AppHandle, database: State<'_, AppDatabase>, -) -> Result, String> { +) -> Result, String> { let friends = all(&database).await.map_err(db::command_error)?; FriendsChanged { @@ -110,8 +131,8 @@ pub async fn list_friends( pub async fn get_friend( database: State<'_, AppDatabase>, id: String, -) -> Result, String> { - sqlx::query_as::<_, User>("SELECT id, display_name FROM friends WHERE id = ?1") +) -> Result, String> { + sqlx::query_as::<_, Friend>("SELECT id, display_name FROM friends WHERE id = ?1") .bind(id) .fetch_optional(database.pool()) .await @@ -199,9 +220,9 @@ mod tests { ); assert_eq!( all(&database).await.unwrap(), - [User { + [Friend { id: "friend-id".to_owned(), - display_name: "New".to_owned(), + display_name: Some("New".to_owned()), }] ); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1d8b641..04b98d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -72,6 +72,7 @@ fn specta_builder() -> tauri_specta::Builder { keypair::get_public_key, network::list_statuses, network::list_friend_statuses, + network::resolve_friend_display_name, images::pick_and_send_image, images::send_image_bytes, interactions::send_interaction, diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs index b3e5b4b..035208b 100644 --- a/src-tauri/src/network/mod.rs +++ b/src-tauri/src/network/mod.rs @@ -62,6 +62,7 @@ struct Connection { remote: Remote, sender: mpsc::Sender, interaction_sender: mpsc::Sender, + profile_lookup_sender: mpsc::Sender, task: tauri::async_runtime::JoinHandle<()>, generation: u64, } @@ -73,6 +74,21 @@ struct InteractionRequest { response: oneshot::Sender, } +struct ProfileLookupRequest { + request_id: String, + user_id: String, + response: oneshot::Sender>, +} + +struct ConnectionInputs { + profiles: watch::Receiver, + friends: watch::Receiver>, + keypair: AppKeypair, + live_data: mpsc::Receiver, + interactions: mpsc::Receiver, + profile_lookups: mpsc::Receiver, +} + pub struct Network { connections: Mutex>, statuses: Statuses, @@ -167,6 +183,45 @@ impl Network { Err("Friend is no longer available".to_owned()) } + pub async fn resolve_profile(&self, user_id: String) -> Result, String> { + crate::user::validate_id(&user_id)?; + if user_id == self.keypair.public_key() { + return Err("You cannot add your own identification key.".to_owned()); + } + + let senders: Vec<_> = self + .connections + .lock() + .map_err(|error| error.to_string())? + .values() + .map(|connection| connection.profile_lookup_sender.clone()) + .collect(); + let request_id = uuid::Uuid::new_v4().to_string(); + let mut responses = Vec::new(); + for sender in senders { + let (response, receiver) = oneshot::channel(); + let request = ProfileLookupRequest { + request_id: request_id.clone(), + user_id: user_id.clone(), + response, + }; + if sender.try_send(request).is_ok() { + responses.push(receiver); + } + } + + let mut pending: FuturesUnordered<_> = responses + .into_iter() + .map(|response| tokio::time::timeout(Duration::from_secs(3), response)) + .collect(); + while let Some(result) = pending.next().await { + if let Ok(Ok(Some(display_name))) = result { + return Ok(Some(display_name)); + } + } + Ok(None) + } + fn sync_remotes(&self, handle: &AppHandle, remotes: Vec) -> Result<(), String> { let desired: HashMap<_, _> = remotes .into_iter() @@ -196,6 +251,7 @@ impl Network { let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); let (sender, receiver) = mpsc::channel(32); let (interaction_sender, interaction_receiver) = mpsc::channel(16); + let (profile_lookup_sender, profile_lookup_receiver) = mpsc::channel(16); set_initial( &self.statuses, &remote, @@ -208,11 +264,14 @@ impl Network { self.friend_presence.clone(), remote.clone(), generation, - self.profile.subscribe(), - self.friends.subscribe(), - self.keypair.clone(), - receiver, - interaction_receiver, + ConnectionInputs { + profiles: self.profile.subscribe(), + friends: self.friends.subscribe(), + keypair: self.keypair.clone(), + live_data: receiver, + interactions: interaction_receiver, + profile_lookups: profile_lookup_receiver, + }, )); connections.insert( remote.id.clone(), @@ -220,6 +279,7 @@ impl Network { remote, sender, interaction_sender, + profile_lookup_sender, task, generation, }, @@ -280,11 +340,7 @@ async fn run( friend_presence: Arc, remote: Remote, generation: u64, - mut profiles: watch::Receiver, - mut friends: watch::Receiver>, - keypair: AppKeypair, - mut outgoing: mpsc::Receiver, - mut active_outgoing: mpsc::Receiver, + mut inputs: ConnectionInputs, ) { loop { changed( @@ -300,11 +356,7 @@ async fn run( &friend_presence, &remote, generation, - &mut profiles, - &mut friends, - &keypair, - &mut outgoing, - &mut active_outgoing, + &mut inputs, ) .await { @@ -328,12 +380,16 @@ async fn connect( friend_presence: &FriendPresence, remote: &Remote, generation: u64, - profiles: &mut watch::Receiver, - friends: &mut watch::Receiver>, - keypair: &AppKeypair, - outgoing: &mut mpsc::Receiver, - active_outgoing: &mut mpsc::Receiver, + inputs: &mut ConnectionInputs, ) -> Result<(), Box> { + let ConnectionInputs { + profiles, + friends, + keypair, + live_data, + interactions: active_outgoing, + profile_lookups, + } = inputs; let (socket, _) = tokio_tungstenite::connect_async(url(remote)).await?; let (mut writer, mut reader) = socket.split(); @@ -366,7 +422,7 @@ async fn connect( if !matches!(recv(&mut reader).await?, ServerMessage::Registered) { return Err("server rejected registration".into()); } - while outgoing.try_recv().is_ok() {} + while live_data.try_recv().is_ok() {} while let Ok(request) = active_outgoing.try_recv() { let _ = request .response @@ -383,9 +439,11 @@ async fn connect( ); let mut pending_interactions = HashMap::new(); + let mut pending_profile_lookups: HashMap>)> = + HashMap::new(); loop { tokio::select! { - payload = outgoing.recv() => { + payload = live_data.recv() => { let payload = payload.ok_or("network sender closed")?; send(&mut writer, &ClientMessage::Signed { signature: keypair.sign(&message_bytes(&payload)), @@ -407,6 +465,21 @@ async fn connect( }).await?; pending_interactions.insert(request.interaction_id, request.response); } + request = profile_lookups.recv() => { + let request = request.ok_or("profile lookup sender closed")?; + if request.response.is_closed() { + continue; + } + pending_profile_lookups.retain(|_, (_, response)| !response.is_closed()); + send(&mut writer, &ClientMessage::ResolveProfile { + request_id: request.request_id.clone(), + user_id: request.user_id.clone(), + }).await?; + pending_profile_lookups.insert( + request.request_id, + (request.user_id, request.response), + ); + } changed = profiles.changed() => { changed.map_err(|_| "profile sender closed")?; let current = profiles.borrow_and_update().clone(); @@ -481,6 +554,14 @@ async fn connect( let _ = response.send(status); } } + ServerMessage::ProfileResolved { request_id, profile } => { + if let Some((user_id, response)) = pending_profile_lookups.remove(&request_id) { + let display_name = profile + .filter(|profile| profile.id == user_id) + .map(|profile| profile.display_name); + let _ = response.send(display_name); + } + } _ => {} }, Message::Ping(data) => writer.send(Message::Pong(data)).await?, @@ -512,6 +593,15 @@ pub fn list_friend_statuses(network: State<'_, Network>) -> Result, network.friend_presence.snapshot() } +#[tauri::command] +#[specta::specta] +pub async fn resolve_friend_display_name( + user_id: String, + network: State<'_, Network>, +) -> Result, String> { + network.resolve_profile(user_id.trim().to_owned()).await +} + fn update_friend_presence( handle: &AppHandle, presence: &FriendPresence, @@ -658,7 +748,7 @@ fn url(remote: &Remote) -> String { format!("{scheme}://{address}{port}/v1/ws") } -fn friend_ids(friends: Vec, own_id: &str) -> Vec { +fn friend_ids(friends: Vec, own_id: &str) -> Vec { let mut ids: Vec<_> = friends .into_iter() .map(|friend| friend.id) @@ -672,22 +762,22 @@ fn friend_ids(friends: Vec, own_id: &str) -> Vec { #[cfg(test)] mod tests { use super::friend_ids; - use crate::user::User; + use crate::friends::Friend; #[test] fn friend_ids_discards_display_names_and_normalizes_ids() { let friends = vec![ - User { + Friend { id: "friend-b".to_owned(), - display_name: "Old name".to_owned(), + display_name: Some("Old name".to_owned()), }, - User { + Friend { id: "self".to_owned(), - display_name: "Me".to_owned(), + display_name: None, }, - User { + Friend { id: "friend-a".to_owned(), - display_name: "Any name".to_owned(), + display_name: Some("Any name".to_owned()), }, ]; diff --git a/src-tauri/src/user/mod.rs b/src-tauri/src/user/mod.rs index b43b4d4..48020b8 100644 --- a/src-tauri/src/user/mod.rs +++ b/src-tauri/src/user/mod.rs @@ -1,3 +1,6 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ed25519_dalek::VerifyingKey; use serde::{Deserialize, Serialize}; use specta::Type; @@ -9,3 +12,29 @@ pub struct User { pub id: String, pub display_name: String, } + +pub(crate) fn validate_id(id: &str) -> Result<(), String> { + let bytes = URL_SAFE_NO_PAD + .decode(id) + .map_err(|_| "Identification key is not valid base64url.".to_owned())?; + let bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| "Identification key must encode a 32-byte public key.".to_owned())?; + VerifyingKey::from_bytes(&bytes) + .map(|_| ()) + .map_err(|_| "Identification key is not a valid Ed25519 public key.".to_owned()) +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::SigningKey; + + use super::*; + + #[test] + fn id_must_be_an_ed25519_public_key() { + let valid = URL_SAFE_NO_PAD.encode(SigningKey::from_bytes(&[7; 32]).verifying_key()); + assert!(validate_id(&valid).is_ok()); + assert!(validate_id("not a public key").is_err()); + } +} diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index ccb4b6f..cf4f842 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -5,13 +5,13 @@ export const commands = { -async createFriend(friend: User) : Promise { - return await TAURI_INVOKE("create_friend", { friend }); +async createFriend(id: string) : Promise { + return await TAURI_INVOKE("create_friend", { id }); }, -async listFriends() : Promise { +async listFriends() : Promise { return await TAURI_INVOKE("list_friends"); }, -async getFriend(id: string) : Promise { +async getFriend(id: string) : Promise { return await TAURI_INVOKE("get_friend", { id }); }, async deleteFriend(id: string) : Promise { @@ -47,6 +47,9 @@ async listStatuses() : Promise { async listFriendStatuses() : Promise { return await TAURI_INVOKE("list_friend_statuses"); }, +async resolveFriendDisplayName(userId: string) : Promise { + return await TAURI_INVOKE("resolve_friend_display_name", { userId }); +}, async pickAndSendImage(recipientId: string) : Promise { return await TAURI_INVOKE("pick_and_send_image", { recipientId }); }, @@ -113,10 +116,15 @@ raw: CursorPosition; */ mapped: CursorPosition } export type ForegroundAppChanged = { meta: AppMeta } +/** + * A configured public-key relationship with optional cached remote metadata. + * The display name remains absent until learned from a signed remote profile. + */ +export type Friend = { id: string; displayName: string | null } export type FriendForegroundAppChanged = { friendId: string; meta: AppMeta } export type FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent } export type FriendStatusesChanged = { friendIds: string[] } -export type FriendsChanged = { friends: User[] } +export type FriendsChanged = { friends: Friend[] } export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string } export type NetworkStatusChanged = { statuses: ConnectionStatus[] } export type ProfileChanged = { profile: User } diff --git a/src/lib/components/control-panel/activity-panel.svelte b/src/lib/components/control-panel/activity-panel.svelte index f1ba523..2886b0a 100644 --- a/src/lib/components/control-panel/activity-panel.svelte +++ b/src/lib/components/control-panel/activity-panel.svelte @@ -1,5 +1,5 @@
@@ -20,7 +24,7 @@ {#each $friends as friend (friend.id)} {@const online = $onlineFriendIds.has(friend.id)}
  • - {friend.displayName}: {online ? "Online" : "Offline"} + {friendName(friend, "Unknown friend")}: {online ? "Online" : "Offline"} ({friend.id})
  • {/each} diff --git a/src/lib/components/debug/friends-list.svelte b/src/lib/components/debug/friends-list.svelte index 0d0e355..f29e31b 100644 --- a/src/lib/components/debug/friends-list.svelte +++ b/src/lib/components/debug/friends-list.svelte @@ -1,6 +1,10 @@ Add Friend @@ -46,18 +86,14 @@
    {#if error}{error}{/if} + + Their display name will appear after Wyd learns their profile from a + shared server. + +
    Friend identity - - -
    diff --git a/src/routes/scene/+page.svelte b/src/routes/scene/+page.svelte index b90cada..52c79bd 100644 --- a/src/routes/scene/+page.svelte +++ b/src/routes/scene/+page.svelte @@ -1,7 +1,7 @@