1use ed25519_dalek::{SECRET_KEY_LENGTH, SigningKey, VerifyingKey};
7use rand_core::OsRng;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12#[cfg(unix)]
13use std::os::unix::fs::PermissionsExt;
14
15#[derive(Debug, thiserror::Error)]
16pub enum IdentityError {
17 #[error("identity files not found")]
18 NotFound,
19 #[error("refusing to overwrite an existing identity key at {0}")]
25 Exists(String),
26 #[error("identity key exists but is not readable: {0}")]
31 Denied(String),
32 #[error("io error: {0}")]
33 Io(#[from] io::Error),
34 #[error("invalid key material: {0}")]
35 InvalidKey(String),
36 #[error("multibase decode error: {0}")]
37 Multibase(#[from] multibase::Error),
38}
39
40#[derive(Clone)]
41pub struct AgentIdentity {
42 signing: SigningKey,
43}
44
45impl std::fmt::Debug for AgentIdentity {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.debug_struct("AgentIdentity")
48 .field("verifying_key", &self.signing.verifying_key())
49 .finish()
50 }
51}
52
53pub fn private_key_dir(dir: &Path) -> PathBuf {
74 let is_agent_home = dir
75 .parent()
76 .and_then(|p| p.file_name())
77 .is_some_and(|n| n == "agents");
78 if !is_agent_home {
79 return dir.to_path_buf();
80 }
81 let (Some(name), Some(mur_home)) = (dir.file_name(), dir.parent().and_then(|p| p.parent()))
82 else {
83 return dir.to_path_buf();
84 };
85 mur_home.join("keys").join(name)
86}
87
88pub fn migrate_private_key(agent_dir: &Path) -> Result<bool, IdentityError> {
108 let key_dir = private_key_dir(agent_dir);
109 if key_dir == agent_dir {
110 return Ok(false); }
112 let legacy = agent_dir.join("identity.key");
113 let target = key_dir.join("identity.key");
114
115 let legacy_bytes = match fs::read(&legacy) {
116 Ok(b) => b,
117 Err(_) => return Ok(false),
122 };
123
124 if let Ok(existing) = fs::read(&target) {
125 if existing == legacy_bytes {
126 let _ = fs::remove_file(&legacy);
130 return Ok(false);
131 }
132 return Err(IdentityError::Exists(format!(
133 "{} already holds a DIFFERENT key than {}; refusing to migrate — resolve by hand, because picking one silently changes this agent's identity",
134 target.display(),
135 legacy.display()
136 )));
137 }
138
139 fs::create_dir_all(&key_dir)?;
140 fs::write(&target, &legacy_bytes)?;
145 #[cfg(unix)]
146 {
147 use std::os::unix::fs::PermissionsExt;
148 fs::set_permissions(&target, fs::Permissions::from_mode(0o600))?;
149 }
150 fs::remove_file(&legacy)?;
152
153 let legacy_prev = agent_dir.join("identity.key.prev");
156 if let Ok(prev) = fs::read(&legacy_prev) {
157 let target_prev = key_dir.join("identity.key.prev");
158 if fs::metadata(&target_prev).is_err() {
159 fs::write(&target_prev, &prev)?;
160 #[cfg(unix)]
161 {
162 use std::os::unix::fs::PermissionsExt;
163 fs::set_permissions(&target_prev, fs::Permissions::from_mode(0o600))?;
164 }
165 }
166 let _ = fs::remove_file(&legacy_prev);
167 }
168 Ok(true)
169}
170
171pub const SIGNING_HANDOFF_ENV: &str = "MUR_CHANNEL_SIGNING_STDIN";
175
176#[derive(serde::Serialize, serde::Deserialize)]
193pub struct SigningHandoff {
194 pub agent: String,
197 pub key_version: u32,
198 pub secret: [u8; SECRET_KEY_LENGTH],
201}
202
203impl AgentIdentity {
204 pub fn from_secret_bytes(secret: &[u8; SECRET_KEY_LENGTH]) -> Self {
209 Self {
210 signing: SigningKey::from_bytes(secret),
211 }
212 }
213
214 pub fn secret_bytes_for_handoff(&self) -> [u8; SECRET_KEY_LENGTH] {
220 self.signing.to_bytes()
221 }
222
223 pub fn generate() -> Self {
225 Self {
226 signing: SigningKey::generate(&mut OsRng),
227 }
228 }
229
230 pub fn save(&self, dir: &Path) -> Result<(), IdentityError> {
244 fs::create_dir_all(dir)?;
245 let key_dir = private_key_dir(dir);
250 fs::create_dir_all(&key_dir)?;
251 let priv_path = key_dir.join("identity.key");
252 let pub_path = dir.join("identity.pub");
253
254 if priv_path.exists() {
259 return Err(IdentityError::Exists(priv_path.display().to_string()));
260 }
261
262 fs::write(&priv_path, self.signing.to_bytes())?;
263 #[cfg(unix)]
264 {
265 let mut perms = fs::metadata(&priv_path)?.permissions();
266 perms.set_mode(0o600);
267 fs::set_permissions(&priv_path, perms)?;
268 }
269
270 let pub_text = encode_pubkey(&self.signing.verifying_key());
271 fs::write(&pub_path, pub_text)?;
272 Ok(())
273 }
274
275 pub fn load(dir: &Path) -> Result<Self, IdentityError> {
279 let priv_path = private_key_dir(dir).join("identity.key");
291 if let Err(e) = fs::metadata(&priv_path) {
296 return Err(match e.kind() {
297 io::ErrorKind::NotFound => IdentityError::NotFound,
298 _ => IdentityError::Denied(format!("{}: {e}", priv_path.display())),
299 });
300 }
301 let bytes = fs::read(&priv_path).map_err(|e| match e.kind() {
302 io::ErrorKind::NotFound => IdentityError::NotFound,
303 io::ErrorKind::PermissionDenied => {
304 IdentityError::Denied(format!("{}: {e}", priv_path.display()))
305 }
306 _ => IdentityError::Io(e),
307 })?;
308 if bytes.len() != SECRET_KEY_LENGTH {
309 return Err(IdentityError::InvalidKey(format!(
310 "expected {SECRET_KEY_LENGTH} bytes, got {}",
311 bytes.len()
312 )));
313 }
314 let arr: [u8; SECRET_KEY_LENGTH] = bytes.as_slice().try_into().unwrap();
315 let signing = SigningKey::from_bytes(&arr);
316
317 let pub_path = dir.join("identity.pub");
318 if pub_path.exists() {
319 let text = fs::read_to_string(&pub_path)?;
320 let loaded_pub = decode_pubkey(text.trim())?;
321 if loaded_pub != *signing.verifying_key().as_bytes() {
322 return Err(IdentityError::InvalidKey(
323 "identity.pub does not match identity.key".into(),
324 ));
325 }
326 }
327
328 Ok(Self { signing })
329 }
330
331 pub fn load_pubkey(dir: &Path) -> Result<[u8; 32], IdentityError> {
334 let path = dir.join("identity.pub");
335 if !path.exists() {
336 return Err(IdentityError::NotFound);
337 }
338 decode_pubkey(fs::read_to_string(&path)?.trim())
339 }
340
341 pub fn signing_key(&self) -> &SigningKey {
342 &self.signing
343 }
344
345 pub fn sign_bytes(&self, msg: &[u8]) -> [u8; 64] {
350 use ed25519_dalek::Signer;
351 self.signing.sign(msg).to_bytes()
352 }
353
354 pub fn sign_multibase(&self, msg: &[u8]) -> String {
357 multibase::encode(multibase::Base::Base58Btc, self.sign_bytes(msg))
358 }
359
360 pub fn verifying_key(&self) -> VerifyingKey {
361 self.signing.verifying_key()
362 }
363
364 pub fn verifying_key_bytes(&self) -> [u8; 32] {
365 *self.signing.verifying_key().as_bytes()
366 }
367
368 pub fn pubkey_text(&self) -> String {
369 encode_pubkey(&self.signing.verifying_key())
370 }
371
372 pub fn public_key_multibase(&self) -> String {
376 encode_pubkey(&self.signing.verifying_key())
377 }
378
379 pub fn to_x25519_static_secret(&self) -> x25519_dalek::StaticSecret {
385 let scalar_bytes = self.signing.to_scalar_bytes();
386 x25519_dalek::StaticSecret::from(scalar_bytes)
387 }
388}
389
390pub fn verify_bytes(pubkey: &[u8; 32], msg: &[u8], sig_multibase: &str) -> bool {
393 let Ok((_, sig_bytes)) = multibase::decode(sig_multibase) else {
394 return false;
395 };
396 let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.try_into() else {
397 return false;
398 };
399 let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pubkey) else {
400 return false;
401 };
402 vk.verify_strict(msg, &ed25519_dalek::Signature::from_bytes(&sig_arr))
403 .is_ok()
404}
405
406pub fn valid_ed25519_pubkey(bytes: &[u8; 32]) -> bool {
408 VerifyingKey::from_bytes(bytes).is_ok()
409}
410
411pub fn encode_pubkey(key: &VerifyingKey) -> String {
413 multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
414}
415
416pub fn decode_pubkey(text: &str) -> Result<[u8; 32], IdentityError> {
418 let (_base, bytes) = multibase::decode(text)?;
419 if bytes.len() != 32 {
420 return Err(IdentityError::InvalidKey(format!(
421 "pubkey must be 32 bytes, got {}",
422 bytes.len()
423 )));
424 }
425 let mut out = [0u8; 32];
426 out.copy_from_slice(&bytes);
427 Ok(out)
428}
429
430pub fn ed25519_pub_to_x25519(ed_pub: &[u8; 32]) -> Option<[u8; 32]> {
438 let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
439 let point = compressed.decompress()?;
440 Some(point.to_montgomery().to_bytes())
441}
442
443pub fn x25519_pub_from_multibase(text: &str) -> Result<[u8; 32], IdentityError> {
445 let ed = decode_pubkey(text)?;
446 ed25519_pub_to_x25519(&ed)
447 .ok_or_else(|| IdentityError::InvalidKey("pubkey is not a valid Edwards point".into()))
448}
449
450pub fn default_dir(agent_home: &Path) -> PathBuf {
452 agent_home.to_path_buf()
453}
454
455use serde::{Deserialize, Serialize};
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(rename_all = "snake_case")]
466pub enum RotationReason {
467 Scheduled,
468 SuspectCompromise,
469 OwnerChange,
470 Emergency,
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481pub struct RotationAttestation {
482 pub schema: u32,
484 pub uuid: String,
486 pub algorithm: String,
488 pub old_pubkey: String,
490 pub new_pubkey: String,
492 pub old_key_version: u32,
493 pub new_key_version: u32,
495 pub rotated_at: String,
497 pub reason: RotationReason,
498 #[serde(default, skip_serializing_if = "String::is_empty")]
501 pub signature: String,
502 #[serde(default, skip_serializing_if = "is_false")]
504 pub bootstrap: bool,
505}
506
507fn is_false(b: &bool) -> bool {
508 !*b
509}
510
511impl RotationAttestation {
512 pub fn new(
514 uuid: impl Into<String>,
515 old_pubkey: impl Into<String>,
516 new_pubkey: impl Into<String>,
517 old_key_version: u32,
518 new_key_version: u32,
519 rotated_at: impl Into<String>,
520 reason: RotationReason,
521 ) -> Self {
522 Self {
523 schema: 1,
524 uuid: uuid.into(),
525 algorithm: "ed25519".into(),
526 old_pubkey: old_pubkey.into(),
527 new_pubkey: new_pubkey.into(),
528 old_key_version,
529 new_key_version,
530 rotated_at: rotated_at.into(),
531 reason,
532 signature: String::new(),
533 bootstrap: false,
534 }
535 }
536
537 pub fn into_bootstrap(mut self) -> Self {
541 self.bootstrap = true;
542 self.old_pubkey = String::new();
543 self.signature = String::new();
544 self
545 }
546
547 pub fn canonical_bytes(&self) -> Vec<u8> {
551 let mut clone = self.clone();
552 clone.signature = String::new();
553 canonical_json(&clone)
554 }
555
556 pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
559 use ed25519_dalek::Signer;
560 let sig = signing.sign(&self.canonical_bytes());
561 self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
562 }
563
564 pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
573 if self.bootstrap {
574 return Ok(());
575 }
576 if self.signature.is_empty() {
577 return Err(IdentityError::InvalidKey(
578 "attestation signature is empty".into(),
579 ));
580 }
581 let pub_bytes = decode_pubkey(old_pubkey)?;
582 let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
583 .map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
584 let (_base, sig_bytes) = multibase::decode(&self.signature)?;
585 let sig_arr: [u8; 64] = sig_bytes
586 .as_slice()
587 .try_into()
588 .map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
589 let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
590 verifying
591 .verify_strict(&self.canonical_bytes(), &sig)
592 .map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
593 Ok(())
594 }
595
596 pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
599 if self.reason == RotationReason::Emergency && self.signature.is_empty() {
600 return Ok(());
601 }
602 self.verify(old_pubkey)
603 }
604}
605
606#[derive(Debug, Clone, Copy, Default)]
612pub struct ChainOptions {
613 pub allow_emergency: bool,
618}
619
620#[derive(Debug, Clone, PartialEq, Eq)]
622pub struct ChainOutcome {
623 pub head_key_version: u32,
625 pub head_pubkey: String,
627 pub length: usize,
629}
630
631#[derive(Debug)]
633pub enum ChainError {
634 MissingBootstrap,
636 VersionSkip { expected: u32, got: u32 },
638 PubkeyDiscontinuity { at_version: u32 },
640 DuplicateVersion(u32),
642 BadSignature { at_version: u32, detail: String },
644 EmergencyDisallowed { at_version: u32 },
646}
647
648impl std::fmt::Display for ChainError {
649 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
650 match self {
651 Self::MissingBootstrap => {
652 write!(
653 f,
654 "chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
655 )
656 }
657 Self::VersionSkip { expected, got } => {
658 write!(f, "version skip: expected {expected}, got {got}")
659 }
660 Self::PubkeyDiscontinuity { at_version } => {
661 write!(
662 f,
663 "pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
664 )
665 }
666 Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
667 Self::BadSignature { at_version, detail } => {
668 write!(f, "bad signature at key_version {at_version}: {detail}")
669 }
670 Self::EmergencyDisallowed { at_version } => {
671 write!(
672 f,
673 "emergency attestation at key_version {at_version} requires allow_emergency=true"
674 )
675 }
676 }
677 }
678}
679
680impl std::error::Error for ChainError {}
681
682pub fn verify_chain(
685 chain: &[RotationAttestation],
686 opts: ChainOptions,
687) -> std::result::Result<ChainOutcome, ChainError> {
688 if chain.is_empty() {
689 return Err(ChainError::MissingBootstrap);
690 }
691 let first = &chain[0];
692 if !first.bootstrap || first.new_key_version != 0 {
693 return Err(ChainError::MissingBootstrap);
694 }
695
696 let mut prev_pubkey = first.new_pubkey.clone();
697 let mut prev_version = 0u32;
698 let mut seen_versions = std::collections::HashSet::new();
699 seen_versions.insert(0u32);
700
701 for (i, a) in chain.iter().enumerate().skip(1) {
702 if !seen_versions.insert(a.new_key_version) {
704 return Err(ChainError::DuplicateVersion(a.new_key_version));
705 }
706 let expected = prev_version + 1;
708 if a.old_key_version != prev_version || a.new_key_version != expected {
709 return Err(ChainError::VersionSkip {
710 expected,
711 got: a.new_key_version,
712 });
713 }
714 if a.old_pubkey != prev_pubkey {
716 return Err(ChainError::PubkeyDiscontinuity {
717 at_version: a.new_key_version,
718 });
719 }
720 if a.reason == RotationReason::Emergency {
722 if !opts.allow_emergency {
723 return Err(ChainError::EmergencyDisallowed {
724 at_version: a.new_key_version,
725 });
726 }
727 if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
729 return Err(ChainError::BadSignature {
730 at_version: a.new_key_version,
731 detail: e.to_string(),
732 });
733 }
734 } else if let Err(e) = a.verify(&a.old_pubkey) {
735 return Err(ChainError::BadSignature {
736 at_version: a.new_key_version,
737 detail: e.to_string(),
738 });
739 }
740
741 prev_pubkey = a.new_pubkey.clone();
742 prev_version = a.new_key_version;
743 let _ = i; }
745
746 Ok(ChainOutcome {
747 head_key_version: prev_version,
748 head_pubkey: prev_pubkey,
749 length: chain.len(),
750 })
751}
752
753fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
757 let v: serde_json::Value =
760 serde_json::to_value(value).expect("serialize should not fail for our types");
761 let mut out = Vec::new();
762 write_canonical(&mut out, &v);
763 out
764}
765
766fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
767 use serde_json::Value;
768 match v {
769 Value::Null => out.extend_from_slice(b"null"),
770 Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
771 Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
772 Value::String(s) => {
773 let escaped = serde_json::to_string(s).unwrap();
775 out.extend_from_slice(escaped.as_bytes());
776 }
777 Value::Array(arr) => {
778 out.push(b'[');
779 for (i, item) in arr.iter().enumerate() {
780 if i > 0 {
781 out.push(b',');
782 }
783 write_canonical(out, item);
784 }
785 out.push(b']');
786 }
787 Value::Object(map) => {
788 let mut keys: Vec<&String> = map.keys().collect();
790 keys.sort();
791 out.push(b'{');
792 for (i, k) in keys.iter().enumerate() {
793 if i > 0 {
794 out.push(b',');
795 }
796 let kesc = serde_json::to_string(k).unwrap();
797 out.extend_from_slice(kesc.as_bytes());
798 out.push(b':');
799 write_canonical(out, &map[*k]);
800 }
801 out.push(b'}');
802 }
803 }
804}
805
806#[cfg(test)]
807mod identity_readability_tests {
808 use super::*;
809
810 #[test]
817 fn a_signing_handoff_round_trips_to_the_same_key_on_one_line() {
818 let id = AgentIdentity::generate();
819 let line = serde_json::to_string(&SigningHandoff {
820 agent: "mur".into(),
821 key_version: 3,
822 secret: id.secret_bytes_for_handoff(),
823 })
824 .expect("serialize handoff");
825 assert!(!line.contains('\n'), "must survive read_line: {line}");
826
827 let back: SigningHandoff = serde_json::from_str(&line).expect("parse handoff");
828 assert_eq!(back.agent, "mur");
829 assert_eq!(back.key_version, 3);
830 assert_eq!(
831 AgentIdentity::from_secret_bytes(&back.secret).verifying_key_bytes(),
832 id.verifying_key_bytes(),
833 "the child must sign as the same agent, not a lookalike"
834 );
835 }
836
837 #[cfg(unix)]
844 #[test]
845 fn an_unreadable_key_is_denied_not_notfound() {
846 use std::os::unix::fs::PermissionsExt;
847 let dir = tempfile::tempdir().unwrap();
848 AgentIdentity::generate().save(dir.path()).unwrap();
849 let key = dir.path().join("identity.key");
850
851 assert!(AgentIdentity::load(dir.path()).is_ok());
854
855 std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o000)).unwrap();
858 let unreadable = AgentIdentity::load(dir.path()).unwrap_err();
859 std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap();
860 assert!(
861 matches!(unreadable, IdentityError::Denied(_)),
862 "an unreadable key must be Denied, got {unreadable:?}"
863 );
864
865 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
871 let unstattable = AgentIdentity::load(dir.path()).unwrap_err();
872 let exists_lies = !key.exists();
873 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
874 assert!(
875 exists_lies,
876 "precondition: Path::exists() must be answering false here, or this \
877 case is not reproducing a sandbox deny"
878 );
879 assert!(
880 matches!(unstattable, IdentityError::Denied(_)),
881 "a key that cannot be STATted must be Denied, not NotFound, got {unstattable:?}"
882 );
883 }
884
885 #[test]
887 fn an_agent_key_moves_and_the_public_half_stays() {
888 let tmp = tempfile::tempdir().unwrap();
889 let mur = tmp.path();
890 let agent = mur.join("agents").join("pm");
891 AgentIdentity::generate().save(&agent).unwrap();
892
893 assert!(
894 mur.join("keys").join("pm").join("identity.key").exists(),
895 "private key must live under keys/"
896 );
897 assert!(
898 !agent.join("identity.key").exists(),
899 "the agents tree must hold no private key"
900 );
901 assert!(
902 agent.join("identity.pub").exists(),
903 "the public half must stay where peers read it"
904 );
905 }
906
907 #[test]
910 fn non_agent_identities_are_not_remapped() {
911 let tmp = tempfile::tempdir().unwrap();
912 let mur = tmp.path();
913 for dir in [
914 mur.to_path_buf(), mur.join("commander"), mur.join("publisher"), ] {
918 assert_eq!(
919 private_key_dir(&dir),
920 dir,
921 "{} must not be remapped",
922 dir.display()
923 );
924 AgentIdentity::generate().save(&dir).unwrap();
925 assert!(
926 dir.join("identity.key").exists(),
927 "{} lost its key to the remap",
928 dir.display()
929 );
930 }
931 }
932
933 #[test]
941 fn a_legacy_key_loads_only_after_migration() {
942 let tmp = tempfile::tempdir().unwrap();
943 let agent = tmp.path().join("agents").join("legacy");
944 std::fs::create_dir_all(&agent).unwrap();
945 let id = AgentIdentity::generate();
946 std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
947
948 assert!(
950 matches!(
951 AgentIdentity::load(&agent).unwrap_err(),
952 IdentityError::NotFound
953 ),
954 "a key in the legacy location must not be silently honoured"
955 );
956
957 assert!(migrate_private_key(&agent).unwrap());
959 assert_eq!(
960 AgentIdentity::load(&agent).unwrap().pubkey_text(),
961 id.pubkey_text()
962 );
963 }
964
965 #[test]
968 fn the_new_location_wins_over_a_leftover_legacy_key() {
969 let tmp = tempfile::tempdir().unwrap();
970 let mur = tmp.path();
971 let agent = mur.join("agents").join("dual");
972 let current = AgentIdentity::generate();
973 current.save(&agent).unwrap();
974 let stale = AgentIdentity::generate();
975 std::fs::create_dir_all(&agent).unwrap();
976 std::fs::write(agent.join("identity.key"), stale.signing.to_bytes()).unwrap();
977
978 let loaded = AgentIdentity::load(&agent).unwrap();
979 assert_eq!(
980 loaded.pubkey_text(),
981 current.pubkey_text(),
982 "the migrated key must win over the leftover"
983 );
984 }
985
986 #[test]
988 fn migration_moves_the_key_out_of_the_agents_tree() {
989 let tmp = tempfile::tempdir().unwrap();
990 let mur = tmp.path();
991 let agent = mur.join("agents").join("pm");
992 std::fs::create_dir_all(&agent).unwrap();
993 let id = AgentIdentity::generate();
994 std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
995
996 assert!(migrate_private_key(&agent).unwrap());
997
998 assert!(!agent.join("identity.key").exists(), "key left in agents/");
999 assert!(mur.join("keys/pm/identity.key").exists());
1000 assert_eq!(
1001 AgentIdentity::load(&agent).unwrap().pubkey_text(),
1002 id.pubkey_text(),
1003 "the same identity must load after the move"
1004 );
1005 }
1006
1007 #[test]
1009 fn migration_is_idempotent() {
1010 let tmp = tempfile::tempdir().unwrap();
1011 let agent = tmp.path().join("agents").join("pm");
1012 AgentIdentity::generate().save(&agent).unwrap(); assert!(!migrate_private_key(&agent).unwrap());
1014 assert!(!migrate_private_key(&agent).unwrap());
1015 }
1016
1017 #[test]
1022 fn migration_refuses_when_the_destination_differs() {
1023 let tmp = tempfile::tempdir().unwrap();
1024 let mur = tmp.path();
1025 let agent = mur.join("agents").join("pm");
1026 let migrated = AgentIdentity::generate();
1027 migrated.save(&agent).unwrap();
1028 let stray = AgentIdentity::generate();
1029 std::fs::write(agent.join("identity.key"), stray.signing.to_bytes()).unwrap();
1030
1031 let err = migrate_private_key(&agent).unwrap_err();
1032
1033 assert!(matches!(err, IdentityError::Exists(_)), "got {err:?}");
1034 assert_eq!(
1035 std::fs::read(mur.join("keys/pm/identity.key")).unwrap(),
1036 migrated.signing.to_bytes().to_vec(),
1037 "the destination key was modified despite the refusal"
1038 );
1039 assert!(
1040 agent.join("identity.key").exists(),
1041 "the source was removed despite the refusal"
1042 );
1043 }
1044
1045 #[test]
1048 fn migration_clears_an_identical_leftover() {
1049 let tmp = tempfile::tempdir().unwrap();
1050 let agent = tmp.path().join("agents").join("pm");
1051 let id = AgentIdentity::generate();
1052 id.save(&agent).unwrap();
1053 std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
1054
1055 assert!(!migrate_private_key(&agent).unwrap());
1056 assert!(!agent.join("identity.key").exists());
1057 }
1058
1059 #[test]
1061 fn migration_skips_non_agent_identities() {
1062 let tmp = tempfile::tempdir().unwrap();
1063 let mur = tmp.path();
1064 for dir in [
1065 mur.to_path_buf(),
1066 mur.join("commander"),
1067 mur.join("publisher"),
1068 ] {
1069 AgentIdentity::generate().save(&dir).unwrap();
1070 assert!(!migrate_private_key(&dir).unwrap());
1071 assert!(dir.join("identity.key").exists(), "{}", dir.display());
1072 }
1073 }
1074
1075 #[test]
1083 fn save_refuses_to_overwrite_an_existing_key() {
1084 let dir = tempfile::tempdir().unwrap();
1085 let first = AgentIdentity::generate();
1086 first.save(dir.path()).unwrap();
1087 let original = std::fs::read(dir.path().join("identity.key")).unwrap();
1088
1089 let err = AgentIdentity::generate().save(dir.path()).unwrap_err();
1090
1091 assert!(
1092 matches!(err, IdentityError::Exists(_)),
1093 "expected Exists, got {err:?}"
1094 );
1095 assert_eq!(
1096 std::fs::read(dir.path().join("identity.key")).unwrap(),
1097 original,
1098 "the existing key was modified despite the refusal"
1099 );
1100 }
1101
1102 #[test]
1106 fn save_into_an_empty_directory_succeeds() {
1107 let dir = tempfile::tempdir().unwrap();
1108 let id = AgentIdentity::generate();
1109 id.save(dir.path()).unwrap();
1110 assert_eq!(
1111 AgentIdentity::load(dir.path()).unwrap().pubkey_text(),
1112 id.pubkey_text()
1113 );
1114 }
1115
1116 #[test]
1119 fn a_missing_key_is_still_notfound() {
1120 let dir = tempfile::tempdir().unwrap();
1121 assert!(matches!(
1122 AgentIdentity::load(dir.path()).unwrap_err(),
1123 IdentityError::NotFound
1124 ));
1125 }
1126}
1127
1128#[cfg(test)]
1129mod identity_x25519_tests {
1130 use super::*;
1131
1132 #[test]
1133 fn x25519_pub_matches_secret_derivation() {
1134 let id = AgentIdentity::generate();
1138 let from_secret = x25519_dalek::PublicKey::from(&id.to_x25519_static_secret());
1139 let from_pub = x25519_pub_from_multibase(&id.public_key_multibase()).unwrap();
1140 assert_eq!(from_secret.as_bytes(), &from_pub);
1141 }
1142}