huddle_protocol/
identity.rs1use ed25519_dalek::{Signer, SigningKey};
2use sha2::{Digest, Sha256};
3use zeroize::Zeroizing;
4
5use crate::crypto::pqc::{self, PqKeypair};
6use crate::error::Result;
7
8pub 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 pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
56 self.signing_key.sign(msg).to_bytes()
57 }
58
59 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 pub fn mlkem_public_bytes(&self) -> [u8; pqc::MLKEM_EK_LEN] {
73 self.pq_keypair().encapsulation_key_bytes()
74 }
75
76 pub fn seed(&self) -> Zeroizing<[u8; 32]> {
83 Zeroizing::new(self.signing_key.to_bytes())
84 }
85
86 pub fn from_seed(seed: Zeroizing<[u8; 32]>) -> Result<Self> {
90 Ok(Self::from_signing_key(SigningKey::from_bytes(&seed)))
91 }
92}
93
94pub 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
110pub const RELAY_AUTH_DOMAIN: &[u8] = b"huddle-relay-auth-v1";
117
118pub 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
129pub 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}