loa_core/
identity.rs

1//! Agent Identity Management
2//!
3//! Handles Ed25519 keypairs for signing and X25519 keypairs for encryption.
4//!
5//! # Key Usage
6//! - **Ed25519**: Currently used for heartbeat authentication (signing messages)
7//! - **X25519**: Reserved for future encrypted agent-to-agent communication
8//!   (encrypted metrics, encrypted commands, etc.). The API stores both public
9//!   keys but only validates Ed25519 signatures currently.
10
11use crate::{Error, Result};
12use libp2p_identity::{ed25519, Keypair, PeerId};
13use once_cell::sync::OnceCell;
14use rand::rngs::OsRng;
15use serde::{Deserialize, Serialize};
16use std::{fs, path::Path, path::PathBuf, sync::Arc};
17use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519SecretKey};
18
19/// Lightweight agent information that can be read without starting the runtime.
20///
21/// Use this for CLI info commands, status checks, or any scenario where you need
22/// agent metadata without spawning the full actor system.
23#[derive(Debug, Clone)]
24pub struct AgentInfo {
25    /// Unique agent identifier derived from Ed25519 public key
26    pub peer_id: PeerId,
27    /// Human-readable agent name (truncated peer_id)
28    pub name: String,
29    /// Path to agent storage directory
30    pub storage_path: PathBuf,
31    /// Dashboard URL for this agent
32    pub dashboard_url: String,
33    /// Ed25519 public key in hex format
34    pub ed25519_public_key_hex: String,
35    /// X25519 public key in hex format (for future encrypted communication)
36    pub x25519_public_key_hex: String,
37    /// Workspace claim token if agent is claimed
38    pub claim_token: Option<String>,
39}
40
41impl AgentInfo {
42    /// Read agent info from storage without starting the runtime.
43    ///
44    /// This is a lightweight operation that only reads files from disk.
45    /// No actors, databases, or network connections are started.
46    ///
47    /// # Arguments
48    ///
49    /// * `storage_path` - Path to the agent's storage directory
50    ///
51    /// # Returns
52    ///
53    /// Returns `AgentInfo` if the identity file exists, or an error if the
54    /// agent has not been initialized yet.
55    ///
56    /// # Example
57    ///
58    /// ```no_run
59    /// use elo::AgentInfo;
60    /// use std::path::Path;
61    ///
62    /// let info = AgentInfo::read(Path::new("/var/lib/loa"))?;
63    /// println!("Agent: {} ({})", info.name, info.peer_id);
64    /// println!("Dashboard: {}", info.dashboard_url);
65    /// # Ok::<(), elo::Error>(())
66    /// ```
67    pub fn read(storage_path: &Path) -> Result<Self> {
68        let identity_path = storage_path.join("agent_id.key");
69
70        if !identity_path.exists() {
71            return Err(Error::Config(format!(
72                "Agent not initialized. No identity found at {}",
73                identity_path.display()
74            )));
75        }
76
77        // Load identity (this doesn't start any actors)
78        let identity = AgentIdentity::from_file_or_generate_new(&identity_path)?;
79
80        // Read claim token if it exists
81        let claim_token_path = storage_path.join("claim_token");
82        let claim_token = if claim_token_path.exists() {
83            fs::read_to_string(&claim_token_path)
84                .ok()
85                .map(|s| s.trim().to_string())
86                .filter(|s| !s.is_empty())
87        } else {
88            None
89        };
90
91        let peer_id = identity.peer_id().clone();
92        let peer_id_str = peer_id.to_string();
93
94        // Generate human-readable name (first 8 chars of peer_id)
95        let name = format!("agent-{}", &peer_id_str[..8.min(peer_id_str.len())]);
96
97        // Build dashboard URL
98        let api_url = crate::constants::api_url();
99        let dashboard_url = if api_url.contains("localhost") {
100            format!("http://localhost:3000/agents/{}", peer_id)
101        } else if api_url.contains("workers.dev") {
102            // Dev environment
103            format!("https://loa-web.pages.dev/agents/{}", peer_id)
104        } else {
105            // Production
106            format!("https://loa.sh/agents/{}", peer_id)
107        };
108
109        Ok(Self {
110            peer_id,
111            name,
112            storage_path: storage_path.to_path_buf(),
113            dashboard_url,
114            ed25519_public_key_hex: identity.ed25519_public_key_hex().to_string(),
115            x25519_public_key_hex: identity.x25519_public_key_hex().to_string(),
116            claim_token,
117        })
118    }
119
120    /// Check if the agent identity exists at the given storage path.
121    ///
122    /// This is a quick check that doesn't load the identity file.
123    pub fn exists(storage_path: &Path) -> bool {
124        storage_path.join("agent_id.key").exists()
125    }
126}
127
128/// Global agent identity singleton
129///
130/// Initialized once during agent startup and accessible from all actors.
131static AGENT_IDENTITY: OnceCell<Arc<AgentIdentity>> = OnceCell::new();
132
133/// Initialize the global agent identity
134///
135/// This should be called once during agent startup, typically in the builder.
136///
137/// # Panics
138///
139/// Panics if called more than once.
140pub fn init_global_identity(identity: Arc<AgentIdentity>) {
141    AGENT_IDENTITY
142        .set(identity)
143        .expect("Global identity already initialized");
144}
145
146/// Get a clone of the global agent identity
147///
148/// Returns an Arc clone for efficient sharing across actors.
149///
150/// # Panics
151///
152/// Panics if called before `init_global_identity()`.
153pub fn get_global_identity() -> Arc<AgentIdentity> {
154    AGENT_IDENTITY
155        .get()
156        .expect("Global identity not initialized - call init_global_identity() first")
157        .clone()
158}
159
160#[derive(Serialize, Deserialize)]
161struct PersistedKeys {
162    ed25519_bytes: Vec<u8>,
163    x25519_bytes: Vec<u8>,
164}
165
166pub struct AgentIdentity {
167    ed25519_keypair: Keypair,
168    x25519_secret: X25519SecretKey,
169    /// X25519 public key - reserved for future encrypted communication.
170    /// Currently only sent to API during registration; not used locally yet.
171    #[allow(dead_code)]
172    x25519_public: X25519PublicKey,
173    peer_id: PeerId,
174    ed25519_public_key_hex: String,
175    x25519_public_key_hex: String,
176}
177
178// Manual Debug impl that skips secret fields for security
179impl std::fmt::Debug for AgentIdentity {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        f.debug_struct("AgentIdentity")
182            .field("peer_id", &self.peer_id)
183            .field("ed25519_public_key_hex", &self.ed25519_public_key_hex)
184            .field("x25519_public_key_hex", &self.x25519_public_key_hex)
185            .finish_non_exhaustive()
186    }
187}
188
189impl AgentIdentity {
190    pub fn from_file_or_generate_new(path: &Path) -> Result<Self> {
191        let (ed25519_keypair, x25519_secret, x25519_public) = if path.exists() {
192            Self::load_keypairs_from_file(path)?
193        } else {
194            Self::generate_and_save_new_keypairs(path)?
195        };
196
197        let peer_id = PeerId::from_public_key(&ed25519_keypair.public());
198        let ed25519_public_key_hex = hex::encode(ed25519_keypair.public().encode_protobuf());
199        let x25519_public_key_hex = hex::encode(x25519_public.as_bytes());
200
201        Ok(Self {
202            ed25519_keypair,
203            x25519_secret,
204            x25519_public,
205            peer_id,
206            ed25519_public_key_hex,
207            x25519_public_key_hex,
208        })
209    }
210
211    pub fn peer_id(&self) -> &PeerId {
212        &self.peer_id
213    }
214
215    pub fn ed25519_public_key_hex(&self) -> &str {
216        &self.ed25519_public_key_hex
217    }
218
219    pub fn x25519_public_key_hex(&self) -> &str {
220        &self.x25519_public_key_hex
221    }
222
223    pub fn sign_message_as_hex(&self, message: &[u8]) -> Result<String> {
224        let signature = self
225            .ed25519_keypair
226            .sign(message)
227            .map_err(|e| Self::err("sign", e))?;
228        Ok(hex::encode(signature))
229    }
230
231    /// Decrypt data using x25519 + XChaCha20-Poly1305
232    ///
233    /// **Note:** This method is reserved for future encrypted communication features
234    /// (e.g., encrypted config updates, encrypted metrics). Currently unused but
235    /// maintained for forward compatibility with the encryption package.
236    ///
237    /// Expected format of encrypted_data:
238    /// - First 32 bytes: ephemeral x25519 public key
239    /// - Remaining bytes: XChaCha20-Poly1305 ciphertext (includes auth tag)
240    #[allow(dead_code)]
241    pub fn decrypt_x25519(
242        &self,
243        encrypted_data: &[u8],
244        nonce: &[u8],
245    ) -> std::result::Result<Vec<u8>, String> {
246        use chacha20poly1305::{
247            aead::{Aead, KeyInit},
248            XChaCha20Poly1305, XNonce,
249        };
250
251        // Extract ephemeral public key (first 32 bytes)
252        if encrypted_data.len() < 32 {
253            return Err("encrypted data too short".to_string());
254        }
255
256        let (ephemeral_public_bytes, ciphertext) = encrypted_data.split_at(32);
257        let ephemeral_public_array: [u8; 32] = ephemeral_public_bytes
258            .try_into()
259            .map_err(|_| "invalid ephemeral public key")?;
260        let ephemeral_public = X25519PublicKey::from(ephemeral_public_array);
261
262        // Compute shared secret using Diffie-Hellman
263        let shared_secret = self.x25519_secret.diffie_hellman(&ephemeral_public);
264
265        // Use shared secret as XChaCha20-Poly1305 key (24-byte nonce)
266        let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
267
268        // Decrypt with provided 24-byte nonce
269        let nonce_array: XNonce = nonce
270            .try_into()
271            .map_err(|_| "Invalid nonce length".to_string())?;
272        let plaintext = cipher
273            .decrypt(&nonce_array, ciphertext)
274            .map_err(|e| format!("decryption failed: {}", e))?;
275
276        Ok(plaintext)
277    }
278
279    fn load_keypairs_from_file(path: &Path) -> Result<(Keypair, X25519SecretKey, X25519PublicKey)> {
280        tracing::info!("Loading existing agent identity from {}", path.display());
281
282        // Try loading as JSON (new format)
283        if let Ok(json) = fs::read_to_string(path) {
284            if let Ok(persisted) = serde_json::from_str::<PersistedKeys>(&json) {
285                let mut ed25519_bytes = persisted.ed25519_bytes;
286                let ed25519_kp = ed25519::Keypair::try_from_bytes(&mut ed25519_bytes)
287                    .map_err(|e| Self::err("decode Ed25519 key", e))?;
288
289                let x25519_bytes: [u8; 32] = persisted
290                    .x25519_bytes
291                    .try_into()
292                    .map_err(|_| Self::err("invalid X25519 key length", "expected 32 bytes"))?;
293                let x25519_secret = X25519SecretKey::from(x25519_bytes);
294                let x25519_public = X25519PublicKey::from(&x25519_secret);
295
296                return Ok((ed25519_kp.into(), x25519_secret, x25519_public));
297            }
298        }
299
300        // Fall back to loading old binary format and migrate
301        tracing::info!("Migrating old identity format to JSON");
302        let mut bytes = fs::read(path).map_err(|e| Self::err("read identity file", e))?;
303        let ed25519_kp = ed25519::Keypair::try_from_bytes(&mut bytes)
304            .map_err(|e| Self::err("decode Ed25519 key", e))?;
305
306        // Generate new X25519 keypair for migration
307        let x25519_secret = X25519SecretKey::random_from_rng(OsRng);
308        let x25519_public = X25519PublicKey::from(&x25519_secret);
309
310        // Save in new JSON format
311        let ed25519_full = ed25519_kp.to_bytes();
312        let persisted = PersistedKeys {
313            ed25519_bytes: ed25519_full.to_vec(),
314            x25519_bytes: x25519_secret.to_bytes().to_vec(),
315        };
316        let json = serde_json::to_string_pretty(&persisted)
317            .map_err(|e| Self::err("serialize keys during migration", e))?;
318        fs::write(path, json).map_err(|e| Self::err("write migrated identity file", e))?;
319
320        tracing::info!("Identity migration complete");
321        Ok((ed25519_kp.into(), x25519_secret, x25519_public))
322    }
323
324    fn generate_and_save_new_keypairs(
325        path: &Path,
326    ) -> Result<(Keypair, X25519SecretKey, X25519PublicKey)> {
327        tracing::info!(
328            "Generating new agent identity (saved to {})",
329            path.display()
330        );
331
332        // Generate random bytes for keypair
333        let mut bytes = [0u8; 32];
334        use rand::RngCore;
335        OsRng.fill_bytes(&mut bytes);
336        let ed25519_kp = ed25519::Keypair::from(
337            ed25519::SecretKey::try_from_bytes(&mut bytes)
338                .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?,
339        );
340        let ed25519_bytes = ed25519_kp.to_bytes();
341
342        let x25519_secret = X25519SecretKey::random_from_rng(OsRng);
343        let x25519_public = X25519PublicKey::from(&x25519_secret);
344        let x25519_bytes = x25519_secret.to_bytes();
345
346        let persisted = PersistedKeys {
347            ed25519_bytes: ed25519_bytes.to_vec(),
348            x25519_bytes: x25519_bytes.to_vec(),
349        };
350
351        let json =
352            serde_json::to_string_pretty(&persisted).map_err(|e| Self::err("serialize keys", e))?;
353        fs::write(path, json).map_err(|e| Self::err("write identity file", e))?;
354
355        Ok((ed25519_kp.into(), x25519_secret, x25519_public))
356    }
357
358    fn err(context: &str, error: impl std::fmt::Display) -> Error {
359        Error::Io(std::io::Error::new(
360            std::io::ErrorKind::Other,
361            format!("{}: {}", context, error),
362        ))
363    }
364}