1use std::path::{Path, PathBuf};
20
21use anyhow::{Context, Result, bail};
22use clap::{Args, Subcommand};
23use ed25519_dalek::SigningKey;
24
25use crate::cli::CliOutput;
26use crate::identity::{self, keypair};
27use crate::validate;
28
29const PUBLIC_KEY_B64_FIELD: &str = "public_key_b64";
31
32#[derive(Args)]
33pub struct IdentityArgs {
34 #[arg(long, value_name = "PATH", global = true)]
41 pub key_dir: Option<PathBuf>,
42 #[command(subcommand)]
43 pub action: IdentityAction,
44}
45
46#[derive(Subcommand)]
47pub enum IdentityAction {
48 Generate {
52 #[arg(long)]
55 agent_id: Option<String>,
56 #[arg(long, default_value_t = false)]
62 force: bool,
63 #[arg(long, default_value_t = false, hide = true)]
68 no_overwrite: bool,
69 },
70 Import {
75 #[arg(long)]
77 agent_id: String,
78 #[arg(long = "pub", value_name = "PATH")]
80 public: PathBuf,
81 #[arg(long = "priv", value_name = "PATH")]
83 private: Option<PathBuf>,
84 },
85 List,
89 ExportPub {
94 #[arg(long)]
96 agent_id: String,
97 },
98}
99
100fn resolve_key_dir(override_dir: Option<&Path>) -> Result<PathBuf> {
103 if let Some(p) = override_dir {
104 return Ok(p.to_path_buf());
105 }
106 keypair::default_key_dir()
107}
108
109fn resolve_id(explicit: Option<&str>) -> Result<String> {
114 identity::resolve_agent_id(explicit, None)
115}
116
117pub fn run(args: IdentityArgs, json_out: bool, out: &mut CliOutput<'_>) -> Result<()> {
123 let dir = resolve_key_dir(args.key_dir.as_deref())?;
124 match args.action {
125 IdentityAction::Generate {
126 agent_id,
127 force,
128 no_overwrite: _,
129 } => generate(&dir, agent_id.as_deref(), force, json_out, out),
130 IdentityAction::Import {
131 agent_id,
132 public,
133 private,
134 } => import(&dir, &agent_id, &public, private.as_deref(), json_out, out),
135 IdentityAction::List => list(&dir, json_out, out),
136 IdentityAction::ExportPub { agent_id } => export_pub(&dir, &agent_id, json_out, out),
137 }
138}
139
140fn generate(
141 dir: &Path,
142 explicit_agent_id: Option<&str>,
143 force: bool,
144 json_out: bool,
145 out: &mut CliOutput<'_>,
146) -> Result<()> {
147 let id = match explicit_agent_id {
161 Some(explicit) if !explicit.is_empty() => {
162 validate::validate_agent_id_shape(explicit)?;
163 explicit.to_string()
164 }
165 _ => resolve_id(explicit_agent_id)?,
166 };
167 let pub_path = dir.join(format!("{id}.pub"));
168 if !force && pub_path.exists() {
175 bail!(
176 "keypair for {id} already exists at {} (pass --force to rotate; refused by default to prevent accidental key overwrite)",
177 pub_path.display()
178 );
179 }
180 let kp = keypair::generate(&id)?;
181 keypair::save(&kp, dir)?;
182 if json_out {
183 writeln!(
184 out.stdout,
185 "{}",
186 serde_json::json!({
187 "generated": true,
188 "agent_id": id,
189 "key_dir": dir,
190 (PUBLIC_KEY_B64_FIELD): kp.public_base64(),
191 })
192 )?;
193 } else {
194 writeln!(out.stdout, "generated keypair for {id}")?;
195 writeln!(out.stdout, " key_dir = {}", dir.display())?;
196 writeln!(out.stdout, " pub_b64 = {}", kp.public_base64())?;
197 }
198 Ok(())
199}
200
201fn import(
202 dir: &Path,
203 agent_id: &str,
204 pub_path: &Path,
205 priv_path: Option<&Path>,
206 json_out: bool,
207 out: &mut CliOutput<'_>,
208) -> Result<()> {
209 crate::validate::validate_agent_id(agent_id)?;
210 let pub_bytes = keypair::read_raw_key_file(pub_path)
211 .with_context(|| format!("reading --pub {}", pub_path.display()))?;
212 let public = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
213 .with_context(|| "decoding imported public key".to_string())?;
214
215 let private = if let Some(p) = priv_path {
216 let priv_bytes = keypair::read_raw_key_file(p)
217 .with_context(|| format!("reading --priv {}", p.display()))?;
218 let signing = SigningKey::from_bytes(&priv_bytes);
219 if signing.verifying_key().to_bytes() != public.to_bytes() {
221 bail!(
222 "imported --priv {} does not match --pub {}",
223 p.display(),
224 pub_path.display()
225 );
226 }
227 Some(signing)
228 } else {
229 None
230 };
231
232 let kp = keypair::AgentKeypair {
233 agent_id: agent_id.to_string(),
234 public,
235 private,
236 };
237 if kp.private.is_some() {
238 keypair::save(&kp, dir)?;
239 } else {
240 keypair::save_public_only(&kp, dir)?;
241 }
242
243 if json_out {
244 writeln!(
245 out.stdout,
246 "{}",
247 serde_json::json!({
248 "imported": true,
249 "agent_id": agent_id,
250 "key_dir": dir,
251 "private_imported": kp.private.is_some(),
252 (PUBLIC_KEY_B64_FIELD): kp.public_base64(),
253 })
254 )?;
255 } else {
256 writeln!(
257 out.stdout,
258 "imported keypair for {agent_id} (private={})",
259 if kp.private.is_some() { "yes" } else { "no" }
260 )?;
261 writeln!(out.stdout, " key_dir = {}", dir.display())?;
262 writeln!(out.stdout, " pub_b64 = {}", kp.public_base64())?;
263 }
264 Ok(())
265}
266
267fn list(dir: &Path, json_out: bool, out: &mut CliOutput<'_>) -> Result<()> {
268 let keys = keypair::list(dir)?;
269 if json_out {
270 let entries: Vec<_> = keys
271 .iter()
272 .map(|k| {
273 serde_json::json!({
274 "agent_id": k.agent_id,
275 (PUBLIC_KEY_B64_FIELD): k.public_base64(),
276 })
277 })
278 .collect();
279 writeln!(
280 out.stdout,
281 "{}",
282 serde_json::json!({
283 "count": entries.len(),
284 "key_dir": dir,
285 "keys": entries,
286 })
287 )?;
288 } else if keys.is_empty() {
289 writeln!(out.stdout, "no keypairs in {}", dir.display())?;
290 } else {
291 for k in &keys {
292 writeln!(out.stdout, "{} {}", k.agent_id, k.public_base64())?;
293 }
294 writeln!(out.stdout, "{} keypair(s) in {}", keys.len(), dir.display())?;
295 }
296 Ok(())
297}
298
299fn export_pub(dir: &Path, agent_id: &str, json_out: bool, out: &mut CliOutput<'_>) -> Result<()> {
300 let kp = keypair::load(agent_id, dir)?;
301 if json_out {
302 writeln!(
303 out.stdout,
304 "{}",
305 serde_json::json!({
306 "agent_id": agent_id,
307 (PUBLIC_KEY_B64_FIELD): kp.public_base64(),
308 })
309 )?;
310 } else {
311 writeln!(out.stdout, "{}", kp.public_base64())?;
314 }
315 Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::cli::test_utils::TestEnv;
322
323 fn fresh_env() -> (TestEnv, tempfile::TempDir) {
324 let env = TestEnv::fresh();
325 let dir = tempfile::TempDir::new().unwrap();
326 (env, dir)
327 }
328
329 #[test]
330 fn generate_then_list_then_export() {
331 let (mut env, dir) = fresh_env();
332 let dir_path = dir.path().to_path_buf();
333
334 {
336 let mut out = env.output();
337 run(
338 IdentityArgs {
339 key_dir: Some(dir_path.clone()),
340 action: IdentityAction::Generate {
341 agent_id: Some("alice".to_string()),
342 force: false,
343 no_overwrite: false,
344 },
345 },
346 false,
347 &mut out,
348 )
349 .unwrap();
350 }
351 let stdout = env.stdout_str().to_string();
352 assert!(
353 stdout.contains("generated keypair for alice"),
354 "got: {stdout}"
355 );
356
357 env.stdout.clear();
359 env.stderr.clear();
360 {
361 let mut out = env.output();
362 run(
363 IdentityArgs {
364 key_dir: Some(dir_path.clone()),
365 action: IdentityAction::List,
366 },
367 false,
368 &mut out,
369 )
370 .unwrap();
371 }
372 let stdout = env.stdout_str().to_string();
373 assert!(stdout.contains("alice"), "got: {stdout}");
374 assert!(stdout.contains("1 keypair(s)"), "got: {stdout}");
375
376 env.stdout.clear();
378 env.stderr.clear();
379 {
380 let mut out = env.output();
381 run(
382 IdentityArgs {
383 key_dir: Some(dir_path),
384 action: IdentityAction::ExportPub {
385 agent_id: "alice".to_string(),
386 },
387 },
388 false,
389 &mut out,
390 )
391 .unwrap();
392 }
393 let stdout = env.stdout_str().trim().to_string();
394 let decoded = keypair::decode_public_base64(&stdout).expect("decode");
396 assert_eq!(decoded.to_bytes().len(), 32);
397 }
398
399 #[test]
400 fn generate_refuses_existing_without_force() {
401 let (mut env, dir) = fresh_env();
407 let dir_path = dir.path().to_path_buf();
408 {
410 let mut out = env.output();
411 run(
412 IdentityArgs {
413 key_dir: Some(dir_path.clone()),
414 action: IdentityAction::Generate {
415 agent_id: Some("alice".to_string()),
416 force: false,
417 no_overwrite: false,
418 },
419 },
420 false,
421 &mut out,
422 )
423 .unwrap();
424 }
425 env.stdout.clear();
426 env.stderr.clear();
427 let result = {
429 let mut out = env.output();
430 run(
431 IdentityArgs {
432 key_dir: Some(dir_path.clone()),
433 action: IdentityAction::Generate {
434 agent_id: Some("alice".to_string()),
435 force: false,
436 no_overwrite: false,
437 },
438 },
439 false,
440 &mut out,
441 )
442 };
443 let err = result.unwrap_err();
444 let msg = format!("{err:#}");
445 assert!(msg.contains("already exists"), "got: {msg}");
446 assert!(
447 msg.contains("--force"),
448 "error message should guide operator toward --force, got: {msg}"
449 );
450
451 env.stdout.clear();
453 env.stderr.clear();
454 {
455 let mut out = env.output();
456 run(
457 IdentityArgs {
458 key_dir: Some(dir_path),
459 action: IdentityAction::Generate {
460 agent_id: Some("alice".to_string()),
461 force: true,
462 no_overwrite: false,
463 },
464 },
465 false,
466 &mut out,
467 )
468 .unwrap();
469 }
470 let stdout = env.stdout_str().to_string();
471 assert!(
472 stdout.contains("generated keypair for alice"),
473 "rotation with --force did not succeed: {stdout}"
474 );
475 }
476
477 #[test]
478 fn list_json_emits_keys_array() {
479 let (mut env, dir) = fresh_env();
480 let dir_path = dir.path().to_path_buf();
481 {
482 let mut out = env.output();
483 run(
484 IdentityArgs {
485 key_dir: Some(dir_path.clone()),
486 action: IdentityAction::Generate {
487 agent_id: Some("alice".to_string()),
488 force: false,
489 no_overwrite: false,
490 },
491 },
492 true,
493 &mut out,
494 )
495 .unwrap();
496 }
497 env.stdout.clear();
498 env.stderr.clear();
499 {
500 let mut out = env.output();
501 run(
502 IdentityArgs {
503 key_dir: Some(dir_path),
504 action: IdentityAction::List,
505 },
506 true,
507 &mut out,
508 )
509 .unwrap();
510 }
511 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
512 assert_eq!(v["count"].as_u64().unwrap(), 1);
513 assert_eq!(v["keys"][0]["agent_id"].as_str().unwrap(), "alice");
514 assert!(v["keys"][0]["public_key_b64"].as_str().unwrap().len() > 10);
515 }
516
517 #[test]
518 fn import_round_trip_through_raw_files() {
519 let (mut env, dir) = fresh_env();
520 let dir_path = dir.path().to_path_buf();
521
522 let kp = keypair::generate("alice").unwrap();
524 let pub_bytes = kp.public.to_bytes();
525 let priv_bytes = kp.private.as_ref().unwrap().to_bytes();
526 let staging = tempfile::TempDir::new().unwrap();
527 let pub_file = staging.path().join("a.pub");
528 let priv_file = staging.path().join("a.priv");
529 std::fs::write(&pub_file, pub_bytes).unwrap();
530 std::fs::write(&priv_file, priv_bytes).unwrap();
531
532 {
533 let mut out = env.output();
534 run(
535 IdentityArgs {
536 key_dir: Some(dir_path.clone()),
537 action: IdentityAction::Import {
538 agent_id: "alice".to_string(),
539 public: pub_file.clone(),
540 private: Some(priv_file.clone()),
541 },
542 },
543 false,
544 &mut out,
545 )
546 .unwrap();
547 }
548 let stdout = env.stdout_str().to_string();
549 assert!(
550 stdout.contains("imported keypair for alice"),
551 "got: {stdout}"
552 );
553 let loaded = keypair::load("alice", &dir_path).unwrap();
555 assert_eq!(loaded.public.to_bytes(), pub_bytes);
556 assert!(loaded.can_sign());
557 }
558
559 #[test]
560 fn import_refuses_priv_pub_mismatch() {
561 let (mut env, dir) = fresh_env();
562 let dir_path = dir.path().to_path_buf();
563 let alice = keypair::generate("alice").unwrap();
564 let bob = keypair::generate("bob").unwrap();
565 let staging = tempfile::TempDir::new().unwrap();
566 let pub_file = staging.path().join("alice.pub");
567 let priv_file = staging.path().join("bob.priv");
568 std::fs::write(&pub_file, alice.public.to_bytes()).unwrap();
569 std::fs::write(&priv_file, bob.private.as_ref().unwrap().to_bytes()).unwrap();
570
571 let result = {
572 let mut out = env.output();
573 run(
574 IdentityArgs {
575 key_dir: Some(dir_path),
576 action: IdentityAction::Import {
577 agent_id: "alice".to_string(),
578 public: pub_file,
579 private: Some(priv_file),
580 },
581 },
582 false,
583 &mut out,
584 )
585 };
586 let err = result.unwrap_err();
587 let msg = format!("{err:#}");
588 assert!(msg.contains("does not match"), "got: {msg}");
589 }
590
591 #[test]
596 fn generate_json_mode_emits_payload() {
597 let (mut env, dir) = fresh_env();
600 let dir_path = dir.path().to_path_buf();
601 {
602 let mut out = env.output();
603 run(
604 IdentityArgs {
605 key_dir: Some(dir_path),
606 action: IdentityAction::Generate {
607 agent_id: Some("carol".to_string()),
608 force: false,
609 no_overwrite: false,
610 },
611 },
612 true,
613 &mut out,
614 )
615 .unwrap();
616 }
617 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
618 assert_eq!(v["generated"], true);
619 assert_eq!(v["agent_id"].as_str().unwrap(), "carol");
620 assert!(v["public_key_b64"].as_str().unwrap().len() > 10);
621 assert!(v["key_dir"].is_string());
622 }
623
624 #[test]
625 fn import_public_only_text_mode() {
626 let (mut env, dir) = fresh_env();
630 let dir_path = dir.path().to_path_buf();
631 let kp = keypair::generate("dave").unwrap();
632 let staging = tempfile::TempDir::new().unwrap();
633 let pub_file = staging.path().join("d.pub");
634 std::fs::write(&pub_file, kp.public.to_bytes()).unwrap();
635 {
636 let mut out = env.output();
637 run(
638 IdentityArgs {
639 key_dir: Some(dir_path.clone()),
640 action: IdentityAction::Import {
641 agent_id: "dave".to_string(),
642 public: pub_file,
643 private: None,
644 },
645 },
646 false,
647 &mut out,
648 )
649 .unwrap();
650 }
651 let stdout = env.stdout_str().to_string();
652 assert!(
653 stdout.contains("imported keypair for dave"),
654 "got: {stdout}"
655 );
656 assert!(stdout.contains("(private=no)"), "got: {stdout}");
657 let loaded = keypair::load("dave", &dir_path).unwrap();
659 assert!(!loaded.can_sign());
660 }
661
662 #[test]
663 fn import_public_only_json_mode() {
664 let (mut env, dir) = fresh_env();
666 let dir_path = dir.path().to_path_buf();
667 let kp = keypair::generate("eve").unwrap();
668 let staging = tempfile::TempDir::new().unwrap();
669 let pub_file = staging.path().join("e.pub");
670 std::fs::write(&pub_file, kp.public.to_bytes()).unwrap();
671 {
672 let mut out = env.output();
673 run(
674 IdentityArgs {
675 key_dir: Some(dir_path),
676 action: IdentityAction::Import {
677 agent_id: "eve".to_string(),
678 public: pub_file,
679 private: None,
680 },
681 },
682 true,
683 &mut out,
684 )
685 .unwrap();
686 }
687 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
688 assert_eq!(v["imported"], true);
689 assert_eq!(v["agent_id"].as_str().unwrap(), "eve");
690 assert_eq!(v["private_imported"], false);
691 assert!(v["public_key_b64"].as_str().unwrap().len() > 10);
692 }
693
694 #[test]
695 fn import_with_priv_json_mode_reports_private_imported_true() {
696 let (mut env, dir) = fresh_env();
699 let dir_path = dir.path().to_path_buf();
700 let kp = keypair::generate("frank").unwrap();
701 let staging = tempfile::TempDir::new().unwrap();
702 let pub_file = staging.path().join("f.pub");
703 let priv_file = staging.path().join("f.priv");
704 std::fs::write(&pub_file, kp.public.to_bytes()).unwrap();
705 std::fs::write(&priv_file, kp.private.as_ref().unwrap().to_bytes()).unwrap();
706 {
707 let mut out = env.output();
708 run(
709 IdentityArgs {
710 key_dir: Some(dir_path),
711 action: IdentityAction::Import {
712 agent_id: "frank".to_string(),
713 public: pub_file,
714 private: Some(priv_file),
715 },
716 },
717 true,
718 &mut out,
719 )
720 .unwrap();
721 }
722 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
723 assert_eq!(v["private_imported"], true);
724 }
725
726 #[test]
727 fn list_empty_text_mode_emits_no_keypairs() {
728 let (mut env, dir) = fresh_env();
730 let dir_path = dir.path().to_path_buf();
731 {
732 let mut out = env.output();
733 run(
734 IdentityArgs {
735 key_dir: Some(dir_path),
736 action: IdentityAction::List,
737 },
738 false,
739 &mut out,
740 )
741 .unwrap();
742 }
743 assert!(env.stdout_str().contains("no keypairs in"));
744 }
745
746 #[test]
747 fn list_empty_json_mode_emits_count_zero() {
748 let (mut env, dir) = fresh_env();
751 let dir_path = dir.path().to_path_buf();
752 {
753 let mut out = env.output();
754 run(
755 IdentityArgs {
756 key_dir: Some(dir_path),
757 action: IdentityAction::List,
758 },
759 true,
760 &mut out,
761 )
762 .unwrap();
763 }
764 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
765 assert_eq!(v["count"].as_u64().unwrap(), 0);
766 assert!(v["keys"].as_array().unwrap().is_empty());
767 }
768
769 #[test]
770 fn export_pub_json_mode_emits_payload() {
771 let (mut env, dir) = fresh_env();
773 let dir_path = dir.path().to_path_buf();
774 {
776 let mut out = env.output();
777 run(
778 IdentityArgs {
779 key_dir: Some(dir_path.clone()),
780 action: IdentityAction::Generate {
781 agent_id: Some("grace".to_string()),
782 force: false,
783 no_overwrite: false,
784 },
785 },
786 false,
787 &mut out,
788 )
789 .unwrap();
790 }
791 env.stdout.clear();
792 env.stderr.clear();
793 {
794 let mut out = env.output();
795 run(
796 IdentityArgs {
797 key_dir: Some(dir_path),
798 action: IdentityAction::ExportPub {
799 agent_id: "grace".to_string(),
800 },
801 },
802 true,
803 &mut out,
804 )
805 .unwrap();
806 }
807 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
808 assert_eq!(v["agent_id"].as_str().unwrap(), "grace");
809 assert!(v["public_key_b64"].as_str().unwrap().len() > 10);
810 }
811
812 #[test]
813 fn resolve_key_dir_falls_through_to_default() {
814 let r = resolve_key_dir(None);
818 match r {
822 Ok(p) => assert!(p.as_os_str().len() > 0),
823 Err(_) => {}
824 }
825 }
826}