Skip to main content

saorsa_gossip_identity/
lib.rs

1#![warn(missing_docs)]
2
3//! ML-DSA identity and key management
4//!
5//! Manages long-term ML-DSA identities
6
7use anyhow::{Context, Result};
8use saorsa_gossip_types::PeerId;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11
12/// ML-DSA key pair using saorsa-pqc ML-DSA-65
13///
14/// Stores raw key bytes for serialization compatibility.
15/// Uses ML-DSA-65 which provides ~128-bit security level per SPEC2 §2.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct MlDsaKeyPair {
18    /// Public key bytes (ML-DSA-65 public key)
19    pub public_key: Vec<u8>,
20    /// Secret key bytes (to be secured, ML-DSA-65 secret key)
21    secret_key: Vec<u8>,
22}
23
24impl MlDsaKeyPair {
25    /// Generate a new ML-DSA-65 key pair
26    ///
27    /// Uses saorsa-pqc for post-quantum digital signatures.
28    pub fn generate() -> Result<Self> {
29        use saorsa_pqc::{MlDsa65, MlDsaOperations};
30
31        let signer = MlDsa65::new();
32        let (pk, sk) = signer.generate_keypair()?;
33
34        Ok(Self {
35            public_key: pk.as_bytes().to_vec(),
36            secret_key: sk.as_bytes().to_vec(),
37        })
38    }
39
40    /// Construct a key pair from raw ML-DSA-65 public and secret key bytes.
41    ///
42    /// For callers that already hold an ML-DSA-65 identity outside this crate
43    /// — e.g. the machine keypair that backs a node's QUIC peer id — and need
44    /// to sign gossip messages (presence beacons, FOAF responses) with that
45    /// same identity. Signing with the identity that derives `peer_id` is
46    /// required so receivers can verify `PeerId::from_pubkey(public_key)`
47    /// binds to the claimed sender; a freshly generated key would fail that
48    /// binding check.
49    ///
50    /// The bytes are taken as-is; callers are responsible for supplying a
51    /// matching ML-DSA-65 public/secret pair.
52    pub fn from_keypair_bytes(public_key: Vec<u8>, secret_key: Vec<u8>) -> Self {
53        Self {
54            public_key,
55            secret_key,
56        }
57    }
58
59    /// Get public key bytes
60    pub fn public_key(&self) -> &[u8] {
61        &self.public_key
62    }
63
64    /// Get secret key bytes (for transport identity synchronization)
65    pub fn secret_key(&self) -> &[u8] {
66        &self.secret_key
67    }
68
69    /// Get saorsa-pqc secret key type
70    pub fn get_secret_key_typed(&self) -> Result<saorsa_pqc::MlDsaSecretKey> {
71        Ok(saorsa_pqc::MlDsaSecretKey::from_bytes(&self.secret_key)?)
72    }
73
74    /// Derive PeerId from public key
75    pub fn peer_id(&self) -> PeerId {
76        PeerId::from_pubkey(&self.public_key)
77    }
78
79    /// Sign a message using ML-DSA-65
80    ///
81    /// # Arguments
82    /// * `message` - Message bytes to sign
83    ///
84    /// # Returns
85    /// ML-DSA-65 signature bytes
86    pub fn sign(&self, message: &[u8]) -> Result<Vec<u8>> {
87        use saorsa_pqc::{MlDsa65, MlDsaOperations};
88
89        let signer = MlDsa65::new();
90        let sk = self.get_secret_key_typed()?;
91        let signature = signer.sign(&sk, message)?;
92        Ok(signature.as_bytes().to_vec())
93    }
94
95    /// Verify a signature using ML-DSA-65
96    ///
97    /// # Arguments
98    /// * `public_key` - Public key bytes
99    /// * `message` - Original message bytes
100    /// * `signature` - Signature bytes to verify
101    ///
102    /// # Returns
103    /// `Ok(true)` if signature is valid, `Ok(false)` if invalid
104    pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<bool> {
105        use saorsa_pqc::{MlDsa65, MlDsaOperations, MlDsaPublicKey, MlDsaSignature};
106
107        let verifier = MlDsa65::new();
108        let pk = MlDsaPublicKey::from_bytes(public_key)?;
109        let sig = MlDsaSignature::from_bytes(signature)?;
110
111        Ok(verifier.verify(&pk, message, &sig)?)
112    }
113
114    /// Serialize key pair to bytes using postcard
115    pub fn to_bytes(&self) -> Result<Vec<u8>> {
116        postcard::to_stdvec(self).context("Failed to serialize keypair")
117    }
118
119    /// Deserialize key pair from bytes using postcard
120    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
121        postcard::from_bytes(bytes).context("Failed to deserialize keypair")
122    }
123}
124
125/// Identity with human-readable alias
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct Identity {
128    /// ML-DSA key pair
129    key_pair: MlDsaKeyPair,
130    /// Human-readable alias
131    alias: String,
132}
133
134impl Identity {
135    /// Create a new identity with alias
136    pub fn new(alias: String) -> Result<Self> {
137        Ok(Self {
138            key_pair: MlDsaKeyPair::generate()?,
139            alias,
140        })
141    }
142
143    /// Load existing identity or create new one
144    ///
145    /// This is the primary API for Communitas integration. It will:
146    /// 1. Try to load an existing identity from the keystore
147    /// 2. If not found, create a new identity and save it
148    ///
149    /// # Arguments
150    /// * `four_words` - The four-word identifier (e.g., "ocean-forest-moon-star")
151    /// * `display_name` - Human-readable display name
152    /// * `keystore_path` - Path to the keystore directory
153    pub async fn load_or_create(
154        four_words: &str,
155        display_name: &str,
156        keystore_path: &str,
157    ) -> Result<Self> {
158        // Try to load existing
159        match Self::load_from_keystore(four_words, keystore_path).await {
160            Ok(identity) => Ok(identity),
161            Err(_) => {
162                // Create new identity
163                let identity = Self::new(display_name.to_string())?;
164
165                // Save to keystore
166                identity.save_to_keystore(four_words, keystore_path).await?;
167
168                Ok(identity)
169            }
170        }
171    }
172
173    /// Load identity from encrypted keystore
174    ///
175    /// # Arguments
176    /// * `four_words` - The four-word identifier
177    /// * `keystore_path` - Path to the keystore directory
178    pub async fn load_from_keystore(four_words: &str, keystore_path: &str) -> Result<Self> {
179        let file_path = Self::keystore_file_path(four_words, keystore_path);
180
181        // Read file
182        let data = tokio::fs::read(&file_path).await.context(format!(
183            "Failed to read keystore file: {}",
184            file_path.display()
185        ))?;
186
187        // Deserialize (in production, this would be encrypted)
188        let identity: Identity =
189            postcard::from_bytes(&data).context("Failed to deserialize identity")?;
190
191        Ok(identity)
192    }
193
194    /// Save identity to encrypted keystore
195    ///
196    /// # Arguments
197    /// * `four_words` - The four-word identifier
198    /// * `keystore_path` - Path to the keystore directory
199    pub async fn save_to_keystore(&self, four_words: &str, keystore_path: &str) -> Result<()> {
200        let file_path = Self::keystore_file_path(four_words, keystore_path);
201
202        // Ensure directory exists
203        if let Some(parent) = file_path.parent() {
204            tokio::fs::create_dir_all(parent)
205                .await
206                .context("Failed to create keystore directory")?;
207        }
208
209        // Serialize (in production, this would be encrypted)
210        let data = postcard::to_stdvec(&self).context("Failed to serialize identity")?;
211
212        // Write file
213        tokio::fs::write(&file_path, data).await.context(format!(
214            "Failed to write keystore file: {}",
215            file_path.display()
216        ))?;
217
218        Ok(())
219    }
220
221    /// Get the path to the keystore file for a given four-word identifier
222    fn keystore_file_path(four_words: &str, keystore_path: &str) -> std::path::PathBuf {
223        let safe_filename = four_words.replace('-', "_");
224        Path::new(keystore_path).join(format!("{}.identity", safe_filename))
225    }
226
227    /// Get the alias
228    pub fn alias(&self) -> &str {
229        &self.alias
230    }
231
232    /// Get the PeerId
233    pub fn peer_id(&self) -> PeerId {
234        self.key_pair.peer_id()
235    }
236
237    /// Get the key pair
238    pub fn key_pair(&self) -> &MlDsaKeyPair {
239        &self.key_pair
240    }
241}
242
243#[cfg(test)]
244#[allow(clippy::unwrap_used, clippy::expect_used)]
245mod tests {
246    use super::*;
247    use tempfile::TempDir;
248
249    #[test]
250    fn test_keypair_generation() {
251        let keypair = MlDsaKeyPair::generate();
252        assert!(keypair.is_ok());
253    }
254
255    #[test]
256    fn test_identity_creation() {
257        let identity = Identity::new("Alice".to_string());
258        assert!(identity.is_ok());
259
260        if let Ok(id) = identity {
261            assert_eq!(id.alias(), "Alice");
262        }
263    }
264
265    #[test]
266    fn test_peer_id_derivation() {
267        let keypair = MlDsaKeyPair::generate().ok();
268        if let Some(kp) = keypair {
269            let peer_id = kp.peer_id();
270            assert_eq!(peer_id.as_bytes().len(), 32);
271        }
272    }
273
274    // TDD: New failing tests for load_or_create functionality
275
276    #[tokio::test]
277    async fn test_load_or_create_new_identity() {
278        // RED: This should fail because load_or_create doesn't exist yet
279        let temp_dir = TempDir::new().expect("temp dir");
280        let keystore_path = temp_dir.path().to_str().expect("path");
281
282        let four_words = "ocean-forest-moon-star";
283        let display_name = "Alice";
284
285        let identity = Identity::load_or_create(four_words, display_name, keystore_path)
286            .await
287            .expect("should create new identity");
288
289        assert_eq!(identity.alias(), display_name);
290
291        // PeerId should be deterministic based on key material
292        let peer_id = identity.peer_id();
293        assert_eq!(peer_id.as_bytes().len(), 32);
294    }
295
296    #[tokio::test]
297    async fn test_load_or_create_existing_identity() {
298        // RED: This should fail because load_or_create doesn't exist yet
299        let temp_dir = TempDir::new().expect("temp dir");
300        let keystore_path = temp_dir.path().to_str().expect("path");
301
302        let four_words = "ocean-forest-moon-star";
303        let display_name = "Alice";
304
305        // Create first time
306        let identity1 = Identity::load_or_create(four_words, display_name, keystore_path)
307            .await
308            .expect("should create");
309
310        let peer_id1 = identity1.peer_id();
311
312        // Load second time - should get same identity
313        let identity2 = Identity::load_or_create(four_words, display_name, keystore_path)
314            .await
315            .expect("should load existing");
316
317        let peer_id2 = identity2.peer_id();
318
319        // Same PeerId proves it's the same identity
320        assert_eq!(peer_id1, peer_id2);
321        assert_eq!(identity2.alias(), display_name);
322    }
323
324    #[tokio::test]
325    async fn test_keystore_persistence() {
326        // RED: This should fail because save/load methods don't exist
327        let temp_dir = TempDir::new().expect("temp dir");
328        let keystore_path = temp_dir.path().to_str().expect("path");
329
330        let four_words = "river-mountain-cloud-light";
331        let identity = Identity::new("Bob".to_string()).expect("create");
332
333        // Save to keystore
334        identity
335            .save_to_keystore(four_words, keystore_path)
336            .await
337            .expect("should save");
338
339        // Load from keystore
340        let loaded = Identity::load_from_keystore(four_words, keystore_path)
341            .await
342            .expect("should load");
343
344        // Verify same identity
345        assert_eq!(identity.peer_id(), loaded.peer_id());
346        assert_eq!(identity.alias(), loaded.alias());
347    }
348
349    #[tokio::test]
350    async fn test_load_nonexistent_identity_fails() {
351        // RED: Should fail because load_from_keystore doesn't exist
352        let temp_dir = TempDir::new().expect("temp dir");
353        let keystore_path = temp_dir.path().to_str().expect("path");
354
355        let result = Identity::load_from_keystore("nonexistent-four-words", keystore_path).await;
356
357        // Should return error for non-existent identity
358        assert!(result.is_err());
359    }
360
361    #[tokio::test]
362    async fn test_multiple_identities_in_same_keystore() {
363        // Test that we can store multiple identities with different four-word IDs
364        let temp_dir = TempDir::new().expect("temp dir");
365        let keystore_path = temp_dir.path().to_str().expect("path");
366
367        // Create two different identities
368        let alice = Identity::load_or_create("ocean-forest-moon-star", "Alice", keystore_path)
369            .await
370            .expect("alice");
371
372        let bob = Identity::load_or_create("river-mountain-cloud-light", "Bob", keystore_path)
373            .await
374            .expect("bob");
375
376        // Different aliases
377        assert_ne!(alice.alias(), bob.alias());
378
379        // NOTE: With placeholder key generation, PeerIds will be same
380        // In production with real ML-DSA, they would be different
381        // For now, just verify the aliases and that load/save works
382
383        // Load them again - should get same ones
384        let alice2 = Identity::load_or_create("ocean-forest-moon-star", "Alice", keystore_path)
385            .await
386            .expect("alice2");
387
388        let bob2 = Identity::load_or_create("river-mountain-cloud-light", "Bob", keystore_path)
389            .await
390            .expect("bob2");
391
392        // Same identities when reloaded
393        assert_eq!(alice.peer_id(), alice2.peer_id());
394        assert_eq!(alice.alias(), alice2.alias());
395        assert_eq!(bob.peer_id(), bob2.peer_id());
396        assert_eq!(bob.alias(), bob2.alias());
397    }
398}