signal_bridge/
contact_manager.rs

1//! Contact management for Radix Relay
2//!
3//! This module provides contact database operations separate from Signal Protocol
4//! encryption concerns. Contacts are identified by RDX fingerprints with optional
5//! user-assigned aliases.
6
7use crate::nostr_identity::NostrIdentity;
8use crate::SignalBridgeError;
9use libsignal_protocol::{DeviceId, IdentityKey, ProtocolAddress, SessionStore};
10use rusqlite::OptionalExtension;
11use sha2::{Digest, Sha256};
12use std::sync::{Arc, Mutex};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15/// Information about a known contact/peer
16#[derive(Clone, Debug)]
17pub struct ContactInfo {
18    /// RDX fingerprint (SHA-256 hash of identity key)
19    pub rdx_fingerprint: String,
20    /// Nostr public key derived from Signal identity
21    pub nostr_pubkey: String,
22    /// User-assigned alias or auto-generated name
23    pub user_alias: Option<String>,
24    /// Whether an active Signal session exists with this contact
25    pub has_active_session: bool,
26}
27
28/// Manages contact database operations separate from Signal Protocol
29pub struct ContactManager {
30    storage: Arc<Mutex<rusqlite::Connection>>,
31}
32
33impl ContactManager {
34    /// Creates a new contact manager with the given database connection
35    pub fn new(storage_connection: Arc<Mutex<rusqlite::Connection>>) -> Self {
36        Self {
37            storage: storage_connection,
38        }
39    }
40
41    /// Adds a contact from their Signal identity key
42    ///
43    /// # Arguments
44    /// * `identity_key` - Peer's Signal Protocol identity key
45    ///
46    /// # Returns
47    /// RDX fingerprint of the added contact
48    pub async fn add_contact_from_identity_key(
49        &mut self,
50        identity_key: &IdentityKey,
51    ) -> Result<String, SignalBridgeError> {
52        let rdx = Self::generate_identity_fingerprint_from_key(identity_key);
53
54        let nostr_pubkey = NostrIdentity::derive_public_key_from_peer_identity(identity_key)?;
55
56        let auto_alias = format!("Unknown-{}", &rdx[4..12]);
57
58        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
59        let conn_lock = self.storage.lock().unwrap();
60
61        conn_lock
62            .execute(
63                "INSERT OR REPLACE INTO contacts
64                 (rdx_fingerprint, nostr_pubkey, user_alias, signal_identity_key, first_seen, last_updated)
65                 VALUES (?1, ?2, ?3, ?4,
66                         COALESCE((SELECT first_seen FROM contacts WHERE rdx_fingerprint = ?1), ?5),
67                         ?5)",
68                rusqlite::params![rdx, nostr_pubkey.to_hex(), auto_alias, identity_key.serialize(), now],
69            )
70            .map_err(|e| SignalBridgeError::Storage(e.to_string()))?;
71
72        Ok(rdx)
73    }
74
75    /// Adds a contact from a prekey bundle
76    ///
77    /// # Arguments
78    /// * `bundle_bytes` - Serialized prekey bundle
79    /// * `user_alias` - Optional user-assigned alias
80    /// * `_session_store` - Session store (unused)
81    ///
82    /// # Returns
83    /// RDX fingerprint of the added contact
84    pub async fn add_contact_from_bundle(
85        &mut self,
86        bundle_bytes: &[u8],
87        user_alias: Option<&str>,
88        _session_store: &mut impl SessionStore,
89    ) -> Result<String, SignalBridgeError> {
90        use crate::SerializablePreKeyBundle;
91
92        let bundle: SerializablePreKeyBundle = bincode::deserialize(bundle_bytes).map_err(|e| {
93            SignalBridgeError::InvalidInput(format!("Failed to deserialize bundle: {}", e))
94        })?;
95
96        let identity_key = IdentityKey::decode(&bundle.identity_key)
97            .map_err(|e| SignalBridgeError::Protocol(e.to_string()))?;
98
99        let nostr_pubkey = NostrIdentity::derive_public_key_from_peer_identity(&identity_key)?;
100
101        let rdx = Self::generate_identity_fingerprint_from_key(&identity_key);
102
103        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
104        let conn_lock = self.storage.lock().unwrap();
105
106        conn_lock
107            .execute(
108                "INSERT OR REPLACE INTO contacts
109                 (rdx_fingerprint, nostr_pubkey, user_alias, signal_identity_key, first_seen, last_updated)
110                 VALUES (?1, ?2, ?3, ?4,
111                         COALESCE((SELECT first_seen FROM contacts WHERE rdx_fingerprint = ?1), ?5),
112                         ?5)",
113                rusqlite::params![rdx, nostr_pubkey.to_hex(), user_alias, bundle.identity_key, now],
114            )
115            .map_err(|e| SignalBridgeError::Storage(e.to_string()))?;
116
117        Ok(rdx)
118    }
119
120    /// Looks up a contact by RDX fingerprint, Nostr pubkey, or alias
121    ///
122    /// # Arguments
123    /// * `identifier` - RDX fingerprint, Nostr pubkey, or user alias
124    /// * `session_store` - Session store to check for active sessions
125    pub async fn lookup_contact(
126        &mut self,
127        identifier: &str,
128        session_store: &mut impl SessionStore,
129    ) -> Result<ContactInfo, SignalBridgeError> {
130        let contact = {
131            let conn_lock = self.storage.lock().unwrap();
132
133            let mut stmt = conn_lock
134                .prepare(
135                    "SELECT rdx_fingerprint, nostr_pubkey, user_alias
136                     FROM contacts
137                     WHERE rdx_fingerprint = ?1 OR user_alias = ?1 OR nostr_pubkey = ?1",
138                )
139                .map_err(|e| SignalBridgeError::Storage(e.to_string()))?;
140
141            stmt.query_row(rusqlite::params![identifier], |row| {
142                Ok(ContactInfo {
143                    rdx_fingerprint: row.get(0)?,
144                    nostr_pubkey: row.get(1)?,
145                    user_alias: row.get(2)?,
146                    has_active_session: false,
147                })
148            })
149            .map_err(|e| SignalBridgeError::Storage(format!("Contact not found: {}", e)))?
150        };
151
152        let address =
153            ProtocolAddress::new(contact.rdx_fingerprint.clone(), DeviceId::new(1).unwrap());
154        let has_session = session_store.load_session(&address).await?.is_some();
155
156        Ok(ContactInfo {
157            has_active_session: has_session,
158            ..contact
159        })
160    }
161
162    /// Assigns or updates an alias for a contact
163    ///
164    /// # Arguments
165    /// * `identifier` - RDX fingerprint, Nostr pubkey, or current alias
166    /// * `new_alias` - New alias to assign
167    /// * `session_store` - Session store for contact lookup
168    pub async fn assign_contact_alias(
169        &mut self,
170        identifier: &str,
171        new_alias: &str,
172        session_store: &mut impl SessionStore,
173    ) -> Result<(), SignalBridgeError> {
174        let contact = self.lookup_contact(identifier, session_store).await?;
175
176        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
177        let conn_lock = self.storage.lock().unwrap();
178
179        // Check if this alias is already assigned to a different contact
180        let existing: Option<String> = conn_lock
181            .query_row(
182                "SELECT rdx_fingerprint FROM contacts WHERE user_alias = ?1",
183                rusqlite::params![new_alias],
184                |row| row.get(0),
185            )
186            .optional()
187            .map_err(|e| SignalBridgeError::Storage(e.to_string()))?;
188
189        if let Some(existing_rdx) = existing {
190            if existing_rdx != contact.rdx_fingerprint {
191                return Err(SignalBridgeError::InvalidInput(format!(
192                    "Alias '{}' is already assigned to contact {}. Remove it first before reassigning.",
193                    new_alias, existing_rdx
194                )));
195            }
196            // If the alias is already assigned to this same contact, that's fine - just update the timestamp
197        }
198
199        // Assign the alias to the target contact
200        let updated = conn_lock
201            .execute(
202                "UPDATE contacts SET user_alias = ?1, last_updated = ?2
203                 WHERE rdx_fingerprint = ?3",
204                rusqlite::params![new_alias, now, contact.rdx_fingerprint],
205            )
206            .map_err(|e| SignalBridgeError::Storage(e.to_string()))?;
207
208        if updated == 0 {
209            return Err(SignalBridgeError::InvalidInput(format!(
210                "Contact not found: {}",
211                identifier
212            )));
213        }
214
215        Ok(())
216    }
217
218    /// Returns all known contacts ordered by last update
219    ///
220    /// # Arguments
221    /// * `session_store` - Session store to check for active sessions
222    pub async fn list_contacts(
223        &mut self,
224        session_store: &mut impl SessionStore,
225    ) -> Result<Vec<ContactInfo>, SignalBridgeError> {
226        let contacts = {
227            let conn_lock = self.storage.lock().unwrap();
228
229            let mut stmt = conn_lock
230                .prepare(
231                    "SELECT rdx_fingerprint, nostr_pubkey, user_alias FROM contacts
232                     ORDER BY last_updated DESC",
233                )
234                .map_err(|e| SignalBridgeError::Storage(e.to_string()))?;
235
236            let contacts_iter = stmt
237                .query_map([], |row| {
238                    Ok(ContactInfo {
239                        rdx_fingerprint: row.get(0)?,
240                        nostr_pubkey: row.get(1)?,
241                        user_alias: row.get(2)?,
242                        has_active_session: false,
243                    })
244                })
245                .map_err(|e| SignalBridgeError::Storage(e.to_string()))?;
246
247            contacts_iter
248                .filter_map(Result::ok)
249                .collect::<Vec<ContactInfo>>()
250        };
251
252        let mut result = Vec::new();
253        for mut contact in contacts {
254            let address =
255                ProtocolAddress::new(contact.rdx_fingerprint.clone(), DeviceId::new(1).unwrap());
256            contact.has_active_session = session_store.load_session(&address).await?.is_some();
257            result.push(contact);
258        }
259
260        Ok(result)
261    }
262
263    /// Generates an RDX fingerprint from a Signal identity key
264    ///
265    /// # Arguments
266    /// * `identity_key` - Signal Protocol identity key
267    ///
268    /// # Returns
269    /// RDX fingerprint (SHA-256 hash prefixed with "RDX:")
270    pub fn generate_identity_fingerprint_from_key(identity_key: &IdentityKey) -> String {
271        let mut hasher = Sha256::new();
272        hasher.update(identity_key.serialize());
273        hasher.update(b"radix-identity-fingerprint");
274        let result = hasher.finalize();
275        format!("RDX:{:x}", result)
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::keys::generate_identity_key_pair;
283
284    #[tokio::test]
285    async fn test_add_contact_from_identity_key() {
286        use crate::memory_storage::MemoryStorage;
287
288        let storage_conn = Arc::new(Mutex::new(rusqlite::Connection::open_in_memory().unwrap()));
289
290        {
291            let conn = storage_conn.lock().unwrap();
292            conn.execute(
293                "CREATE TABLE contacts (
294                    rdx_fingerprint TEXT PRIMARY KEY,
295                    nostr_pubkey TEXT NOT NULL,
296                    user_alias TEXT,
297                    signal_identity_key BLOB NOT NULL,
298                    first_seen INTEGER NOT NULL,
299                    last_updated INTEGER NOT NULL
300                )",
301                [],
302            )
303            .unwrap();
304        }
305
306        let mut manager = ContactManager::new(storage_conn.clone());
307        let mut session_store = MemoryStorage::new().session_store;
308
309        let peer_identity = generate_identity_key_pair().await.unwrap();
310        let peer_identity_key = peer_identity.identity_key();
311
312        let rdx_fingerprint = manager
313            .add_contact_from_identity_key(peer_identity_key)
314            .await
315            .unwrap();
316
317        assert!(
318            rdx_fingerprint.starts_with("RDX:"),
319            "RDX fingerprint should start with RDX:"
320        );
321
322        let contact = manager
323            .lookup_contact(&rdx_fingerprint, &mut session_store)
324            .await
325            .unwrap();
326
327        assert_eq!(contact.rdx_fingerprint, rdx_fingerprint);
328        assert!(
329            !contact.nostr_pubkey.is_empty(),
330            "Nostr pubkey should be derived from identity key"
331        );
332        assert!(
333            contact.user_alias.as_ref().unwrap().starts_with("Unknown-"),
334            "Auto-generated alias should start with Unknown-"
335        );
336        assert!(!contact.has_active_session, "No session should exist yet");
337
338        let contact_by_nostr = manager
339            .lookup_contact(&contact.nostr_pubkey, &mut session_store)
340            .await
341            .unwrap();
342        assert_eq!(contact_by_nostr.rdx_fingerprint, rdx_fingerprint);
343    }
344}