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 base64::{engine::general_purpose::STANDARD as BASE64, Engine};
12use crate::{Error, Result};
13use libp2p_identity::{ed25519, Keypair, PeerId};
14use once_cell::sync::OnceCell;
15use rand::rngs::OsRng;
16use serde::{Deserialize, Serialize};
17use std::{fs, path::Path, path::PathBuf, sync::Arc};
18use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519SecretKey};
19
20/// Filename for consolidated agent data (new format)
21const AGENT_TOML_FILE: &str = "agent.toml";
22/// Filename for legacy key storage (old format)
23const LEGACY_KEY_FILE: &str = "agent_id.key";
24/// Filename for legacy claim token (old format)
25const LEGACY_CLAIM_TOKEN_FILE: &str = "claim_token";
26/// Filename for legacy agent name (old format)
27const LEGACY_AGENT_NAME_FILE: &str = "agent_name";
28
29/// Lightweight agent information that can be read without starting the runtime.
30///
31/// Use this for CLI info commands, status checks, or any scenario where you need
32/// agent metadata without spawning the full actor system.
33#[derive(Debug, Clone)]
34pub struct AgentInfo {
35    /// Unique agent identifier derived from Ed25519 public key
36    pub peer_id: PeerId,
37    /// Human-readable 3-word name (e.g., "conscious-jade-mongoose")
38    /// None if agent hasn't registered with the server yet
39    pub name: Option<String>,
40    /// Path to agent storage directory
41    pub storage_path: PathBuf,
42    /// Dashboard URL for this agent
43    pub dashboard_url: String,
44    /// Ed25519 public key in hex format
45    pub ed25519_public_key_hex: String,
46    /// X25519 public key in hex format (for future encrypted communication)
47    pub x25519_public_key_hex: String,
48    /// Workspace claim token if agent is claimed
49    pub claim_token: Option<String>,
50}
51
52impl AgentInfo {
53    /// Read agent info from storage without starting the runtime.
54    ///
55    /// This is a lightweight operation that only reads files from disk.
56    /// No actors, databases, or network connections are started.
57    ///
58    /// Tries TOML format first (`agent.toml`), then falls back to legacy
59    /// separate files (`agent_id.key`, `claim_token`, `agent_name`).
60    ///
61    /// # Arguments
62    ///
63    /// * `storage_path` - Path to the agent's storage directory
64    ///
65    /// # Returns
66    ///
67    /// Returns `AgentInfo` if the identity file exists, or an error if the
68    /// agent has not been initialized yet.
69    ///
70    /// # Example
71    ///
72    /// ```no_run
73    /// use elo::AgentInfo;
74    /// use std::path::Path;
75    ///
76    /// let info = AgentInfo::read(Path::new("/var/lib/loa"))?;
77    /// println!("Agent: {} ({})", info.name, info.peer_id);
78    /// println!("Dashboard: {}", info.dashboard_url);
79    /// # Ok::<(), elo::Error>(())
80    /// ```
81    pub fn read(storage_path: &Path) -> Result<Self> {
82        let toml_path = storage_path.join(AGENT_TOML_FILE);
83        let legacy_path = storage_path.join(LEGACY_KEY_FILE);
84
85        if !toml_path.exists() && !legacy_path.exists() {
86            return Err(Error::Config(format!(
87                "Agent not initialized. No identity found at {} or {}",
88                toml_path.display(),
89                legacy_path.display()
90            )));
91        }
92
93        // Try to read registration data from TOML first
94        let (name, claim_token) = if toml_path.exists() {
95            Self::read_registration_from_toml(&toml_path)?
96        } else {
97            // Fall back to legacy separate files
98            let name = AgentIdentity::read_legacy_file(storage_path, LEGACY_AGENT_NAME_FILE);
99            let claim_token = AgentIdentity::read_legacy_file(storage_path, LEGACY_CLAIM_TOKEN_FILE);
100            (name, claim_token)
101        };
102
103        // Load identity (handles TOML vs legacy automatically)
104        let identity = AgentIdentity::from_storage_or_generate_new(storage_path)?;
105        let peer_id = identity.peer_id().clone();
106
107        // Build dashboard URL
108        let api_url = crate::constants::api_url();
109        let dashboard_url = if api_url.contains("localhost") {
110            format!("http://localhost:3000/agents/{}", peer_id)
111        } else if api_url.contains("workers.dev") {
112            // Dev environment
113            format!("https://loa-web.pages.dev/agents/{}", peer_id)
114        } else {
115            // Production
116            format!("https://loa.sh/agents/{}", peer_id)
117        };
118
119        Ok(Self {
120            peer_id,
121            name,
122            storage_path: storage_path.to_path_buf(),
123            dashboard_url,
124            ed25519_public_key_hex: identity.ed25519_public_key_hex().to_string(),
125            x25519_public_key_hex: identity.x25519_public_key_hex().to_string(),
126            claim_token,
127        })
128    }
129
130    /// Read registration section from TOML file
131    fn read_registration_from_toml(toml_path: &Path) -> Result<(Option<String>, Option<String>)> {
132        let toml_content = fs::read_to_string(toml_path)
133            .map_err(|e| Error::Io(std::io::Error::new(e.kind(), format!("read agent.toml: {}", e))))?;
134        let agent_toml: AgentToml = toml::from_str(&toml_content)
135            .map_err(|e| Error::Config(format!("parse agent.toml: {}", e)))?;
136
137        let (name, claim_token) = match agent_toml.registration {
138            Some(reg) => (reg.name, reg.claim_token),
139            None => (None, None),
140        };
141
142        Ok((name, claim_token))
143    }
144
145    /// Check if the agent identity exists at the given storage path.
146    ///
147    /// This is a quick check that doesn't load the identity file.
148    /// Checks for both TOML and legacy formats.
149    pub fn exists(storage_path: &Path) -> bool {
150        storage_path.join(AGENT_TOML_FILE).exists()
151            || storage_path.join(LEGACY_KEY_FILE).exists()
152    }
153}
154
155/// Global agent identity singleton
156///
157/// Initialized once during agent startup and accessible from all actors.
158static AGENT_IDENTITY: OnceCell<Arc<AgentIdentity>> = OnceCell::new();
159
160/// Initialize the global agent identity
161///
162/// This should be called once during agent startup, typically in the builder.
163///
164/// # Panics
165///
166/// Panics if called more than once.
167pub fn init_global_identity(identity: Arc<AgentIdentity>) {
168    AGENT_IDENTITY
169        .set(identity)
170        .expect("Global identity already initialized");
171}
172
173/// Get a clone of the global agent identity
174///
175/// Returns an Arc clone for efficient sharing across actors.
176///
177/// # Panics
178///
179/// Panics if called before `init_global_identity()`.
180pub fn get_global_identity() -> Arc<AgentIdentity> {
181    AGENT_IDENTITY
182        .get()
183        .expect("Global identity not initialized - call init_global_identity() first")
184        .clone()
185}
186
187#[derive(Serialize, Deserialize)]
188struct PersistedKeys {
189    ed25519_bytes: Vec<u8>,
190    x25519_bytes: Vec<u8>,
191}
192
193// ============================================================================
194// TOML FORMAT (new consolidated format)
195// ============================================================================
196
197/// TOML structure for consolidated agent persistence
198#[derive(Serialize, Deserialize)]
199struct AgentToml {
200    identity: IdentitySection,
201    #[serde(default)]
202    registration: Option<RegistrationSection>,
203}
204
205#[derive(Serialize, Deserialize)]
206struct IdentitySection {
207    /// Base64-encoded Ed25519 secret key (64 bytes)
208    ed25519_secret: String,
209    /// Base64-encoded X25519 secret key (32 bytes)
210    x25519_secret: String,
211}
212
213#[derive(Serialize, Deserialize, Default, Clone)]
214struct RegistrationSection {
215    /// Human-readable 3-word name (e.g., "conscious-jade-mongoose")
216    #[serde(skip_serializing_if = "Option::is_none")]
217    name: Option<String>,
218    /// Workspace claim token
219    #[serde(skip_serializing_if = "Option::is_none")]
220    claim_token: Option<String>,
221}
222
223pub struct AgentIdentity {
224    ed25519_keypair: Keypair,
225    x25519_secret: X25519SecretKey,
226    /// X25519 public key - reserved for future encrypted communication.
227    /// Currently only sent to API during registration; not used locally yet.
228    #[allow(dead_code)]
229    x25519_public: X25519PublicKey,
230    peer_id: PeerId,
231    ed25519_public_key_hex: String,
232    x25519_public_key_hex: String,
233}
234
235// Manual Debug impl that skips secret fields for security
236impl std::fmt::Debug for AgentIdentity {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        f.debug_struct("AgentIdentity")
239            .field("peer_id", &self.peer_id)
240            .field("ed25519_public_key_hex", &self.ed25519_public_key_hex)
241            .field("x25519_public_key_hex", &self.x25519_public_key_hex)
242            .finish_non_exhaustive()
243    }
244}
245
246impl AgentIdentity {
247    /// Load agent identity from storage, migrating or generating as needed.
248    ///
249    /// Priority:
250    /// 1. Load from `agent.toml` (new consolidated format)
251    /// 2. Migrate from `agent_id.key` + separate files (legacy format)
252    /// 3. Generate new identity and save as `agent.toml`
253    pub fn from_storage_or_generate_new(storage_path: &Path) -> Result<Self> {
254        let toml_path = storage_path.join(AGENT_TOML_FILE);
255        let legacy_path = storage_path.join(LEGACY_KEY_FILE);
256
257        let (ed25519_keypair, x25519_secret, x25519_public) = if toml_path.exists() {
258            Self::load_keypairs_from_toml(&toml_path)?
259        } else if legacy_path.exists() {
260            Self::migrate_legacy_to_toml(storage_path)?
261        } else {
262            Self::generate_and_save_new_toml(storage_path)?
263        };
264
265        let peer_id = PeerId::from_public_key(&ed25519_keypair.public());
266        let ed25519_public_key_hex = hex::encode(ed25519_keypair.public().encode_protobuf());
267        let x25519_public_key_hex = hex::encode(x25519_public.as_bytes());
268
269        Ok(Self {
270            ed25519_keypair,
271            x25519_secret,
272            x25519_public,
273            peer_id,
274            ed25519_public_key_hex,
275            x25519_public_key_hex,
276        })
277    }
278
279    /// Legacy method for backwards compatibility - delegates to from_storage_or_generate_new
280    #[deprecated(note = "Use from_storage_or_generate_new instead")]
281    pub fn from_file_or_generate_new(path: &Path) -> Result<Self> {
282        // Assume path is to agent_id.key, get parent directory
283        let storage_path = path.parent().ok_or_else(|| {
284            Error::Config("Invalid identity path - no parent directory".to_string())
285        })?;
286        Self::from_storage_or_generate_new(storage_path)
287    }
288
289    pub fn peer_id(&self) -> &PeerId {
290        &self.peer_id
291    }
292
293    pub fn ed25519_public_key_hex(&self) -> &str {
294        &self.ed25519_public_key_hex
295    }
296
297    pub fn x25519_public_key_hex(&self) -> &str {
298        &self.x25519_public_key_hex
299    }
300
301    pub fn sign_message_as_hex(&self, message: &[u8]) -> Result<String> {
302        let signature = self
303            .ed25519_keypair
304            .sign(message)
305            .map_err(|e| Self::err("sign", e))?;
306        Ok(hex::encode(signature))
307    }
308
309    /// Decrypt data using x25519 + XChaCha20-Poly1305
310    ///
311    /// **Note:** This method is reserved for future encrypted communication features
312    /// (e.g., encrypted config updates, encrypted metrics). Currently unused but
313    /// maintained for forward compatibility with the encryption package.
314    ///
315    /// Expected format of encrypted_data:
316    /// - First 32 bytes: ephemeral x25519 public key
317    /// - Remaining bytes: XChaCha20-Poly1305 ciphertext (includes auth tag)
318    #[allow(dead_code)]
319    pub fn decrypt_x25519(
320        &self,
321        encrypted_data: &[u8],
322        nonce: &[u8],
323    ) -> std::result::Result<Vec<u8>, String> {
324        use chacha20poly1305::{
325            aead::{Aead, KeyInit},
326            XChaCha20Poly1305, XNonce,
327        };
328
329        // Extract ephemeral public key (first 32 bytes)
330        if encrypted_data.len() < 32 {
331            return Err("encrypted data too short".to_string());
332        }
333
334        let (ephemeral_public_bytes, ciphertext) = encrypted_data.split_at(32);
335        let ephemeral_public_array: [u8; 32] = ephemeral_public_bytes
336            .try_into()
337            .map_err(|_| "invalid ephemeral public key")?;
338        let ephemeral_public = X25519PublicKey::from(ephemeral_public_array);
339
340        // Compute shared secret using Diffie-Hellman
341        let shared_secret = self.x25519_secret.diffie_hellman(&ephemeral_public);
342
343        // Use shared secret as XChaCha20-Poly1305 key (24-byte nonce)
344        let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
345
346        // Decrypt with provided 24-byte nonce
347        let nonce_array: XNonce = nonce
348            .try_into()
349            .map_err(|_| "Invalid nonce length".to_string())?;
350        let plaintext = cipher
351            .decrypt(&nonce_array, ciphertext)
352            .map_err(|e| format!("decryption failed: {}", e))?;
353
354        Ok(plaintext)
355    }
356
357    /// Load keypairs from TOML format (new consolidated format)
358    fn load_keypairs_from_toml(
359        toml_path: &Path,
360    ) -> Result<(Keypair, X25519SecretKey, X25519PublicKey)> {
361        tracing::info!("Loading agent identity from {}", toml_path.display());
362
363        let toml_content =
364            fs::read_to_string(toml_path).map_err(|e| Self::err("read agent.toml", e))?;
365        let agent_toml: AgentToml =
366            toml::from_str(&toml_content).map_err(|e| Self::err("parse agent.toml", e))?;
367
368        // Decode Ed25519 secret from base64
369        let ed25519_bytes = BASE64
370            .decode(&agent_toml.identity.ed25519_secret)
371            .map_err(|e| Self::err("decode Ed25519 base64", e))?;
372        let mut ed25519_bytes_array: [u8; 64] = ed25519_bytes
373            .try_into()
374            .map_err(|_| Self::err("invalid Ed25519 key length", "expected 64 bytes"))?;
375        let ed25519_kp = ed25519::Keypair::try_from_bytes(&mut ed25519_bytes_array)
376            .map_err(|e| Self::err("decode Ed25519 keypair", e))?;
377
378        // Decode X25519 secret from base64
379        let x25519_bytes = BASE64
380            .decode(&agent_toml.identity.x25519_secret)
381            .map_err(|e| Self::err("decode X25519 base64", e))?;
382        let x25519_bytes_array: [u8; 32] = x25519_bytes
383            .try_into()
384            .map_err(|_| Self::err("invalid X25519 key length", "expected 32 bytes"))?;
385        let x25519_secret = X25519SecretKey::from(x25519_bytes_array);
386        let x25519_public = X25519PublicKey::from(&x25519_secret);
387
388        Ok((ed25519_kp.into(), x25519_secret, x25519_public))
389    }
390
391    /// Migrate from legacy format (agent_id.key + separate files) to TOML
392    fn migrate_legacy_to_toml(
393        storage_path: &Path,
394    ) -> Result<(Keypair, X25519SecretKey, X25519PublicKey)> {
395        let legacy_path = storage_path.join(LEGACY_KEY_FILE);
396        tracing::info!(
397            "Migrating legacy agent identity from {} to agent.toml",
398            legacy_path.display()
399        );
400
401        // Load keys from legacy format (JSON or binary)
402        let (ed25519_kp, x25519_secret, x25519_public) =
403            Self::load_legacy_keypairs(&legacy_path)?;
404
405        // Read optional registration data from separate files
406        let claim_token = Self::read_legacy_file(storage_path, LEGACY_CLAIM_TOKEN_FILE);
407        let name = Self::read_legacy_file(storage_path, LEGACY_AGENT_NAME_FILE);
408
409        // Build and save TOML
410        let agent_toml = AgentToml {
411            identity: IdentitySection {
412                ed25519_secret: BASE64.encode(ed25519_kp.to_bytes()),
413                x25519_secret: BASE64.encode(x25519_secret.to_bytes()),
414            },
415            registration: if name.is_some() || claim_token.is_some() {
416                Some(RegistrationSection { name, claim_token })
417            } else {
418                None
419            },
420        };
421
422        let toml_path = storage_path.join(AGENT_TOML_FILE);
423        let toml_content =
424            toml::to_string_pretty(&agent_toml).map_err(|e| Self::err("serialize agent.toml", e))?;
425        fs::write(&toml_path, &toml_content).map_err(|e| Self::err("write agent.toml", e))?;
426
427        tracing::info!(
428            "Migration complete - agent data consolidated in {}",
429            toml_path.display()
430        );
431
432        Ok((ed25519_kp.into(), x25519_secret, x25519_public))
433    }
434
435    /// Load keypairs from legacy JSON format (agent_id.key)
436    fn load_legacy_keypairs(
437        path: &Path,
438    ) -> Result<(ed25519::Keypair, X25519SecretKey, X25519PublicKey)> {
439        // Try loading as JSON first
440        if let Ok(json) = fs::read_to_string(path) {
441            if let Ok(persisted) = serde_json::from_str::<PersistedKeys>(&json) {
442                let mut ed25519_bytes = persisted.ed25519_bytes;
443                let ed25519_kp = ed25519::Keypair::try_from_bytes(&mut ed25519_bytes)
444                    .map_err(|e| Self::err("decode Ed25519 key", e))?;
445
446                let x25519_bytes: [u8; 32] = persisted
447                    .x25519_bytes
448                    .try_into()
449                    .map_err(|_| Self::err("invalid X25519 key length", "expected 32 bytes"))?;
450                let x25519_secret = X25519SecretKey::from(x25519_bytes);
451                let x25519_public = X25519PublicKey::from(&x25519_secret);
452
453                return Ok((ed25519_kp, x25519_secret, x25519_public));
454            }
455        }
456
457        // Fall back to old binary format (ed25519 only, needs X25519 generation)
458        tracing::info!("Loading old binary identity format");
459        let mut bytes = fs::read(path).map_err(|e| Self::err("read identity file", e))?;
460        let ed25519_kp = ed25519::Keypair::try_from_bytes(&mut bytes)
461            .map_err(|e| Self::err("decode Ed25519 key", e))?;
462
463        // Generate new X25519 keypair for migration
464        let x25519_secret = X25519SecretKey::random_from_rng(OsRng);
465        let x25519_public = X25519PublicKey::from(&x25519_secret);
466
467        Ok((ed25519_kp, x25519_secret, x25519_public))
468    }
469
470    /// Read a legacy file (claim_token or agent_name), returning None if missing/empty
471    fn read_legacy_file(storage_path: &Path, filename: &str) -> Option<String> {
472        let path = storage_path.join(filename);
473        if path.exists() {
474            fs::read_to_string(&path)
475                .ok()
476                .map(|s| s.trim().to_string())
477                .filter(|s| !s.is_empty())
478        } else {
479            None
480        }
481    }
482
483    /// Generate new identity and save as TOML
484    fn generate_and_save_new_toml(
485        storage_path: &Path,
486    ) -> Result<(Keypair, X25519SecretKey, X25519PublicKey)> {
487        let toml_path = storage_path.join(AGENT_TOML_FILE);
488        tracing::info!(
489            "Generating new agent identity (saved to {})",
490            toml_path.display()
491        );
492
493        // Generate Ed25519 keypair
494        let mut bytes = [0u8; 32];
495        use rand::RngCore;
496        OsRng.fill_bytes(&mut bytes);
497        let ed25519_kp = ed25519::Keypair::from(
498            ed25519::SecretKey::try_from_bytes(&mut bytes)
499                .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?,
500        );
501
502        // Generate X25519 keypair
503        let x25519_secret = X25519SecretKey::random_from_rng(OsRng);
504        let x25519_public = X25519PublicKey::from(&x25519_secret);
505
506        // Build TOML (no registration data for new identity)
507        let agent_toml = AgentToml {
508            identity: IdentitySection {
509                ed25519_secret: BASE64.encode(ed25519_kp.to_bytes()),
510                x25519_secret: BASE64.encode(x25519_secret.to_bytes()),
511            },
512            registration: None,
513        };
514
515        // Ensure storage directory exists
516        fs::create_dir_all(storage_path).map_err(|e| Self::err("create storage directory", e))?;
517
518        let toml_content =
519            toml::to_string_pretty(&agent_toml).map_err(|e| Self::err("serialize agent.toml", e))?;
520        fs::write(&toml_path, &toml_content).map_err(|e| Self::err("write agent.toml", e))?;
521
522        Ok((ed25519_kp.into(), x25519_secret, x25519_public))
523    }
524
525    fn err(context: &str, error: impl std::fmt::Display) -> Error {
526        Error::Io(std::io::Error::new(
527            std::io::ErrorKind::Other,
528            format!("{}: {}", context, error),
529        ))
530    }
531}
532
533// ============================================================================
534// PUBLIC HELPERS
535// ============================================================================
536
537/// Update the registration section of agent.toml
538///
539/// This is used by the AlertsWorker to persist name and claim_token after
540/// successful registration with the server.
541///
542/// If agent.toml doesn't exist, this will return an error - the agent must
543/// be initialized first via AgentIdentity::from_storage_or_generate_new().
544pub fn update_agent_toml_registration(
545    storage_path: &Path,
546    name: Option<&str>,
547    claim_token: Option<&str>,
548) -> Result<()> {
549    let toml_path = storage_path.join(AGENT_TOML_FILE);
550
551    if !toml_path.exists() {
552        return Err(Error::Config(format!(
553            "Cannot update registration: {} does not exist",
554            toml_path.display()
555        )));
556    }
557
558    // Read existing TOML
559    let toml_content =
560        fs::read_to_string(&toml_path).map_err(|e| Error::Io(std::io::Error::new(
561            e.kind(),
562            format!("read agent.toml: {}", e),
563        )))?;
564    let mut agent_toml: AgentToml =
565        toml::from_str(&toml_content).map_err(|e| Error::Config(format!("parse agent.toml: {}", e)))?;
566
567    // Update registration section
568    let mut registration = agent_toml.registration.unwrap_or_default();
569
570    if let Some(n) = name {
571        registration.name = Some(n.to_string());
572    }
573    if let Some(ct) = claim_token {
574        registration.claim_token = Some(ct.to_string());
575    }
576
577    // Only set registration if there's something to store
578    agent_toml.registration = if registration.name.is_some() || registration.claim_token.is_some() {
579        Some(registration)
580    } else {
581        None
582    };
583
584    // Write back
585    let toml_content = toml::to_string_pretty(&agent_toml)
586        .map_err(|e| Error::Config(format!("serialize agent.toml: {}", e)))?;
587    fs::write(&toml_path, &toml_content).map_err(|e| Error::Io(std::io::Error::new(
588        e.kind(),
589        format!("write agent.toml: {}", e),
590    )))?;
591
592    tracing::debug!("Updated registration in {}", toml_path.display());
593    Ok(())
594}
595
596/// Read claim token from storage (TOML or legacy file)
597///
598/// Used by AlertsWorker during startup to load existing claim token.
599pub fn read_claim_token(storage_path: &Path) -> Option<String> {
600    let toml_path = storage_path.join(AGENT_TOML_FILE);
601
602    // Try TOML first
603    if toml_path.exists() {
604        if let Ok(toml_content) = fs::read_to_string(&toml_path) {
605            if let Ok(agent_toml) = toml::from_str::<AgentToml>(&toml_content) {
606                if let Some(reg) = agent_toml.registration {
607                    if reg.claim_token.is_some() {
608                        return reg.claim_token;
609                    }
610                }
611            }
612        }
613    }
614
615    // Fall back to legacy file
616    AgentIdentity::read_legacy_file(storage_path, LEGACY_CLAIM_TOKEN_FILE)
617}