Skip to main content

four_word_networking/
identity_encoder.rs

1//! Identity word encoder for x0x agent and user identities.
2//!
3//! This module implements the identity word system described in the x0x
4//! FUTURE_PATH specification. It encodes 256-bit cryptographic hashes
5//! (AgentId, UserId) into human-speakable four-word names using the first
6//! 48 bits as a prefix, mapped through the 4,096-word dictionary.
7//!
8//! # Identity Types
9//!
10//! - **Agent identity (4 words)**: An autonomous agent with no human backing.
11//!   Derived from the first 48 bits of the AgentId (SHA-256 of ML-DSA-65 public key).
12//!
13//! - **Full identity (8 words)**: A human-backed agent, formatted as
14//!   `agent-words @ user-words`. The `@` separator mirrors email conventions
15//!   and carries the semantic "this agent *at* this person."
16//!
17//! # Word Count Semantics
18//!
19//! The word count carries meaning:
20//! - **4 words** = autonomous agent, no human vouching for it
21//! - **8 words (4 @ 4)** = human-backed agent, cryptographically bound to a person
22//!
23//! # Collision Resistance
24//!
25//! Each 4-word identity provides 48 bits of prefix from a 256-bit hash.
26//! Birthday-bound collision threshold is ~2^24 (~16 million) per half.
27//! The combined 8-word identity provides ~2^48 (~281 trillion) collision resistance.
28//!
29//! # Examples
30//!
31//! ```rust
32//! use four_word_networking::identity_encoder::IdentityEncoder;
33//!
34//! let encoder = IdentityEncoder::new();
35//!
36//! // Encode an agent ID (32 bytes) to 4 words
37//! let agent_id = hex::decode(
38//!     "dd6530452610619d468e4e82be82107e86384365c58efa6e3018d7762c7368da"
39//! ).unwrap();
40//! let words = encoder.encode_agent(&agent_id).unwrap();
41//! println!("Agent: {}", words);  // e.g. "highland forest moon river"
42//!
43//! // Decode 4 words back to a 48-bit prefix
44//! let prefix = encoder.decode_to_prefix(&words.to_string()).unwrap();
45//! assert_eq!(&agent_id[..6], &prefix[..]);
46//!
47//! // Encode a full 8-word identity (agent @ user)
48//! let user_id = hex::decode(
49//!     "3e729de0469a594d1e042a672b29adde388e34aed2ced1e4c244a87f03053770"
50//! ).unwrap();
51//! let full = encoder.encode_full(&agent_id, &user_id).unwrap();
52//! println!("Identity: {}", full);  // e.g. "highland forest moon river @ castle autumn wind silver"
53//! ```
54
55use crate::dictionary4k::DICTIONARY;
56use crate::error::{FourWordError, Result};
57
58/// Represents an encoded identity — either 4 words (agent) or 8 words (agent @ user).
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum IdentityWords {
61    /// Autonomous agent identity (4 words from AgentId prefix)
62    Agent { words: [String; 4] },
63    /// Human-backed agent identity (4 agent words @ 4 user words)
64    Full {
65        agent_words: [String; 4],
66        user_words: [String; 4],
67    },
68}
69
70impl IdentityWords {
71    /// Returns just the agent words (first 4)
72    pub fn agent_words(&self) -> &[String; 4] {
73        match self {
74            IdentityWords::Agent { words } => words,
75            IdentityWords::Full { agent_words, .. } => agent_words,
76        }
77    }
78
79    /// Returns the user words if this is a full identity
80    pub fn user_words(&self) -> Option<&[String; 4]> {
81        match self {
82            IdentityWords::Agent { .. } => None,
83            IdentityWords::Full { user_words, .. } => Some(user_words),
84        }
85    }
86
87    /// Returns true if this is a full (human-backed) identity
88    pub fn is_full(&self) -> bool {
89        matches!(self, IdentityWords::Full { .. })
90    }
91
92    /// Returns the number of identity words (4 or 8)
93    pub fn word_count(&self) -> usize {
94        match self {
95            IdentityWords::Agent { .. } => 4,
96            IdentityWords::Full { .. } => 8,
97        }
98    }
99}
100
101impl std::fmt::Display for IdentityWords {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        match self {
104            IdentityWords::Agent { words } => {
105                write!(f, "{}", words.join(" "))
106            }
107            IdentityWords::Full {
108                agent_words,
109                user_words,
110            } => {
111                write!(f, "{} @ {}", agent_words.join(" "), user_words.join(" "))
112            }
113        }
114    }
115}
116
117/// Encoder for x0x identity words.
118///
119/// Converts 256-bit cryptographic hashes (AgentId, UserId) into
120/// human-speakable four-word names using the 4,096-word dictionary.
121pub struct IdentityEncoder;
122
123impl IdentityEncoder {
124    /// Creates a new identity encoder.
125    pub fn new() -> Self {
126        IdentityEncoder
127    }
128
129    /// Encodes the first 48 bits of a hash into 4 words.
130    ///
131    /// Takes a 32-byte hash (SHA-256 of an ML-DSA-65 public key),
132    /// extracts the first 48 bits, and maps them to 4 dictionary words
133    /// at 12 bits per word.
134    fn encode_hash_prefix(&self, hash: &[u8]) -> Result<[String; 4]> {
135        if hash.len() < 6 {
136            return Err(FourWordError::InvalidInput(format!(
137                "Hash must be at least 6 bytes (48 bits), got {} bytes",
138                hash.len()
139            )));
140        }
141
142        // Extract first 48 bits as a u64
143        let mut n: u64 = 0;
144        for &byte in &hash[..6] {
145            n = (n << 8) | (byte as u64);
146        }
147
148        // Split into 4 x 12-bit indices (most significant first)
149        let mut words = Vec::with_capacity(4);
150        for i in (0..4).rev() {
151            let index = ((n >> (i * 12)) & 0xFFF) as u16;
152            let word = DICTIONARY
153                .get_word(index)
154                .ok_or(FourWordError::InvalidWordIndex(index))?
155                .to_string();
156            words.push(word);
157        }
158
159        Ok([
160            words[0].clone(),
161            words[1].clone(),
162            words[2].clone(),
163            words[3].clone(),
164        ])
165    }
166
167    /// Decodes 4 words back to a 48-bit (6-byte) prefix.
168    ///
169    /// This is the reverse of `encode_hash_prefix`. The returned bytes
170    /// can be used as a search prefix to locate agents on the gossip network.
171    pub fn decode_to_prefix(&self, identity: &str) -> Result<[u8; 6]> {
172        let words: Vec<&str> = identity.split_whitespace().collect();
173        if words.len() != 4 {
174            return Err(FourWordError::InvalidInput(format!(
175                "Expected 4 words, got {}",
176                words.len()
177            )));
178        }
179
180        self.decode_words_to_prefix(&words)
181    }
182
183    /// Decodes a slice of 4 word strings to a 48-bit prefix.
184    fn decode_words_to_prefix(&self, words: &[&str]) -> Result<[u8; 6]> {
185        if words.len() != 4 {
186            return Err(FourWordError::InvalidInput(format!(
187                "Expected 4 words, got {}",
188                words.len()
189            )));
190        }
191
192        // Reconstruct the 48-bit value from 4 x 12-bit indices
193        let mut n: u64 = 0;
194        for word in words {
195            let index = DICTIONARY
196                .get_index(word)
197                .ok_or_else(|| FourWordError::InvalidWord(word.to_string()))?;
198            n = (n << 12) | (index as u64);
199        }
200
201        // Convert to 6 bytes (big-endian)
202        let mut prefix = [0u8; 6];
203        for (i, byte) in prefix.iter_mut().enumerate() {
204            *byte = ((n >> (40 - i * 8)) & 0xFF) as u8;
205        }
206
207        Ok(prefix)
208    }
209
210    /// Encodes an AgentId into 4 identity words.
211    ///
212    /// The AgentId is the SHA-256 hash of the agent's ML-DSA-65 public key.
213    /// This produces an autonomous agent identity (4 words, no human backing).
214    pub fn encode_agent(&self, agent_id: &[u8]) -> Result<IdentityWords> {
215        let words = self.encode_hash_prefix(agent_id)?;
216        Ok(IdentityWords::Agent { words })
217    }
218
219    /// Encodes an AgentId and UserId into 8 identity words (4 @ 4).
220    ///
221    /// This produces a full human-backed identity. The AgentId is the
222    /// agent's key hash, the UserId is the human's key hash.
223    pub fn encode_full(&self, agent_id: &[u8], user_id: &[u8]) -> Result<IdentityWords> {
224        let agent_words = self.encode_hash_prefix(agent_id)?;
225        let user_words = self.encode_hash_prefix(user_id)?;
226        Ok(IdentityWords::Full {
227            agent_words,
228            user_words,
229        })
230    }
231
232    /// Encodes a hex-encoded hash string into identity words.
233    ///
234    /// Convenience method that accepts a hex string (as displayed by `x0x agent`).
235    pub fn encode_hex(&self, hex_str: &str) -> Result<IdentityWords> {
236        let bytes = hex::decode(hex_str.trim())
237            .map_err(|e| FourWordError::InvalidInput(format!("Invalid hex string: {e}")))?;
238        self.encode_agent(&bytes)
239    }
240
241    /// Encodes two hex strings into a full 8-word identity.
242    pub fn encode_hex_full(&self, agent_hex: &str, user_hex: &str) -> Result<IdentityWords> {
243        let agent_bytes = hex::decode(agent_hex.trim())
244            .map_err(|e| FourWordError::InvalidInput(format!("Invalid agent hex: {e}")))?;
245        let user_bytes = hex::decode(user_hex.trim())
246            .map_err(|e| FourWordError::InvalidInput(format!("Invalid user hex: {e}")))?;
247        self.encode_full(&agent_bytes, &user_bytes)
248    }
249
250    /// Parses an identity string into `IdentityWords`.
251    ///
252    /// Accepts either:
253    /// - 4 space-separated words (agent identity)
254    /// - 8 words with `@` separator (full identity): `"word1 word2 word3 word4 @ word5 word6 word7 word8"`
255    pub fn parse(&self, input: &str) -> Result<IdentityWords> {
256        if input.contains('@') {
257            // Full identity: agent @ user
258            let parts: Vec<&str> = input.split('@').collect();
259            if parts.len() != 2 {
260                return Err(FourWordError::InvalidInput(
261                    "Full identity must have exactly one '@' separator".to_string(),
262                ));
263            }
264
265            let agent_words: Vec<&str> = parts[0].split_whitespace().collect();
266            let user_words: Vec<&str> = parts[1].split_whitespace().collect();
267
268            if agent_words.len() != 4 {
269                return Err(FourWordError::InvalidInput(format!(
270                    "Agent part must have 4 words, got {}",
271                    agent_words.len()
272                )));
273            }
274            if user_words.len() != 4 {
275                return Err(FourWordError::InvalidInput(format!(
276                    "User part must have 4 words, got {}",
277                    user_words.len()
278                )));
279            }
280
281            // Validate all words exist in dictionary
282            for word in agent_words.iter().chain(user_words.iter()) {
283                if DICTIONARY.get_index(word).is_none() {
284                    return Err(FourWordError::InvalidWord(word.to_string()));
285                }
286            }
287
288            Ok(IdentityWords::Full {
289                agent_words: [
290                    agent_words[0].to_lowercase(),
291                    agent_words[1].to_lowercase(),
292                    agent_words[2].to_lowercase(),
293                    agent_words[3].to_lowercase(),
294                ],
295                user_words: [
296                    user_words[0].to_lowercase(),
297                    user_words[1].to_lowercase(),
298                    user_words[2].to_lowercase(),
299                    user_words[3].to_lowercase(),
300                ],
301            })
302        } else {
303            // Agent-only identity: 4 words
304            let words: Vec<&str> = input.split_whitespace().collect();
305            if words.len() != 4 {
306                return Err(FourWordError::InvalidInput(format!(
307                    "Agent identity must have 4 words, got {}",
308                    words.len()
309                )));
310            }
311
312            // Validate all words exist in dictionary
313            for word in &words {
314                if DICTIONARY.get_index(word).is_none() {
315                    return Err(FourWordError::InvalidWord(word.to_string()));
316                }
317            }
318
319            Ok(IdentityWords::Agent {
320                words: [
321                    words[0].to_lowercase(),
322                    words[1].to_lowercase(),
323                    words[2].to_lowercase(),
324                    words[3].to_lowercase(),
325                ],
326            })
327        }
328    }
329
330    /// Checks whether a 32-byte hash matches a set of identity words.
331    ///
332    /// Compares the first 48 bits of the hash against the prefix encoded
333    /// by the words. Useful for verifying that an agent found via gossip
334    /// actually matches the searched identity words.
335    pub fn matches(&self, hash: &[u8], words: &str) -> Result<bool> {
336        let prefix = self.decode_to_prefix(words)?;
337        if hash.len() < 6 {
338            return Ok(false);
339        }
340        Ok(hash[..6] == prefix[..])
341    }
342}
343
344impl Default for IdentityEncoder {
345    fn default() -> Self {
346        Self::new()
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    // Real agent IDs observed on the x0x network (2026-04-03)
355    const BEN_AGENT_ID: &str = "dd6530452610619d468e4e82be82107e86384365c58efa6e3018d7762c7368da";
356    const DAVID_VPS_AGENT_ID: &str =
357        "da2233d6ba2f95696e5f5ba3bc4db193be1aa53d7ce1c048a8e8a67639337b75";
358    const THIRD_AGENT_ID: &str = "3e729de0469a594d1e042a672b29adde388e34aed2ced1e4c244a87f03053770";
359
360    #[test]
361    fn test_encode_agent_id() {
362        let encoder = IdentityEncoder::new();
363        let bytes = hex::decode(BEN_AGENT_ID).unwrap();
364        let identity = encoder.encode_agent(&bytes).unwrap();
365
366        assert!(matches!(identity, IdentityWords::Agent { .. }));
367        assert_eq!(identity.word_count(), 4);
368
369        let display = identity.to_string();
370        let words: Vec<&str> = display.split_whitespace().collect();
371        assert_eq!(words.len(), 4);
372
373        // Each word should be in the dictionary
374        for word in &words {
375            assert!(
376                DICTIONARY.get_index(word).is_some(),
377                "Word '{}' not in dictionary",
378                word
379            );
380        }
381
382        println!("Ben's agent: {}", identity);
383    }
384
385    #[test]
386    fn test_encode_all_network_agents() {
387        let encoder = IdentityEncoder::new();
388
389        let agents = [
390            ("Ben", BEN_AGENT_ID),
391            ("David VPS", DAVID_VPS_AGENT_ID),
392            ("Third", THIRD_AGENT_ID),
393        ];
394
395        let mut seen = std::collections::HashSet::new();
396        for (name, hex_id) in &agents {
397            let identity = encoder.encode_hex(hex_id).unwrap();
398            let display = identity.to_string();
399            println!("{}: {} -> {}", name, &hex_id[..16], display);
400
401            // No collisions between network agents
402            assert!(
403                seen.insert(display.clone()),
404                "Collision detected for {}",
405                name
406            );
407        }
408    }
409
410    #[test]
411    fn test_round_trip_prefix() {
412        let encoder = IdentityEncoder::new();
413        let bytes = hex::decode(BEN_AGENT_ID).unwrap();
414
415        let identity = encoder.encode_agent(&bytes).unwrap();
416        let prefix = encoder.decode_to_prefix(&identity.to_string()).unwrap();
417
418        // First 6 bytes (48 bits) should match exactly
419        assert_eq!(&bytes[..6], &prefix[..]);
420    }
421
422    #[test]
423    fn test_round_trip_all_agents() {
424        let encoder = IdentityEncoder::new();
425
426        for hex_id in [BEN_AGENT_ID, DAVID_VPS_AGENT_ID, THIRD_AGENT_ID] {
427            let bytes = hex::decode(hex_id).unwrap();
428            let identity = encoder.encode_agent(&bytes).unwrap();
429            let prefix = encoder.decode_to_prefix(&identity.to_string()).unwrap();
430            assert_eq!(
431                &bytes[..6],
432                &prefix[..],
433                "Round-trip failed for {}",
434                &hex_id[..16]
435            );
436        }
437    }
438
439    #[test]
440    fn test_full_identity() {
441        let encoder = IdentityEncoder::new();
442        let full = encoder
443            .encode_hex_full(BEN_AGENT_ID, THIRD_AGENT_ID)
444            .unwrap();
445
446        assert!(full.is_full());
447        assert_eq!(full.word_count(), 8);
448
449        let display = full.to_string();
450        assert!(display.contains(" @ "), "Full identity must contain ' @ '");
451
452        let parts: Vec<&str> = display.split(" @ ").collect();
453        assert_eq!(parts.len(), 2);
454        assert_eq!(parts[0].split_whitespace().count(), 4);
455        assert_eq!(parts[1].split_whitespace().count(), 4);
456
457        println!("Full identity: {}", full);
458    }
459
460    #[test]
461    fn test_parse_agent_identity() {
462        let encoder = IdentityEncoder::new();
463        let bytes = hex::decode(BEN_AGENT_ID).unwrap();
464
465        // Encode then parse
466        let identity = encoder.encode_agent(&bytes).unwrap();
467        let display = identity.to_string();
468        let parsed = encoder.parse(&display).unwrap();
469
470        assert_eq!(identity, parsed);
471    }
472
473    #[test]
474    fn test_parse_full_identity() {
475        let encoder = IdentityEncoder::new();
476        let full = encoder
477            .encode_hex_full(BEN_AGENT_ID, THIRD_AGENT_ID)
478            .unwrap();
479
480        let display = full.to_string();
481        let parsed = encoder.parse(&display).unwrap();
482
483        assert_eq!(full, parsed);
484    }
485
486    #[test]
487    fn test_matches() {
488        let encoder = IdentityEncoder::new();
489        let bytes = hex::decode(BEN_AGENT_ID).unwrap();
490
491        let identity = encoder.encode_agent(&bytes).unwrap();
492        let display = identity.to_string();
493
494        // Should match the original hash
495        assert!(encoder.matches(&bytes, &display).unwrap());
496
497        // Should not match a different hash
498        let other_bytes = hex::decode(DAVID_VPS_AGENT_ID).unwrap();
499        assert!(!encoder.matches(&other_bytes, &display).unwrap());
500    }
501
502    #[test]
503    fn test_different_agents_different_words() {
504        let encoder = IdentityEncoder::new();
505
506        let ben = encoder.encode_hex(BEN_AGENT_ID).unwrap().to_string();
507        let david = encoder.encode_hex(DAVID_VPS_AGENT_ID).unwrap().to_string();
508        let third = encoder.encode_hex(THIRD_AGENT_ID).unwrap().to_string();
509
510        assert_ne!(ben, david);
511        assert_ne!(ben, third);
512        assert_ne!(david, third);
513    }
514
515    #[test]
516    fn test_deterministic() {
517        let encoder = IdentityEncoder::new();
518
519        // Same input always produces same output
520        let a = encoder.encode_hex(BEN_AGENT_ID).unwrap().to_string();
521        let b = encoder.encode_hex(BEN_AGENT_ID).unwrap().to_string();
522        assert_eq!(a, b);
523    }
524
525    #[test]
526    fn test_family_name_pattern() {
527        let encoder = IdentityEncoder::new();
528
529        // Two different agents belonging to the same user
530        // should share the last 4 words (the user's words)
531        let full1 = encoder
532            .encode_hex_full(BEN_AGENT_ID, THIRD_AGENT_ID)
533            .unwrap();
534        let full2 = encoder
535            .encode_hex_full(DAVID_VPS_AGENT_ID, THIRD_AGENT_ID)
536            .unwrap();
537
538        // Agent words should differ (different agents)
539        assert_ne!(full1.agent_words(), full2.agent_words());
540
541        // User words should be identical (same user)
542        assert_eq!(full1.user_words(), full2.user_words());
543
544        println!("Agent 1: {}", full1);
545        println!("Agent 2: {}", full2);
546        println!(
547            "Same family name: {}",
548            full1.user_words().unwrap().join(" ")
549        );
550    }
551
552    #[test]
553    fn test_short_hash_rejected() {
554        let encoder = IdentityEncoder::new();
555        let short = vec![0u8; 5]; // Only 5 bytes, need 6
556        assert!(encoder.encode_agent(&short).is_err());
557    }
558
559    #[test]
560    fn test_invalid_word_rejected() {
561        let encoder = IdentityEncoder::new();
562        assert!(encoder.parse("not real words here").is_err());
563    }
564
565    #[test]
566    fn test_wrong_word_count_rejected() {
567        let encoder = IdentityEncoder::new();
568        // Get a valid word to use
569        let word = DICTIONARY.get_word(0).unwrap();
570        let three_words = format!("{} {} {}", word, word, word);
571        assert!(encoder.parse(&three_words).is_err());
572    }
573}