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.6 (WS2-a): our serialized ML-DSA-65 verifying (public) key,
77    /// published in signed announces so peers can **pin** it for hybrid
78    /// post-quantum authentication. Deterministically derived from the Ed25519
79    /// seed (see [`crate::crypto::mldsa`]); stable across restarts, no storage.
80    pub fn mldsa_public_bytes(&self) -> [u8; crate::crypto::mldsa::MLDSA_PK_LEN] {
81        let seed = Zeroizing::new(self.signing_key.to_bytes());
82        crate::crypto::mldsa::MlDsaKeypair::from_identity_seed(&seed).public_bytes()
83    }
84
85    /// huddle 2.0.6 (WS2-a): ML-DSA-65-sign `msg` with our identity's
86    /// deterministically-derived post-quantum authentication key. Used for the
87    /// composite signature on identity/authority envelopes.
88    pub fn mldsa_sign(&self, msg: &[u8]) -> [u8; crate::crypto::mldsa::MLDSA_SIG_LEN] {
89        let seed = Zeroizing::new(self.signing_key.to_bytes());
90        crate::crypto::mldsa::MlDsaKeypair::from_identity_seed(&seed).sign(msg)
91    }
92
93    /// huddle 2.0: export this identity's 32-byte Ed25519 seed — the **sole
94    /// root secret** from which the PeerId, the ML-KEM-768 keypair, and every
95    /// DM key deterministically derive. Returned in a `Zeroizing` wrapper so
96    /// the copy is scrubbed when the caller drops it. Rendered as a 24-word
97    /// BIP39 phrase by [`crate::crypto::mnemonic::seed_to_phrase`] for backup /
98    /// recovery; treat it as the crown jewel.
99    pub fn seed(&self) -> Zeroizing<[u8; 32]> {
100        Zeroizing::new(self.signing_key.to_bytes())
101    }
102
103    /// huddle 2.0: rebuild from a 32-byte Ed25519 seed recovered from a BIP39
104    /// phrase ([`crate::crypto::mnemonic::phrase_to_seed`]). The seed is the
105    /// only input, so the restored keys are byte-for-byte the original.
106    pub fn from_seed(seed: Zeroizing<[u8; 32]>) -> Result<Self> {
107        Ok(Self::from_signing_key(SigningKey::from_bytes(&seed)))
108    }
109}
110
111/// Derive the human-facing 24-char fingerprint from an Ed25519 public key.
112/// Format: `xxxx-xxxx-xxxx-xxxx-xxxx-xxxx` (6 groups of 4 hex chars, 24 hex
113/// chars total = 12 bytes = 96 bits of SHA-256 over the pubkey). Public so
114/// `crypto::verify_signed` can re-derive it from a signed envelope's pubkey
115/// and check that it matches the asserted fingerprint.
116pub fn compute_fingerprint(public_key: &[u8; 32]) -> String {
117    let hash = Sha256::digest(public_key);
118    let hex_str = hex::encode(&hash[..12]);
119    hex_str
120        .as_bytes()
121        .chunks(4)
122        .map(|chunk| std::str::from_utf8(chunk).unwrap())
123        .collect::<Vec<&str>>()
124        .join("-")
125}
126
127/// huddle 1.1.4: domain-separation prefix for the relay client-auth
128/// challenge-response. The client signs `RELAY_AUTH_DOMAIN || nonce` with its
129/// Ed25519 identity key; the relay verifies that signature against the
130/// presented pubkey and checks the pubkey hashes to the claimed fingerprint.
131/// The distinct domain tag keeps this signature from ever being mistaken for a
132/// `SignedRoomMessage` envelope (which commits a different tag).
133pub const RELAY_AUTH_DOMAIN: &[u8] = b"huddle-relay-auth-v1";
134
135/// Build the exact bytes a client signs to prove control of its identity key to
136/// the relay: the domain tag followed by the server's challenge nonce. The
137/// relay (`huddle-server`) now calls this same function, so the two stay
138/// byte-for-byte in sync by construction.
139pub fn relay_auth_msg(nonce: &[u8]) -> Vec<u8> {
140    let mut m = Vec::with_capacity(RELAY_AUTH_DOMAIN.len() + nonce.len());
141    m.extend_from_slice(RELAY_AUTH_DOMAIN);
142    m.extend_from_slice(nonce);
143    m
144}
145
146/// huddle 0.7.8: 12-hex Safety Code derived from the same SHA-256 of the
147/// Ed25519 pubkey that backs `compute_fingerprint`. Format
148/// `SAFE-XXXX-XXXX-XXXX` (uppercase, dash-separated). Display-only — a shorter,
149/// less ambiguous handle to compare against a friend at the start of a session.
150pub fn safety_code(public_key: &[u8; 32]) -> String {
151    let hash = Sha256::digest(public_key);
152    let hex_str = hex::encode(&hash[..6]).to_ascii_uppercase();
153    let groups: Vec<&str> = hex_str
154        .as_bytes()
155        .chunks(4)
156        .map(|chunk| std::str::from_utf8(chunk).unwrap())
157        .collect();
158    format!("SAFE-{}", groups.join("-"))
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn fingerprint_is_deterministic_and_well_formed() {
167        let id = IdentityKeys::from_secret_bytes([42u8; 32]).unwrap();
168        let id2 = IdentityKeys::from_secret_bytes([42u8; 32]).unwrap();
169        assert_eq!(id.fingerprint(), id2.fingerprint());
170        let parts: Vec<&str> = id.fingerprint().split('-').collect();
171        assert_eq!(parts.len(), 6);
172        for p in &parts {
173            assert_eq!(p.len(), 4);
174            assert!(p.chars().all(|c| c.is_ascii_hexdigit()));
175        }
176    }
177
178    #[test]
179    fn mlkem_pubkey_is_stable_and_per_identity() {
180        let bytes = IdentityKeys::generate().unwrap().secret_bytes();
181        let a = IdentityKeys::from_secret_bytes(bytes).unwrap();
182        let b = IdentityKeys::from_secret_bytes(bytes).unwrap();
183        assert_eq!(a.mlkem_public_bytes(), b.mlkem_public_bytes());
184        assert_eq!(a.mlkem_public_bytes().len(), pqc::MLKEM_EK_LEN);
185        let other = IdentityKeys::generate().unwrap();
186        assert_ne!(a.mlkem_public_bytes(), other.mlkem_public_bytes());
187    }
188
189    #[test]
190    fn seed_round_trips_keys() {
191        let id = IdentityKeys::generate().unwrap();
192        assert_eq!(*id.seed(), id.secret_bytes());
193        let restored = IdentityKeys::from_seed(id.seed()).unwrap();
194        assert_eq!(id.fingerprint(), restored.fingerprint());
195        assert_eq!(id.mlkem_public_bytes(), restored.mlkem_public_bytes());
196    }
197
198    #[test]
199    fn safety_code_is_stable_and_well_formed() {
200        let a = safety_code(&[7u8; 32]);
201        assert_eq!(a, safety_code(&[7u8; 32]));
202        assert!(a.starts_with("SAFE-"));
203        let groups: Vec<&str> = a.trim_start_matches("SAFE-").split('-').collect();
204        assert_eq!(groups.len(), 3);
205        for g in &groups {
206            assert_eq!(g.len(), 4);
207        }
208    }
209
210    #[test]
211    fn relay_auth_msg_is_domain_prefixed() {
212        let m = relay_auth_msg(&[9u8; 32]);
213        assert!(m.starts_with(RELAY_AUTH_DOMAIN));
214        assert_eq!(m.len(), RELAY_AUTH_DOMAIN.len() + 32);
215    }
216}