Skip to main content

huddle_core/
identity.rs

1use ed25519_dalek::{Signer, SigningKey};
2use libp2p::identity::{self, Keypair};
3use libp2p::PeerId;
4use sha2::{Digest, Sha256};
5
6use crate::error::{HuddleError, Result};
7
8pub struct Identity {
9    signing_key: SigningKey,
10    libp2p_keypair: Keypair,
11    peer_id: PeerId,
12    fingerprint: String,
13}
14
15impl Identity {
16    pub fn generate() -> Result<Self> {
17        let mut rng = rand::thread_rng();
18        let signing_key = SigningKey::generate(&mut rng);
19        Self::from_signing_key(signing_key)
20    }
21
22    pub fn from_secret_bytes(bytes: [u8; 32]) -> Result<Self> {
23        let signing_key = SigningKey::from_bytes(&bytes);
24        Self::from_signing_key(signing_key)
25    }
26
27    fn from_signing_key(signing_key: SigningKey) -> Result<Self> {
28        let secret = signing_key.to_bytes();
29        let public = signing_key.verifying_key().to_bytes();
30        let mut combined = [0u8; 64];
31        combined[..32].copy_from_slice(&secret);
32        combined[32..].copy_from_slice(&public);
33
34        let ed25519_kp = identity::ed25519::Keypair::try_from_bytes(&mut combined)
35            .map_err(|e| HuddleError::Identity(e.to_string()))?;
36        let libp2p_keypair = Keypair::from(ed25519_kp);
37        let peer_id = PeerId::from(libp2p_keypair.public());
38        let fingerprint = compute_fingerprint(&public);
39
40        Ok(Self {
41            signing_key,
42            libp2p_keypair,
43            peer_id,
44            fingerprint,
45        })
46    }
47
48    pub fn fingerprint(&self) -> &str {
49        &self.fingerprint
50    }
51
52    pub fn peer_id(&self) -> PeerId {
53        self.peer_id
54    }
55
56    pub fn keypair(&self) -> &Keypair {
57        &self.libp2p_keypair
58    }
59
60    pub fn secret_bytes(&self) -> [u8; 32] {
61        self.signing_key.to_bytes()
62    }
63
64    pub fn public_bytes(&self) -> [u8; 32] {
65        self.signing_key.verifying_key().to_bytes()
66    }
67
68    /// Ed25519-sign `msg` with our identity key. The signature binds
69    /// arbitrary bytes to this fingerprint; used by protocol envelopes
70    /// (`SignedRoomMessage`) so receivers can prove the sender's identity
71    /// at the application layer (gossipsub only proves transport-level).
72    pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
73        self.signing_key.sign(msg).to_bytes()
74    }
75}
76
77/// Derive the human-facing 24-char fingerprint from an Ed25519 public key.
78/// Format: `xxxx-xxxx-xxxx-xxxx-xxxx-xxxx` (6 groups of 4 hex chars, 24 hex
79/// chars total = 12 bytes = 96 bits of SHA-256 over the pubkey). Public so
80/// `crypto::verify_signed` can re-derive it from a signed envelope's pubkey
81/// and check that it matches the asserted fingerprint.
82pub fn compute_fingerprint(public_key: &[u8; 32]) -> String {
83    let hash = Sha256::digest(public_key);
84    let hex_str = hex::encode(&hash[..12]);
85    hex_str
86        .as_bytes()
87        .chunks(4)
88        .map(|chunk| std::str::from_utf8(chunk).unwrap())
89        .collect::<Vec<&str>>()
90        .join("-")
91}
92
93/// huddle 1.1.4: domain-separation prefix for the relay client-auth
94/// challenge-response. The client signs `RELAY_AUTH_DOMAIN || nonce` with
95/// its Ed25519 identity key; the relay verifies that signature against the
96/// presented pubkey and checks the pubkey hashes to the claimed fingerprint.
97/// The distinct domain tag keeps this signature from ever being mistaken for
98/// a `SignedRoomMessage` envelope (which commits a different tag).
99pub const RELAY_AUTH_DOMAIN: &[u8] = b"huddle-relay-auth-v1";
100
101/// Build the exact bytes a client signs to prove control of its identity key
102/// to the relay: the domain tag followed by the server's 32-byte challenge
103/// nonce. The relay (`huddle-server`) open-codes the identical construction,
104/// so the two must stay byte-for-byte in sync.
105pub fn relay_auth_msg(nonce: &[u8]) -> Vec<u8> {
106    let mut m = Vec::with_capacity(RELAY_AUTH_DOMAIN.len() + nonce.len());
107    m.extend_from_slice(RELAY_AUTH_DOMAIN);
108    m.extend_from_slice(nonce);
109    m
110}
111
112/// huddle 0.7.8: 12-hex Safety Code derived from the same SHA-256 of the
113/// Ed25519 pubkey that backs `compute_fingerprint`. Format
114/// `SAFE-XXXX-XXXX-XXXX` (uppercase, dash-separated). Display-only — a
115/// shorter, less ambiguous handle to compare against a friend at the
116/// start of a session. SAS-via-emoji is still the real verification
117/// primitive; this is the visual analogue of DirectChat's
118/// `accountSafetyCode`.
119pub fn safety_code(public_key: &[u8; 32]) -> String {
120    let hash = Sha256::digest(public_key);
121    let hex_str = hex::encode(&hash[..6]).to_ascii_uppercase();
122    let groups: Vec<&str> = hex_str
123        .as_bytes()
124        .chunks(4)
125        .map(|chunk| std::str::from_utf8(chunk).unwrap())
126        .collect();
127    format!("SAFE-{}", groups.join("-"))
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn fingerprint_is_deterministic() {
136        let key_bytes = [42u8; 32];
137        let id = Identity::from_secret_bytes(key_bytes).unwrap();
138        let id2 = Identity::from_secret_bytes(key_bytes).unwrap();
139        assert_eq!(id.fingerprint(), id2.fingerprint());
140    }
141
142    #[test]
143    fn fingerprint_format_is_correct() {
144        let id = Identity::generate().unwrap();
145        let fp = id.fingerprint();
146        let parts: Vec<&str> = fp.split('-').collect();
147        assert_eq!(parts.len(), 6);
148        for part in &parts {
149            assert_eq!(part.len(), 4);
150            assert!(part.chars().all(|c| c.is_ascii_hexdigit()));
151        }
152    }
153
154    #[test]
155    fn different_keys_produce_different_fingerprints() {
156        let id1 = Identity::generate().unwrap();
157        let id2 = Identity::generate().unwrap();
158        assert_ne!(id1.fingerprint(), id2.fingerprint());
159    }
160
161    #[test]
162    fn round_trip_through_bytes() {
163        let id1 = Identity::generate().unwrap();
164        let bytes = id1.secret_bytes();
165        let id2 = Identity::from_secret_bytes(bytes).unwrap();
166        assert_eq!(id1.fingerprint(), id2.fingerprint());
167        assert_eq!(id1.peer_id(), id2.peer_id());
168    }
169
170    #[test]
171    fn peer_id_is_derived_from_same_key() {
172        let id = Identity::generate().unwrap();
173        let pid = id.peer_id();
174        assert!(!pid.to_string().is_empty());
175    }
176
177    #[test]
178    fn safety_code_is_stable_and_well_formed() {
179        let key = [7u8; 32];
180        let a = safety_code(&key);
181        let b = safety_code(&key);
182        assert_eq!(a, b);
183        assert!(a.starts_with("SAFE-"));
184        let groups: Vec<&str> = a.trim_start_matches("SAFE-").split('-').collect();
185        assert_eq!(groups.len(), 3);
186        for g in &groups {
187            assert_eq!(g.len(), 4);
188            assert!(g.chars().all(|c| c.is_ascii_hexdigit() && c.is_ascii_uppercase() || c.is_ascii_digit()));
189        }
190    }
191}