Skip to main content

communitas_core/
identity.rs

1// Copyright (c) 2025 Saorsa Labs Limited
2//
3// Dual-licensed under the AGPL-3.0-or-later and a commercial license.
4// You may use this file under the terms of the GNU Affero General Public License v3.0 or later.
5// For commercial licensing, contact: saorsalabs@gmail.com
6//
7// See the LICENSE-AGPL-3.0 and LICENSE-COMMERCIAL.md files for details.
8
9//! Identity and connection encoding using four-word-networking
10//!
11//! This module provides helpers for:
12//! - Converting public keys to four-word user identities
13//! - Converting SocketAddrs to four-word connection identities
14//! - Parsing four-word strings back to SocketAddrs
15
16use four_word_networking::FourWordAdaptiveEncoder;
17use std::net::SocketAddr;
18use thiserror::Error;
19
20/// Identity encoding errors
21#[derive(Debug, Error)]
22pub enum IdentityError {
23    #[error("Failed to encode identity: {0}")]
24    EncodingFailed(String),
25
26    #[error("Failed to decode identity: {0}")]
27    DecodingFailed(String),
28
29    #[error("Invalid four-word format: {0}")]
30    InvalidFormat(String),
31
32    #[error("Four-word encoder initialization failed: {0}")]
33    EncoderInitFailed(String),
34}
35
36pub type IdentityResult<T> = Result<T, IdentityError>;
37
38/// Generate a random four-word identity
39///
40/// Generates 4 valid random words from the four-word-networking dictionary
41/// to create a new user identity like "ocean-forest-moon-star".
42///
43/// # Returns
44/// Four-word identity string with valid dictionary words
45///
46/// # Example
47/// ```
48/// use communitas_core::identity::generate_id_words;
49///
50/// let identity = generate_id_words()?;
51/// assert_eq!(identity.split('-').count(), 4);
52/// # Ok::<(), communitas_core::identity::IdentityError>(())
53/// ```
54pub fn generate_id_words() -> IdentityResult<String> {
55    // Initialize encoder
56    let encoder = FourWordAdaptiveEncoder::new().map_err(|e| {
57        IdentityError::EncoderInitFailed(format!("Failed to initialize encoder: {}", e))
58    })?;
59
60    // Generate 4 random valid dictionary words
61    let words = encoder.get_random_words(4);
62    if words.len() != 4 {
63        return Err(IdentityError::EncodingFailed(
64            "Failed to generate 4 dictionary words".to_string(),
65        ));
66    }
67
68    Ok(words.join("-"))
69}
70
71/// Derive a deterministic seed from a four-word identity
72///
73/// Takes a four-word identity and produces a deterministic 32-byte seed
74/// that can be used to generate cryptographic keys. Same identity always
75/// produces the same seed.
76///
77/// **Note**: This function only validates the format (4 words separated by dashes),
78/// not whether the words are in the four-word-networking dictionary. This allows
79/// backward compatibility with identities created using different word lists.
80///
81/// # Arguments
82/// * `identity` - Four-word identity string (e.g., "ocean-forest-moon-star")
83///
84/// # Returns
85/// 32-byte deterministic seed derived from the identity
86///
87/// # Example
88/// ```
89/// use communitas_core::identity::identity_to_seed;
90///
91/// let seed = identity_to_seed("ocean-forest-moon-star")?;
92/// assert_eq!(seed.len(), 32);
93/// # Ok::<(), communitas_core::identity::IdentityError>(())
94/// ```
95pub fn identity_to_seed(identity: &str) -> IdentityResult<[u8; 32]> {
96    // Validate the format only (not dictionary words)
97    // This allows backward compatibility with identities from different word lists
98    if !validate_identity_format(identity) {
99        return Err(IdentityError::InvalidFormat(format!(
100            "Invalid four-word format: expected word-word-word-word, got: {}",
101            identity
102        )));
103    }
104
105    // Hash the identity string to get deterministic 32 bytes
106    let hash = blake3::hash(identity.as_bytes());
107    Ok(*hash.as_bytes())
108}
109
110/// Validate a four-word identity
111///
112/// Checks that all 4 words are valid dictionary words from four-word-networking.
113///
114/// # Arguments
115/// * `identity` - Four-word identity string (e.g., "ocean-forest-moon-star")
116///
117/// # Returns
118/// true if all 4 words are valid, false otherwise
119pub fn validate_id_words(identity: &str) -> bool {
120    // Check format first
121    if !validate_identity_format(identity) {
122        return false;
123    }
124
125    // Initialize encoder
126    let encoder = match FourWordAdaptiveEncoder::new() {
127        Ok(e) => e,
128        Err(_) => return false,
129    };
130
131    // Validate each word is in the dictionary
132    for word in identity.split('-') {
133        if !encoder.is_valid_word(word) {
134            return false;
135        }
136    }
137
138    true
139}
140
141/// Convert a SocketAddr to a four-word connection identity
142///
143/// Encodes both the IP address and port into a human-readable format.
144/// IPv4 addresses produce 4 words, IPv6 addresses produce more words.
145///
146/// # Arguments
147/// * `addr` - Socket address (IP + port)
148///
149/// # Returns
150/// Four-word (or more for IPv6) connection identity string
151///
152/// # Example
153/// ```
154/// use communitas_core::identity::conn_words;
155/// use std::net::SocketAddr;
156///
157/// let addr: SocketAddr = "127.0.0.1:8080".parse()?;
158/// let conn_id = conn_words(&addr)?;
159/// assert!(conn_id.contains(' '));
160/// # Ok::<(), Box<dyn std::error::Error>>(())
161/// ```
162pub fn conn_words(addr: &SocketAddr) -> IdentityResult<String> {
163    // Initialize encoder
164    let encoder = FourWordAdaptiveEncoder::new().map_err(|e| {
165        IdentityError::EncoderInitFailed(format!("Failed to initialize encoder: {}", e))
166    })?;
167
168    // Encode the socket address to words
169    let words = encoder.encode(&addr.to_string()).map_err(|e| {
170        IdentityError::EncodingFailed(format!("Failed to encode connection address: {}", e))
171    })?;
172
173    Ok(words)
174}
175
176/// Parse a four-word connection identity back to a SocketAddr
177///
178/// Decodes a human-readable connection identity string back to its original
179/// IP address and port.
180///
181/// # Arguments
182/// * `words` - Four-word (or more) connection identity string
183///
184/// # Returns
185/// Socket address (IP + port)
186///
187/// # Example
188/// ```
189/// use communitas_core::identity::{conn_words, conn_from_words};
190/// use std::net::SocketAddr;
191///
192/// let original: SocketAddr = "192.168.1.100:9000".parse()?;
193/// let words = conn_words(&original)?;
194/// let decoded = conn_from_words(&words)?;
195/// assert_eq!(original, decoded);
196/// # Ok::<(), Box<dyn std::error::Error>>(())
197/// ```
198pub fn conn_from_words(words: &str) -> IdentityResult<SocketAddr> {
199    // Validate format (should contain spaces, not dashes, for FourWordAdaptiveEncoder)
200    if !words.contains(' ') && !words.contains('-') {
201        return Err(IdentityError::InvalidFormat(
202            "Connection identity must contain word separators".to_string(),
203        ));
204    }
205
206    // Initialize encoder
207    let encoder = FourWordAdaptiveEncoder::new().map_err(|e| {
208        IdentityError::EncoderInitFailed(format!("Failed to initialize encoder: {}", e))
209    })?;
210
211    // Decode the words back to address string
212    let addr_str = encoder.decode(words).map_err(|e| {
213        IdentityError::DecodingFailed(format!("Failed to decode connection address: {}", e))
214    })?;
215
216    // Parse the address string to SocketAddr
217    let addr: SocketAddr = addr_str.parse().map_err(|e| {
218        IdentityError::DecodingFailed(format!("Failed to parse decoded address: {}", e))
219    })?;
220
221    Ok(addr)
222}
223
224/// Validate a four-word identity format
225///
226/// Checks if a string has the correct four-word format (word-word-word-word).
227///
228/// # Arguments
229/// * `words` - String to validate
230///
231/// # Returns
232/// true if valid format, false otherwise
233pub fn validate_identity_format(words: &str) -> bool {
234    let parts: Vec<&str> = words.split('-').collect();
235    parts.len() == 4 && parts.iter().all(|part| !part.is_empty())
236}
237
238/// Validate a connection identity format
239///
240/// Checks if a string has a valid connection identity format (at least 4 words).
241///
242/// # Arguments
243/// * `words` - String to validate
244///
245/// # Returns
246/// true if valid format, false otherwise
247pub fn validate_connection_format(words: &str) -> bool {
248    let parts: Vec<&str> = words.split('-').collect();
249    parts.len() >= 4 && parts.iter().all(|part| !part.is_empty())
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_generate_id_words() {
258        let identity = generate_id_words().unwrap();
259
260        // Should have exactly 4 words separated by dashes
261        let parts: Vec<&str> = identity.split('-').collect();
262        assert_eq!(parts.len(), 4);
263
264        // Each part should be non-empty
265        for part in parts {
266            assert!(!part.is_empty());
267        }
268
269        // Should be valid according to four-word-networking
270        assert!(validate_id_words(&identity));
271    }
272
273    #[test]
274    fn test_identity_to_seed_deterministic() {
275        // Use a generated valid identity
276        let identity = generate_id_words().unwrap();
277
278        let seed1 = identity_to_seed(&identity).unwrap();
279        let seed2 = identity_to_seed(&identity).unwrap();
280
281        // Same identity should always produce same seed
282        assert_eq!(seed1, seed2);
283        assert_eq!(seed1.len(), 32);
284    }
285
286    #[test]
287    fn test_identity_to_seed_different_identities() {
288        // Generate two different valid identities
289        let identity1 = generate_id_words().unwrap();
290        let identity2 = generate_id_words().unwrap();
291
292        // They should be different (extremely high probability)
293        if identity1 == identity2 {
294            // Skip this test if we got the same identity by chance
295            return;
296        }
297
298        let seed1 = identity_to_seed(&identity1).unwrap();
299        let seed2 = identity_to_seed(&identity2).unwrap();
300
301        // Different identities should produce different seeds
302        assert_ne!(seed1, seed2);
303    }
304
305    #[test]
306    fn test_validate_id_words() {
307        // Valid identity - generated from our function
308        let valid_identity = generate_id_words().unwrap();
309        assert!(validate_id_words(&valid_identity));
310
311        // Invalid format
312        assert!(!validate_id_words("only-three-words"));
313        assert!(!validate_id_words("too-many-words-here-now"));
314        assert!(!validate_id_words(""));
315    }
316
317    #[test]
318    fn test_conn_words_ipv4() {
319        let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap();
320        let conn_id = conn_words(&addr).unwrap();
321
322        // Should contain spaces (four-word-networking uses spaces for IPs)
323        assert!(conn_id.contains(' '));
324
325        // IPv4 should produce at least 4 words
326        let parts: Vec<&str> = conn_id.split(' ').collect();
327        assert!(parts.len() >= 4);
328    }
329
330    #[test]
331    fn test_conn_words_ipv6() {
332        let addr: SocketAddr = "[::1]:8080".parse().unwrap();
333        let conn_id = conn_words(&addr).unwrap();
334
335        // Should contain spaces (four-word-networking uses spaces for IPs)
336        assert!(conn_id.contains(' '));
337
338        // IPv6 should produce multiple words (more than 4)
339        let parts: Vec<&str> = conn_id.split(' ').collect();
340        assert!(parts.len() >= 4);
341    }
342
343    #[test]
344    fn test_conn_roundtrip_ipv4() {
345        let original: SocketAddr = "192.168.1.100:9000".parse().unwrap();
346
347        let words = conn_words(&original).unwrap();
348        let decoded = conn_from_words(&words).unwrap();
349
350        assert_eq!(original, decoded);
351    }
352
353    #[test]
354    fn test_conn_roundtrip_ipv6() {
355        let original: SocketAddr = "[2001:db8::1]:9000".parse().unwrap();
356
357        let words = conn_words(&original).unwrap();
358        let decoded = conn_from_words(&words).unwrap();
359
360        assert_eq!(original, decoded);
361    }
362
363    #[test]
364    fn test_conn_from_words_invalid_format() {
365        let result = conn_from_words("invalid");
366        assert!(result.is_err());
367    }
368
369    #[test]
370    fn test_validate_identity_format() {
371        assert!(validate_identity_format("ocean-forest-moon-star"));
372        assert!(validate_identity_format("river-mountain-sun-cloud"));
373
374        assert!(!validate_identity_format("only-three-words"));
375        assert!(!validate_identity_format("too-many-words-here-now"));
376        assert!(!validate_identity_format("no spaces allowed"));
377        assert!(!validate_identity_format(""));
378    }
379
380    #[test]
381    fn test_validate_connection_format() {
382        assert!(validate_connection_format("ocean-forest-moon-star"));
383        assert!(validate_connection_format("ocean-forest-moon-star-extra"));
384        assert!(validate_connection_format(
385            "ocean-forest-moon-star-extra-more"
386        ));
387
388        assert!(!validate_connection_format("only-three"));
389        assert!(!validate_connection_format("no spaces"));
390        assert!(!validate_connection_format(""));
391    }
392
393    #[test]
394    fn test_conn_words_deterministic() {
395        let addr: SocketAddr = "10.0.0.1:5000".parse().unwrap();
396
397        let words1 = conn_words(&addr).unwrap();
398        let words2 = conn_words(&addr).unwrap();
399
400        assert_eq!(words1, words2);
401    }
402
403    #[test]
404    fn test_different_ports_different_words() {
405        let addr1: SocketAddr = "127.0.0.1:8080".parse().unwrap();
406        let addr2: SocketAddr = "127.0.0.1:8081".parse().unwrap();
407
408        let words1 = conn_words(&addr1).unwrap();
409        let words2 = conn_words(&addr2).unwrap();
410
411        // Different ports should produce different connection identities
412        assert_ne!(words1, words2);
413    }
414}