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, api_url: Option<&str>) -> Result<Self> {
84 let toml_path = storage_path.join(AGENT_TOML_FILE);
85 let legacy_path = storage_path.join(LEGACY_KEY_FILE);
86
87 if !toml_path.exists() && !legacy_path.exists() {
88 return Err(Error::Config(format!(
89 "Agent not initialized. No identity found at {} or {}",
90 toml_path.display(),
91 legacy_path.display()
92 )));
93 }
94
95 let (name, claim_token) = if toml_path.exists() {
97 Self::read_registration_from_toml(&toml_path)?
98 } else {
99 let name = AgentIdentity::read_legacy_file(storage_path, LEGACY_AGENT_NAME_FILE);
101 let claim_token = AgentIdentity::read_legacy_file(storage_path, LEGACY_CLAIM_TOKEN_FILE);
102 (name, claim_token)
103 };
104
105 let identity = AgentIdentity::from_storage_or_generate_new(storage_path)?;
107 let peer_id = identity.peer_id().clone();
108
109 let api_url = api_url
111 .map(|s| s.to_string())
112 .unwrap_or_else(|| crate::constants::api_url());
113 let peer_id_str = peer_id.to_string();
114 let identifier = name.as_deref().unwrap_or(&peer_id_str);
115 let dashboard_url = if api_url.contains("localhost") {
116 format!("http://status.localhost:3000/{}", identifier)
117 } else if api_url.contains("workers.dev") {
118 format!("https://status.loa-web.pages.dev/{}", identifier)
120 } else {
121 format!("https://status.loa.sh/{}", identifier)
123 };
124
125 Ok(Self {
126 peer_id,
127 name,
128 storage_path: storage_path.to_path_buf(),
129 dashboard_url,
130 ed25519_public_key_hex: identity.ed25519_public_key_hex().to_string(),
131 x25519_public_key_hex: identity.x25519_public_key_hex().to_string(),
132 claim_token,
133 })
134 }
135
136 fn read_registration_from_toml(toml_path: &Path) -> Result<(Option<String>, Option<String>)> {
138 let toml_content = fs::read_to_string(toml_path)
139 .map_err(|e| Error::Io(std::io::Error::new(e.kind(), format!("read agent.toml: {}", e))))?;
140 let agent_toml: AgentToml = toml::from_str(&toml_content)
141 .map_err(|e| Error::Config(format!("parse agent.toml: {}", e)))?;
142
143 let (name, claim_token) = match agent_toml.registration {
144 Some(reg) => (reg.name, reg.claim_token),
145 None => (None, None),
146 };
147
148 Ok((name, claim_token))
149 }
150
151 pub fn exists(storage_path: &Path) -> bool {
156 storage_path.join(AGENT_TOML_FILE).exists()
157 || storage_path.join(LEGACY_KEY_FILE).exists()
158 }
159}
160
161static AGENT_IDENTITY: OnceCell<Arc<AgentIdentity>> = OnceCell::new();
165
166pub fn init_global_identity(identity: Arc<AgentIdentity>) {
174 AGENT_IDENTITY
175 .set(identity)
176 .expect("Global identity already initialized");
177}
178
179pub fn get_global_identity() -> Arc<AgentIdentity> {
187 AGENT_IDENTITY
188 .get()
189 .expect("Global identity not initialized - call init_global_identity() first")
190 .clone()
191}
192
193#[derive(Serialize, Deserialize)]
194struct PersistedKeys {
195 ed25519_bytes: Vec<u8>,
196 x25519_bytes: Vec<u8>,
197}
198
199#[derive(Serialize, Deserialize)]
205struct AgentToml {
206 identity: IdentitySection,
207 #[serde(default)]
208 registration: Option<RegistrationSection>,
209}
210
211#[derive(Serialize, Deserialize)]
212struct IdentitySection {
213 ed25519_secret: String,
215 x25519_secret: String,
217}
218
219#[derive(Serialize, Deserialize, Default, Clone)]
220struct RegistrationSection {
221 #[serde(skip_serializing_if = "Option::is_none")]
223 name: Option<String>,
224 #[serde(skip_serializing_if = "Option::is_none")]
226 claim_token: Option<String>,
227}
228
229pub struct AgentIdentity {
230 ed25519_keypair: Keypair,
231 x25519_secret: X25519SecretKey,
232 #[allow(dead_code)]
235 x25519_public: X25519PublicKey,
236 peer_id: PeerId,
237 ed25519_public_key_hex: String,
238 x25519_public_key_hex: String,
239}
240
241impl std::fmt::Debug for AgentIdentity {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 f.debug_struct("AgentIdentity")
245 .field("peer_id", &self.peer_id)
246 .field("ed25519_public_key_hex", &self.ed25519_public_key_hex)
247 .field("x25519_public_key_hex", &self.x25519_public_key_hex)
248 .finish_non_exhaustive()
249 }
250}
251
252impl AgentIdentity {
253 pub fn from_storage_or_generate_new(storage_path: &Path) -> Result<Self> {
260 let toml_path = storage_path.join(AGENT_TOML_FILE);
261 let legacy_path = storage_path.join(LEGACY_KEY_FILE);
262
263 let (ed25519_keypair, x25519_secret, x25519_public) = if toml_path.exists() {
264 Self::load_keypairs_from_toml(&toml_path)?
265 } else if legacy_path.exists() {
266 Self::migrate_legacy_to_toml(storage_path)?
267 } else {
268 Self::generate_and_save_new_toml(storage_path)?
269 };
270
271 let peer_id = PeerId::from_public_key(&ed25519_keypair.public());
272 let ed25519_public_key_hex = hex::encode(ed25519_keypair.public().encode_protobuf());
273 let x25519_public_key_hex = hex::encode(x25519_public.as_bytes());
274
275 Ok(Self {
276 ed25519_keypair,
277 x25519_secret,
278 x25519_public,
279 peer_id,
280 ed25519_public_key_hex,
281 x25519_public_key_hex,
282 })
283 }
284
285 #[deprecated(note = "Use from_storage_or_generate_new instead")]
287 pub fn from_file_or_generate_new(path: &Path) -> Result<Self> {
288 let storage_path = path.parent().ok_or_else(|| {
290 Error::Config("Invalid identity path - no parent directory".to_string())
291 })?;
292 Self::from_storage_or_generate_new(storage_path)
293 }
294
295 pub fn peer_id(&self) -> &PeerId {
296 &self.peer_id
297 }
298
299 pub fn ed25519_public_key_hex(&self) -> &str {
300 &self.ed25519_public_key_hex
301 }
302
303 pub fn x25519_public_key_hex(&self) -> &str {
304 &self.x25519_public_key_hex
305 }
306
307 pub fn sign_message_as_hex(&self, message: &[u8]) -> Result<String> {
308 let signature = self
309 .ed25519_keypair
310 .sign(message)
311 .map_err(|e| Self::err("sign", e))?;
312 Ok(hex::encode(signature))
313 }
314
315 #[allow(dead_code)]
325 pub fn decrypt_x25519(
326 &self,
327 encrypted_data: &[u8],
328 nonce: &[u8],
329 ) -> std::result::Result<Vec<u8>, String> {
330 use chacha20poly1305::{
331 aead::{Aead, KeyInit},
332 XChaCha20Poly1305, XNonce,
333 };
334
335 if encrypted_data.len() < 32 {
337 return Err("encrypted data too short".to_string());
338 }
339
340 let (ephemeral_public_bytes, ciphertext) = encrypted_data.split_at(32);
341 let ephemeral_public_array: [u8; 32] = ephemeral_public_bytes
342 .try_into()
343 .map_err(|_| "invalid ephemeral public key")?;
344 let ephemeral_public = X25519PublicKey::from(ephemeral_public_array);
345
346 let shared_secret = self.x25519_secret.diffie_hellman(&ephemeral_public);
348
349 let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into());
351
352 let nonce_array: XNonce = nonce
354 .try_into()
355 .map_err(|_| "Invalid nonce length".to_string())?;
356 let plaintext = cipher
357 .decrypt(&nonce_array, ciphertext)
358 .map_err(|e| format!("decryption failed: {}", e))?;
359
360 Ok(plaintext)
361 }
362
363 fn load_keypairs_from_toml(
365 toml_path: &Path,
366 ) -> Result<(Keypair, X25519SecretKey, X25519PublicKey)> {
367 tracing::info!("Loading agent identity from {}", toml_path.display());
368
369 let toml_content =
370 fs::read_to_string(toml_path).map_err(|e| Self::err("read agent.toml", e))?;
371 let agent_toml: AgentToml =
372 toml::from_str(&toml_content).map_err(|e| Self::err("parse agent.toml", e))?;
373
374 let ed25519_bytes = BASE64
376 .decode(&agent_toml.identity.ed25519_secret)
377 .map_err(|e| Self::err("decode Ed25519 base64", e))?;
378 let mut ed25519_bytes_array: [u8; 64] = ed25519_bytes
379 .try_into()
380 .map_err(|_| Self::err("invalid Ed25519 key length", "expected 64 bytes"))?;
381 let ed25519_kp = ed25519::Keypair::try_from_bytes(&mut ed25519_bytes_array)
382 .map_err(|e| Self::err("decode Ed25519 keypair", e))?;
383
384 let x25519_bytes = BASE64
386 .decode(&agent_toml.identity.x25519_secret)
387 .map_err(|e| Self::err("decode X25519 base64", e))?;
388 let x25519_bytes_array: [u8; 32] = x25519_bytes
389 .try_into()
390 .map_err(|_| Self::err("invalid X25519 key length", "expected 32 bytes"))?;
391 let x25519_secret = X25519SecretKey::from(x25519_bytes_array);
392 let x25519_public = X25519PublicKey::from(&x25519_secret);
393
394 Ok((ed25519_kp.into(), x25519_secret, x25519_public))
395 }
396
397 fn migrate_legacy_to_toml(
399 storage_path: &Path,
400 ) -> Result<(Keypair, X25519SecretKey, X25519PublicKey)> {
401 let legacy_path = storage_path.join(LEGACY_KEY_FILE);
402 tracing::info!(
403 "Migrating legacy agent identity from {} to agent.toml",
404 legacy_path.display()
405 );
406
407 let (ed25519_kp, x25519_secret, x25519_public) =
409 Self::load_legacy_keypairs(&legacy_path)?;
410
411 let claim_token = Self::read_legacy_file(storage_path, LEGACY_CLAIM_TOKEN_FILE);
413 let name = Self::read_legacy_file(storage_path, LEGACY_AGENT_NAME_FILE);
414
415 let agent_toml = AgentToml {
417 identity: IdentitySection {
418 ed25519_secret: BASE64.encode(ed25519_kp.to_bytes()),
419 x25519_secret: BASE64.encode(x25519_secret.to_bytes()),
420 },
421 registration: if name.is_some() || claim_token.is_some() {
422 Some(RegistrationSection { name, claim_token })
423 } else {
424 None
425 },
426 };
427
428 let toml_path = storage_path.join(AGENT_TOML_FILE);
429 let toml_content =
430 toml::to_string_pretty(&agent_toml).map_err(|e| Self::err("serialize agent.toml", e))?;
431 fs::write(&toml_path, &toml_content).map_err(|e| Self::err("write agent.toml", e))?;
432
433 tracing::info!(
434 "Migration complete - agent data consolidated in {}",
435 toml_path.display()
436 );
437
438 Ok((ed25519_kp.into(), x25519_secret, x25519_public))
439 }
440
441 fn load_legacy_keypairs(
443 path: &Path,
444 ) -> Result<(ed25519::Keypair, X25519SecretKey, X25519PublicKey)> {
445 if let Ok(json) = fs::read_to_string(path) {
447 if let Ok(persisted) = serde_json::from_str::<PersistedKeys>(&json) {
448 let mut ed25519_bytes = persisted.ed25519_bytes;
449 let ed25519_kp = ed25519::Keypair::try_from_bytes(&mut ed25519_bytes)
450 .map_err(|e| Self::err("decode Ed25519 key", e))?;
451
452 let x25519_bytes: [u8; 32] = persisted
453 .x25519_bytes
454 .try_into()
455 .map_err(|_| Self::err("invalid X25519 key length", "expected 32 bytes"))?;
456 let x25519_secret = X25519SecretKey::from(x25519_bytes);
457 let x25519_public = X25519PublicKey::from(&x25519_secret);
458
459 return Ok((ed25519_kp, x25519_secret, x25519_public));
460 }
461 }
462
463 tracing::info!("Loading old binary identity format");
465 let mut bytes = fs::read(path).map_err(|e| Self::err("read identity file", e))?;
466 let ed25519_kp = ed25519::Keypair::try_from_bytes(&mut bytes)
467 .map_err(|e| Self::err("decode Ed25519 key", e))?;
468
469 let x25519_secret = X25519SecretKey::random_from_rng(OsRng);
471 let x25519_public = X25519PublicKey::from(&x25519_secret);
472
473 Ok((ed25519_kp, x25519_secret, x25519_public))
474 }
475
476 fn read_legacy_file(storage_path: &Path, filename: &str) -> Option<String> {
478 let path = storage_path.join(filename);
479 if path.exists() {
480 fs::read_to_string(&path)
481 .ok()
482 .map(|s| s.trim().to_string())
483 .filter(|s| !s.is_empty())
484 } else {
485 None
486 }
487 }
488
489 fn generate_and_save_new_toml(
491 storage_path: &Path,
492 ) -> Result<(Keypair, X25519SecretKey, X25519PublicKey)> {
493 let toml_path = storage_path.join(AGENT_TOML_FILE);
494 tracing::info!(
495 "Generating new agent identity (saved to {})",
496 toml_path.display()
497 );
498
499 let mut bytes = [0u8; 32];
501 use rand::RngCore;
502 OsRng.fill_bytes(&mut bytes);
503 let ed25519_kp = ed25519::Keypair::from(
504 ed25519::SecretKey::try_from_bytes(&mut bytes)
505 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?,
506 );
507
508 let x25519_secret = X25519SecretKey::random_from_rng(OsRng);
510 let x25519_public = X25519PublicKey::from(&x25519_secret);
511
512 let agent_toml = AgentToml {
514 identity: IdentitySection {
515 ed25519_secret: BASE64.encode(ed25519_kp.to_bytes()),
516 x25519_secret: BASE64.encode(x25519_secret.to_bytes()),
517 },
518 registration: None,
519 };
520
521 fs::create_dir_all(storage_path).map_err(|e| Self::err("create storage directory", e))?;
523
524 let toml_content =
525 toml::to_string_pretty(&agent_toml).map_err(|e| Self::err("serialize agent.toml", e))?;
526 fs::write(&toml_path, &toml_content).map_err(|e| Self::err("write agent.toml", e))?;
527
528 Ok((ed25519_kp.into(), x25519_secret, x25519_public))
529 }
530
531 fn err(context: &str, error: impl std::fmt::Display) -> Error {
532 Error::Io(std::io::Error::new(
533 std::io::ErrorKind::Other,
534 format!("{}: {}", context, error),
535 ))
536 }
537}
538
539pub fn update_agent_toml_registration(
551 storage_path: &Path,
552 name: Option<&str>,
553 claim_token: Option<&str>,
554) -> Result<()> {
555 let toml_path = storage_path.join(AGENT_TOML_FILE);
556
557 if !toml_path.exists() {
558 return Err(Error::Config(format!(
559 "Cannot update registration: {} does not exist",
560 toml_path.display()
561 )));
562 }
563
564 let toml_content =
566 fs::read_to_string(&toml_path).map_err(|e| Error::Io(std::io::Error::new(
567 e.kind(),
568 format!("read agent.toml: {}", e),
569 )))?;
570 let mut agent_toml: AgentToml =
571 toml::from_str(&toml_content).map_err(|e| Error::Config(format!("parse agent.toml: {}", e)))?;
572
573 let mut registration = agent_toml.registration.unwrap_or_default();
575
576 if let Some(n) = name {
577 registration.name = Some(n.to_string());
578 }
579 if let Some(ct) = claim_token {
580 registration.claim_token = Some(ct.to_string());
581 }
582
583 agent_toml.registration = if registration.name.is_some() || registration.claim_token.is_some() {
585 Some(registration)
586 } else {
587 None
588 };
589
590 let toml_content = toml::to_string_pretty(&agent_toml)
592 .map_err(|e| Error::Config(format!("serialize agent.toml: {}", e)))?;
593 fs::write(&toml_path, &toml_content).map_err(|e| Error::Io(std::io::Error::new(
594 e.kind(),
595 format!("write agent.toml: {}", e),
596 )))?;
597
598 tracing::debug!("Updated registration in {}", toml_path.display());
599 Ok(())
600}
601
602pub fn read_claim_token(storage_path: &Path) -> Option<String> {
606 let toml_path = storage_path.join(AGENT_TOML_FILE);
607
608 if toml_path.exists() {
610 if let Ok(toml_content) = fs::read_to_string(&toml_path) {
611 if let Ok(agent_toml) = toml::from_str::<AgentToml>(&toml_content) {
612 if let Some(reg) = agent_toml.registration {
613 if reg.claim_token.is_some() {
614 return reg.claim_token;
615 }
616 }
617 }
618 }
619 }
620
621 AgentIdentity::read_legacy_file(storage_path, LEGACY_CLAIM_TOKEN_FILE)
623}