Skip to main content

saorsa_core/identity/
cli.rs

1// Copyright 2024 Saorsa Labs
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4//! CLI commands for identity management
5
6use super::node_identity::{IdentityData, NodeId, NodeIdentity};
7use crate::Result;
8use sha2::Digest;
9use std::fs;
10use std::path::{Path, PathBuf};
11use tracing::info;
12
13/// Generate a new identity (no proof-of-work)
14pub fn generate_identity() -> Result<()> {
15    let start = std::time::Instant::now();
16    let identity = NodeIdentity::generate()?;
17    let elapsed = start.elapsed();
18
19    info!("āœ… Identity generated successfully (no PoW)");
20    info!("ā±ļø  Generation time: {:?}", elapsed);
21    info!("šŸ“‹ Identity Details:");
22    info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
23    info!("Node ID:      {}", identity.node_id());
24    info!(
25        "Public Key:   {}",
26        hex::encode(identity.public_key().as_bytes())
27    );
28    info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
29
30    Ok(())
31}
32
33/// Save identity to file
34pub fn save_identity(identity: &NodeIdentity, path: &Path) -> Result<()> {
35    let data = identity.export();
36    let json = serde_json::to_string_pretty(&data).map_err(|e| {
37        crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
38            format!("Failed to serialize identity: {}", e).into(),
39        ))
40    })?;
41
42    fs::write(path, json).map_err(|e| {
43        crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
44            format!("Failed to write identity file: {}", e).into(),
45        ))
46    })?;
47
48    info!("āœ… Identity saved to: {}", path.display());
49    Ok(())
50}
51
52/// Load identity from file
53pub fn load_identity(path: &Path) -> Result<NodeIdentity> {
54    let json = fs::read_to_string(path).map_err(|e| {
55        crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
56            format!("Failed to read identity file: {}", e).into(),
57        ))
58    })?;
59
60    let data: IdentityData = serde_json::from_str(&json).map_err(|e| {
61        crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
62            format!("Failed to parse identity file: {}", e).into(),
63        ))
64    })?;
65
66    let identity = NodeIdentity::import(&data)?;
67
68    info!("āœ… Identity loaded from: {}", path.display());
69    info!("Node ID: {}", identity.node_id());
70
71    Ok(identity)
72}
73
74/// Display identity information
75pub fn show_identity(identity: &NodeIdentity) -> Result<()> {
76    info!("šŸ†” P2P Identity Information");
77    info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
78    info!("Node ID:       {}", identity.node_id());
79    info!(
80        "Public Key:    {}",
81        hex::encode(identity.public_key().as_bytes())
82    );
83    info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
84
85    Ok(())
86}
87
88// Public CLI types and handler used by integration tests
89
90#[derive(Debug)]
91pub enum IdentityCommand {
92    /// Generate a new identity
93    Generate {
94        /// Output file path
95        output: Option<PathBuf>,
96
97        /// Seed for deterministic generation
98        seed: Option<String>,
99    },
100
101    /// Show identity information
102    Show {
103        /// Identity file path
104        path: Option<PathBuf>,
105    },
106
107    /// Verify identity validity
108    Verify {
109        /// Identity file path
110        path: Option<PathBuf>,
111    },
112
113    /// Export identity in different formats
114    Export {
115        /// Identity file path
116        path: Option<PathBuf>,
117
118        /// Output file
119        output: PathBuf,
120
121        /// Export format
122        format: String,
123    },
124
125    /// Sign a message
126    Sign {
127        /// Identity file path
128        identity: Option<PathBuf>,
129
130        /// Message to sign (file path or text)
131        message: MessageInput,
132
133        /// Output file for signature
134        output: Option<PathBuf>,
135    },
136}
137
138#[derive(Debug, Clone)]
139pub enum MessageInput {
140    Text(String),
141    File(PathBuf),
142}
143
144#[derive(Debug)]
145pub enum ExportFormat {
146    Json,
147    Base64,
148    Hex,
149}
150
151pub struct IdentityCliHandler {
152    default_path: Option<PathBuf>,
153}
154
155impl IdentityCliHandler {
156    pub fn new(default_path: Option<PathBuf>) -> Self {
157        Self { default_path }
158    }
159
160    pub async fn execute(&self, command: IdentityCommand) -> Result<String> {
161        match command {
162            IdentityCommand::Generate { output, seed } => self.handle_generate(output, seed).await,
163            IdentityCommand::Show { path } => self.handle_show(path).await,
164            IdentityCommand::Verify { path } => self.handle_verify(path).await,
165            IdentityCommand::Export {
166                path,
167                output,
168                format,
169            } => self.handle_export(path, output, format).await,
170            IdentityCommand::Sign {
171                identity,
172                message,
173                output,
174            } => self.handle_sign(identity, message, output).await,
175        }
176    }
177
178    async fn handle_generate(
179        &self,
180        output: Option<PathBuf>,
181        seed: Option<String>,
182    ) -> Result<String> {
183        let output_path = output
184            .or_else(|| self.default_path.clone())
185            .ok_or_else(|| {
186                crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
187                    "No output path specified".into(),
188                ))
189            })?;
190
191        let identity = if let Some(seed_str) = seed {
192            // Deterministic generation from seed
193            let mut seed_bytes = [0u8; 32];
194            let seed_hash = sha2::Sha256::digest(seed_str.as_bytes());
195            seed_bytes.copy_from_slice(&seed_hash);
196            NodeIdentity::from_seed(&seed_bytes)?
197        } else {
198            NodeIdentity::generate()?
199        };
200
201        identity.save_to_file(&output_path).await?;
202
203        let word_address = derive_word_address(identity.node_id());
204
205        Ok(format!(
206            "Generated new identity\nNode ID: {}\nWord Address: {}\nSaved to: {}",
207            identity.node_id(),
208            word_address,
209            output_path.display()
210        ))
211    }
212
213    async fn handle_show(&self, path: Option<PathBuf>) -> Result<String> {
214        let path = path.or_else(|| self.default_path.clone()).ok_or_else(|| {
215            crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
216                "No identity found".into(),
217            ))
218        })?;
219
220        let identity = NodeIdentity::load_from_file(&path).await?;
221
222        let word_address = derive_word_address(identity.node_id());
223
224        Ok(format!(
225            "Identity Information\nNode ID: {}\nWord Address: {}\nPublic Key: {}\nPoW Difficulty: N/A",
226            identity.node_id(),
227            word_address,
228            hex::encode(identity.public_key().as_bytes())
229        ))
230    }
231
232    async fn handle_verify(&self, path: Option<PathBuf>) -> Result<String> {
233        let path = path.or_else(|| self.default_path.clone()).ok_or_else(|| {
234            crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
235                "No identity found".into(),
236            ))
237        })?;
238
239        let identity = NodeIdentity::load_from_file(&path).await?;
240
241        let _word_address = derive_word_address(identity.node_id());
242
243        Ok("Identity is valid\nāœ“ Proof of Work: Valid\nāœ“ Cryptographic keys: Valid\nāœ“ Word address: Matches".to_string())
244    }
245
246    async fn handle_export(
247        &self,
248        path: Option<PathBuf>,
249        output: PathBuf,
250        format: String,
251    ) -> Result<String> {
252        let path = path.or_else(|| self.default_path.clone()).ok_or_else(|| {
253            crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
254                "No identity found".into(),
255            ))
256        })?;
257
258        let identity = NodeIdentity::load_from_file(&path).await?;
259
260        match format.as_str() {
261            "json" => {
262                identity.save_to_file(&output).await?;
263                Ok(format!("Identity exported to {}", output.display()))
264            }
265            _ => Err(crate::P2PError::Identity(
266                crate::error::IdentityError::InvalidFormat(
267                    format!("Unsupported format: {}", format).into(),
268                ),
269            )),
270        }
271    }
272
273    async fn handle_sign(
274        &self,
275        identity_path: Option<PathBuf>,
276        message: MessageInput,
277        output: Option<PathBuf>,
278    ) -> Result<String> {
279        let path = identity_path
280            .or_else(|| self.default_path.clone())
281            .ok_or_else(|| {
282                crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
283                    "No identity found".into(),
284                ))
285            })?;
286
287        let identity = NodeIdentity::load_from_file(&path).await?;
288
289        let message_bytes = match message {
290            MessageInput::Text(s) => s.into_bytes(),
291            MessageInput::File(p) => tokio::fs::read(&p).await.map_err(|e| {
292                crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
293                    format!("Failed to read message file: {}", e).into(),
294                ))
295            })?,
296        };
297
298        let signature = identity.sign(&message_bytes)?;
299        let sig_hex = hex::encode(signature.as_bytes());
300
301        if let Some(output_path) = output {
302            tokio::fs::write(&output_path, &sig_hex)
303                .await
304                .map_err(|e| {
305                    crate::P2PError::Identity(crate::error::IdentityError::InvalidFormat(
306                        format!("Failed to write signature: {}", e).into(),
307                    ))
308                })?;
309        }
310
311        let message_hash = hex::encode(sha2::Sha256::digest(&message_bytes));
312        Ok(format!(
313            "Signature: {}\nMessage hash: {}",
314            sig_hex, message_hash
315        ))
316    }
317}
318
319fn derive_word_address(node_id: &NodeId) -> String {
320    let hex = hex::encode(node_id.to_bytes());
321    if hex.len() >= 16 {
322        format!(
323            "{}-{}-{}-{}",
324            &hex[0..4],
325            &hex[4..8],
326            &hex[8..12],
327            &hex[12..16]
328        )
329    } else {
330        hex
331    }
332}
333
334impl IdentityCommand {
335    pub fn try_parse_from<I, T>(iter: I) -> std::result::Result<Self, String>
336    where
337        I: IntoIterator<Item = T>,
338        T: Into<std::ffi::OsString> + Clone,
339    {
340        // For test purposes, parse basic commands
341        let args: Vec<String> = iter
342            .into_iter()
343            .map(|s| s.into().into_string().unwrap_or_default())
344            .collect();
345
346        if args.len() < 2 || args[0] != "identity" {
347            return Err("invalid subcommand".to_string());
348        }
349
350        match args[1].as_str() {
351            "generate" => {
352                let mut i = 2;
353                while i < args.len() {
354                    i += 1;
355                }
356                Ok(IdentityCommand::Generate {
357                    output: None,
358                    seed: None,
359                })
360            }
361            "show" => {
362                let mut path = None;
363                let mut i = 2;
364                while i < args.len() {
365                    if args[i] == "--path" && i + 1 < args.len() {
366                        path = Some(PathBuf::from(&args[i + 1]));
367                        i += 2;
368                    } else {
369                        i += 1;
370                    }
371                }
372                Ok(IdentityCommand::Show { path })
373            }
374            _ => Err("invalid subcommand".to_string()),
375        }
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use tempfile::TempDir;
383
384    #[test]
385    fn test_save_and_load_identity() {
386        let temp_dir = TempDir::new().expect("Should create temp directory for test");
387        let identity_path = temp_dir.path().join("test_identity.json");
388
389        // Generate identity
390        let identity = NodeIdentity::generate().expect("Should generate identity in test");
391        let original_id = identity.node_id().clone();
392
393        // Save
394        save_identity(&identity, &identity_path).expect("Should save identity in test");
395
396        // Load
397        let loaded = load_identity(&identity_path).expect("Should load identity in test");
398
399        // Verify
400        assert_eq!(loaded.node_id(), &original_id);
401    }
402}