1use 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
20const AGENT_TOML_FILE: &str = "agent.toml";
22const LEGACY_KEY_FILE: &str = "agent_id.key";
24const LEGACY_CLAIM_TOKEN_FILE: &str = "claim_token";
26const LEGACY_AGENT_NAME_FILE: &str = "agent_name";
28
29#[derive(Debug, Clone)]
34pub struct AgentInfo {
35 pub peer_id: PeerId,
37 pub name: Option<String>,
40 pub storage_path: PathBuf,
42 pub dashboard_url: String,
44 pub ed25519_public_key_hex: String,
46 pub x25519_public_key_hex: String,
48 pub claim_token: Option<String>,
50}
51
52impl AgentInfo {
53 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 let (name, claim_token) = if toml_path.exists() {
95 Self::read_registration_from_toml(&toml_path)?
96 } else {
97 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 let identity = AgentIdentity::from_storage_or_generate_new(storage_path)?;
105 let peer_id = identity.peer_id().clone();
106
107 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 format!("https://loa-web.pages.dev/agents/{}", peer_id)
114 } else {
115 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 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 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
155static AGENT_IDENTITY: OnceCell<Arc<AgentIdentity>> = OnceCell::new();
159
160pub fn init_global_identity(identity: Arc<AgentIdentity>) {
168 AGENT_IDENTITY
169 .set(identity)
170 .expect("Global identity already initialized");
171}
172
173pub 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#[derive(Serialize, Deserialize)]
199struct AgentToml {
200 identity: IdentitySection,
201 #[serde(default)]
202 registration: Option<RegistrationSection>,
203}
204
205#[derive(Serialize, Deserialize)]
206struct IdentitySection {
207 ed25519_secret: String,
209 x25519_secret: String,
211}
212
213#[derive(Serialize, Deserialize, Default, Clone)]
214struct RegistrationSection {
215 #[serde(skip_serializing_if = "Option::is_none")]
217 name: Option<String>,
218 #[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 #[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
235impl 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 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 #[deprecated(note = "Use from_storage_or_generate_new instead")]
281 pub fn from_file_or_generate_new(path: &Path) -> Result<Self> {
282 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 #[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 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 let shared_secret = self.x25519_secret.diffie_hellman(&ephemeral_public);
342
343 let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
345
346 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 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 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 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 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 let (ed25519_kp, x25519_secret, x25519_public) =
403 Self::load_legacy_keypairs(&legacy_path)?;
404
405 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 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 fn load_legacy_keypairs(
437 path: &Path,
438 ) -> Result<(ed25519::Keypair, X25519SecretKey, X25519PublicKey)> {
439 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 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 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 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 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 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 let x25519_secret = X25519SecretKey::random_from_rng(OsRng);
504 let x25519_public = X25519PublicKey::from(&x25519_secret);
505
506 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 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
533pub 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 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 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 agent_toml.registration = if registration.name.is_some() || registration.claim_token.is_some() {
579 Some(registration)
580 } else {
581 None
582 };
583
584 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
596pub fn read_claim_token(storage_path: &Path) -> Option<String> {
600 let toml_path = storage_path.join(AGENT_TOML_FILE);
601
602 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 AgentIdentity::read_legacy_file(storage_path, LEGACY_CLAIM_TOKEN_FILE)
617}