Skip to main content

huddle_protocol/
identity.rs

1use ed25519_dalek::{Signer, SigningKey};
2use sha2::{Digest, Sha256};
3use zeroize::Zeroizing;
4
5use crate::crypto::pqc::{self, PqKeypair};
6use crate::error::Result;
7
8/// The runtime-free half of a huddle identity: the Ed25519 signing key, its
9/// derived 24-char fingerprint, and (on demand) the ML-KEM-768 keypair derived
10/// from the same seed.
11///
12/// `huddle-core::identity::Identity` wraps this and adds the libp2p
13/// `PeerId`/`Keypair` (which need the libp2p dependency), delegating every pure
14/// method here via `Deref` — so `id.fingerprint()`, `id.sign(..)`, `id.seed()`
15/// etc. resolve to these implementations and existing call sites are unchanged.
16pub struct IdentityKeys {
17    signing_key: SigningKey,
18    fingerprint: String,
19}
20
21impl IdentityKeys {
22    pub fn generate() -> Result<Self> {
23        let mut rng = rand::thread_rng();
24        Ok(Self::from_signing_key(SigningKey::generate(&mut rng)))
25    }
26
27    pub fn from_secret_bytes(bytes: [u8; 32]) -> Result<Self> {
28        Ok(Self::from_signing_key(SigningKey::from_bytes(&bytes)))
29    }
30
31    fn from_signing_key(signing_key: SigningKey) -> Self {
32        let public = signing_key.verifying_key().to_bytes();
33        let fingerprint = compute_fingerprint(&public);
34        Self {
35            signing_key,
36            fingerprint,
37        }
38    }
39
40    pub fn fingerprint(&self) -> &str {
41        &self.fingerprint
42    }
43
44    pub fn secret_bytes(&self) -> [u8; 32] {
45        self.signing_key.to_bytes()
46    }
47
48    pub fn public_bytes(&self) -> [u8; 32] {
49        self.signing_key.verifying_key().to_bytes()
50    }
51
52    /// Ed25519-sign `msg` with our identity key. Used by protocol envelopes
53    /// (`SignedRoomMessage`) and signed invites so receivers can prove the
54    /// sender's identity at the application layer.
55    pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
56        self.signing_key.sign(msg).to_bytes()
57    }
58
59    /// huddle 1.3: this identity's ML-KEM-768 keypair, **deterministically
60    /// derived** from the Ed25519 secret seed (see [`crate::crypto::pqc`]).
61    /// Computed on demand — there is no extra key material on disk; the 32-byte
62    /// Ed25519 seed is the sole root secret, so every pre-1.3 identity gains a
63    /// post-quantum keypair for free with no migration.
64    pub fn pq_keypair(&self) -> PqKeypair {
65        let seed = Zeroizing::new(self.signing_key.to_bytes());
66        PqKeypair::from_identity_seed(&seed)
67    }
68
69    /// huddle 1.3: our serialized ML-KEM-768 encapsulation (public) key,
70    /// published to peers in the signed `MemberAnnounce` on Direct rooms.
71    /// Stable across restarts.
72    pub fn mlkem_public_bytes(&self) -> [u8; pqc::MLKEM_EK_LEN] {
73        self.pq_keypair().encapsulation_key_bytes()
74    }
75
76    /// huddle 2.0: export this identity's 32-byte Ed25519 seed — the **sole
77    /// root secret** from which the PeerId, the ML-KEM-768 keypair, and every
78    /// DM key deterministically derive. Returned in a `Zeroizing` wrapper so
79    /// the copy is scrubbed when the caller drops it. Rendered as a 24-word
80    /// BIP39 phrase by [`crate::crypto::mnemonic::seed_to_phrase`] for backup /
81    /// recovery; treat it as the crown jewel.
82    pub fn seed(&self) -> Zeroizing<[u8; 32]> {
83        Zeroizing::new(self.signing_key.to_bytes())
84    }
85
86    /// huddle 2.0: rebuild from a 32-byte Ed25519 seed recovered from a BIP39
87    /// phrase ([`crate::crypto::mnemonic::phrase_to_seed`]). The seed is the
88    /// only input, so the restored keys are byte-for-byte the original.
89    pub fn from_seed(seed: Zeroizing<[u8; 32]>) -> Result<Self> {
90        Ok(Self::from_signing_key(SigningKey::from_bytes(&seed)))
91    }
92}
93
94/// Derive the human-facing 24-char fingerprint from an Ed25519 public key.
95/// Format: `xxxx-xxxx-xxxx-xxxx-xxxx-xxxx` (6 groups of 4 hex chars, 24 hex
96/// chars total = 12 bytes = 96 bits of SHA-256 over the pubkey). Public so
97/// `crypto::verify_signed` can re-derive it from a signed envelope's pubkey
98/// and check that it matches the asserted fingerprint.
99pub fn compute_fingerprint(public_key: &[u8; 32]) -> String {
100    let hash = Sha256::digest(public_key);
101    let hex_str = hex::encode(&hash[..12]);
102    hex_str
103        .as_bytes()
104        .chunks(4)
105        .map(|chunk| std::str::from_utf8(chunk).unwrap())
106        .collect::<Vec<&str>>()
107        .join("-")
108}
109
110/// huddle 1.1.4: domain-separation prefix for the relay client-auth
111/// challenge-response. The client signs `RELAY_AUTH_DOMAIN || nonce` with its
112/// Ed25519 identity key; the relay verifies that signature against the
113/// presented pubkey and checks the pubkey hashes to the claimed fingerprint.
114/// The distinct domain tag keeps this signature from ever being mistaken for a
115/// `SignedRoomMessage` envelope (which commits a different tag).
116pub const RELAY_AUTH_DOMAIN: &[u8] = b"huddle-relay-auth-v1";
117
118/// Build the exact bytes a client signs to prove control of its identity key to
119/// the relay: the domain tag followed by the server's challenge nonce. The
120/// relay (`huddle-server`) now calls this same function, so the two stay
121/// byte-for-byte in sync by construction.
122pub fn relay_auth_msg(nonce: &[u8]) -> Vec<u8> {
123    let mut m = Vec::with_capacity(RELAY_AUTH_DOMAIN.len() + nonce.len());
124    m.extend_from_slice(RELAY_AUTH_DOMAIN);
125    m.extend_from_slice(nonce);
126    m
127}
128
129/// huddle 0.7.8: 12-hex Safety Code derived from the same SHA-256 of the
130/// Ed25519 pubkey that backs `compute_fingerprint`. Format
131/// `SAFE-XXXX-XXXX-XXXX` (uppercase, dash-separated). Display-only — a shorter,
132/// less ambiguous handle to compare against a friend at the start of a session.
133pub fn safety_code(public_key: &[u8; 32]) -> String {
134    let hash = Sha256::digest(public_key);
135    let hex_str = hex::encode(&hash[..6]).to_ascii_uppercase();
136    let groups: Vec<&str> = hex_str
137        .as_bytes()
138        .chunks(4)
139        .map(|chunk| std::str::from_utf8(chunk).unwrap())
140        .collect();
141    format!("SAFE-{}", groups.join("-"))
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn fingerprint_is_deterministic_and_well_formed() {
150        let id = IdentityKeys::from_secret_bytes([42u8; 32]).unwrap();
151        let id2 = IdentityKeys::from_secret_bytes([42u8; 32]).unwrap();
152        assert_eq!(id.fingerprint(), id2.fingerprint());
153        let parts: Vec<&str> = id.fingerprint().split('-').collect();
154        assert_eq!(parts.len(), 6);
155        for p in &parts {
156            assert_eq!(p.len(), 4);
157            assert!(p.chars().all(|c| c.is_ascii_hexdigit()));
158        }
159    }
160
161    #[test]
162    fn mlkem_pubkey_is_stable_and_per_identity() {
163        let bytes = IdentityKeys::generate().unwrap().secret_bytes();
164        let a = IdentityKeys::from_secret_bytes(bytes).unwrap();
165        let b = IdentityKeys::from_secret_bytes(bytes).unwrap();
166        assert_eq!(a.mlkem_public_bytes(), b.mlkem_public_bytes());
167        assert_eq!(a.mlkem_public_bytes().len(), pqc::MLKEM_EK_LEN);
168        let other = IdentityKeys::generate().unwrap();
169        assert_ne!(a.mlkem_public_bytes(), other.mlkem_public_bytes());
170    }
171
172    #[test]
173    fn seed_round_trips_keys() {
174        let id = IdentityKeys::generate().unwrap();
175        assert_eq!(*id.seed(), id.secret_bytes());
176        let restored = IdentityKeys::from_seed(id.seed()).unwrap();
177        assert_eq!(id.fingerprint(), restored.fingerprint());
178        assert_eq!(id.mlkem_public_bytes(), restored.mlkem_public_bytes());
179    }
180
181    #[test]
182    fn safety_code_is_stable_and_well_formed() {
183        let a = safety_code(&[7u8; 32]);
184        assert_eq!(a, safety_code(&[7u8; 32]));
185        assert!(a.starts_with("SAFE-"));
186        let groups: Vec<&str> = a.trim_start_matches("SAFE-").split('-').collect();
187        assert_eq!(groups.len(), 3);
188        for g in &groups {
189            assert_eq!(g.len(), 4);
190        }
191    }
192
193    #[test]
194    fn relay_auth_msg_is_domain_prefixed() {
195        let m = relay_auth_msg(&[9u8; 32]);
196        assert!(m.starts_with(RELAY_AUTH_DOMAIN));
197        assert_eq!(m.len(), RELAY_AUTH_DOMAIN.len() + 32);
198    }
199}