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
171impl AgentIdentity {
172 pub fn generate() -> Self {
174 Self {
175 signing: SigningKey::generate(&mut OsRng),
176 }
177 }
178
179 pub fn save(&self, dir: &Path) -> Result<(), IdentityError> {
193 fs::create_dir_all(dir)?;
194 let key_dir = private_key_dir(dir);
199 fs::create_dir_all(&key_dir)?;
200 let priv_path = key_dir.join("identity.key");
201 let pub_path = dir.join("identity.pub");
202
203 if priv_path.exists() {
208 return Err(IdentityError::Exists(priv_path.display().to_string()));
209 }
210
211 fs::write(&priv_path, self.signing.to_bytes())?;
212 #[cfg(unix)]
213 {
214 let mut perms = fs::metadata(&priv_path)?.permissions();
215 perms.set_mode(0o600);
216 fs::set_permissions(&priv_path, perms)?;
217 }
218
219 let pub_text = encode_pubkey(&self.signing.verifying_key());
220 fs::write(&pub_path, pub_text)?;
221 Ok(())
222 }
223
224 pub fn load(dir: &Path) -> Result<Self, IdentityError> {
228 let priv_path = private_key_dir(dir).join("identity.key");
240 if let Err(e) = fs::metadata(&priv_path) {
245 return Err(match e.kind() {
246 io::ErrorKind::NotFound => IdentityError::NotFound,
247 _ => IdentityError::Denied(format!("{}: {e}", priv_path.display())),
248 });
249 }
250 let bytes = fs::read(&priv_path).map_err(|e| match e.kind() {
251 io::ErrorKind::NotFound => IdentityError::NotFound,
252 io::ErrorKind::PermissionDenied => {
253 IdentityError::Denied(format!("{}: {e}", priv_path.display()))
254 }
255 _ => IdentityError::Io(e),
256 })?;
257 if bytes.len() != SECRET_KEY_LENGTH {
258 return Err(IdentityError::InvalidKey(format!(
259 "expected {SECRET_KEY_LENGTH} bytes, got {}",
260 bytes.len()
261 )));
262 }
263 let arr: [u8; SECRET_KEY_LENGTH] = bytes.as_slice().try_into().unwrap();
264 let signing = SigningKey::from_bytes(&arr);
265
266 let pub_path = dir.join("identity.pub");
267 if pub_path.exists() {
268 let text = fs::read_to_string(&pub_path)?;
269 let loaded_pub = decode_pubkey(text.trim())?;
270 if loaded_pub != *signing.verifying_key().as_bytes() {
271 return Err(IdentityError::InvalidKey(
272 "identity.pub does not match identity.key".into(),
273 ));
274 }
275 }
276
277 Ok(Self { signing })
278 }
279
280 pub fn load_pubkey(dir: &Path) -> Result<[u8; 32], IdentityError> {
283 let path = dir.join("identity.pub");
284 if !path.exists() {
285 return Err(IdentityError::NotFound);
286 }
287 decode_pubkey(fs::read_to_string(&path)?.trim())
288 }
289
290 pub fn signing_key(&self) -> &SigningKey {
291 &self.signing
292 }
293
294 pub fn sign_bytes(&self, msg: &[u8]) -> [u8; 64] {
299 use ed25519_dalek::Signer;
300 self.signing.sign(msg).to_bytes()
301 }
302
303 pub fn sign_multibase(&self, msg: &[u8]) -> String {
306 multibase::encode(multibase::Base::Base58Btc, self.sign_bytes(msg))
307 }
308
309 pub fn verifying_key(&self) -> VerifyingKey {
310 self.signing.verifying_key()
311 }
312
313 pub fn verifying_key_bytes(&self) -> [u8; 32] {
314 *self.signing.verifying_key().as_bytes()
315 }
316
317 pub fn pubkey_text(&self) -> String {
318 encode_pubkey(&self.signing.verifying_key())
319 }
320
321 pub fn public_key_multibase(&self) -> String {
325 encode_pubkey(&self.signing.verifying_key())
326 }
327
328 pub fn to_x25519_static_secret(&self) -> x25519_dalek::StaticSecret {
334 let scalar_bytes = self.signing.to_scalar_bytes();
335 x25519_dalek::StaticSecret::from(scalar_bytes)
336 }
337}
338
339pub fn verify_bytes(pubkey: &[u8; 32], msg: &[u8], sig_multibase: &str) -> bool {
342 let Ok((_, sig_bytes)) = multibase::decode(sig_multibase) else {
343 return false;
344 };
345 let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.try_into() else {
346 return false;
347 };
348 let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pubkey) else {
349 return false;
350 };
351 vk.verify_strict(msg, &ed25519_dalek::Signature::from_bytes(&sig_arr))
352 .is_ok()
353}
354
355pub fn valid_ed25519_pubkey(bytes: &[u8; 32]) -> bool {
357 VerifyingKey::from_bytes(bytes).is_ok()
358}
359
360pub fn encode_pubkey(key: &VerifyingKey) -> String {
362 multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
363}
364
365pub fn decode_pubkey(text: &str) -> Result<[u8; 32], IdentityError> {
367 let (_base, bytes) = multibase::decode(text)?;
368 if bytes.len() != 32 {
369 return Err(IdentityError::InvalidKey(format!(
370 "pubkey must be 32 bytes, got {}",
371 bytes.len()
372 )));
373 }
374 let mut out = [0u8; 32];
375 out.copy_from_slice(&bytes);
376 Ok(out)
377}
378
379pub fn ed25519_pub_to_x25519(ed_pub: &[u8; 32]) -> Option<[u8; 32]> {
387 let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
388 let point = compressed.decompress()?;
389 Some(point.to_montgomery().to_bytes())
390}
391
392pub fn x25519_pub_from_multibase(text: &str) -> Result<[u8; 32], IdentityError> {
394 let ed = decode_pubkey(text)?;
395 ed25519_pub_to_x25519(&ed)
396 .ok_or_else(|| IdentityError::InvalidKey("pubkey is not a valid Edwards point".into()))
397}
398
399pub fn default_dir(agent_home: &Path) -> PathBuf {
401 agent_home.to_path_buf()
402}
403
404use serde::{Deserialize, Serialize};
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
414#[serde(rename_all = "snake_case")]
415pub enum RotationReason {
416 Scheduled,
417 SuspectCompromise,
418 OwnerChange,
419 Emergency,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct RotationAttestation {
431 pub schema: u32,
433 pub uuid: String,
435 pub algorithm: String,
437 pub old_pubkey: String,
439 pub new_pubkey: String,
441 pub old_key_version: u32,
442 pub new_key_version: u32,
444 pub rotated_at: String,
446 pub reason: RotationReason,
447 #[serde(default, skip_serializing_if = "String::is_empty")]
450 pub signature: String,
451 #[serde(default, skip_serializing_if = "is_false")]
453 pub bootstrap: bool,
454}
455
456fn is_false(b: &bool) -> bool {
457 !*b
458}
459
460impl RotationAttestation {
461 pub fn new(
463 uuid: impl Into<String>,
464 old_pubkey: impl Into<String>,
465 new_pubkey: impl Into<String>,
466 old_key_version: u32,
467 new_key_version: u32,
468 rotated_at: impl Into<String>,
469 reason: RotationReason,
470 ) -> Self {
471 Self {
472 schema: 1,
473 uuid: uuid.into(),
474 algorithm: "ed25519".into(),
475 old_pubkey: old_pubkey.into(),
476 new_pubkey: new_pubkey.into(),
477 old_key_version,
478 new_key_version,
479 rotated_at: rotated_at.into(),
480 reason,
481 signature: String::new(),
482 bootstrap: false,
483 }
484 }
485
486 pub fn into_bootstrap(mut self) -> Self {
490 self.bootstrap = true;
491 self.old_pubkey = String::new();
492 self.signature = String::new();
493 self
494 }
495
496 pub fn canonical_bytes(&self) -> Vec<u8> {
500 let mut clone = self.clone();
501 clone.signature = String::new();
502 canonical_json(&clone)
503 }
504
505 pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
508 use ed25519_dalek::Signer;
509 let sig = signing.sign(&self.canonical_bytes());
510 self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
511 }
512
513 pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
522 if self.bootstrap {
523 return Ok(());
524 }
525 if self.signature.is_empty() {
526 return Err(IdentityError::InvalidKey(
527 "attestation signature is empty".into(),
528 ));
529 }
530 let pub_bytes = decode_pubkey(old_pubkey)?;
531 let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
532 .map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
533 let (_base, sig_bytes) = multibase::decode(&self.signature)?;
534 let sig_arr: [u8; 64] = sig_bytes
535 .as_slice()
536 .try_into()
537 .map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
538 let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
539 verifying
540 .verify_strict(&self.canonical_bytes(), &sig)
541 .map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
542 Ok(())
543 }
544
545 pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
548 if self.reason == RotationReason::Emergency && self.signature.is_empty() {
549 return Ok(());
550 }
551 self.verify(old_pubkey)
552 }
553}
554
555#[derive(Debug, Clone, Copy, Default)]
561pub struct ChainOptions {
562 pub allow_emergency: bool,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq)]
571pub struct ChainOutcome {
572 pub head_key_version: u32,
574 pub head_pubkey: String,
576 pub length: usize,
578}
579
580#[derive(Debug)]
582pub enum ChainError {
583 MissingBootstrap,
585 VersionSkip { expected: u32, got: u32 },
587 PubkeyDiscontinuity { at_version: u32 },
589 DuplicateVersion(u32),
591 BadSignature { at_version: u32, detail: String },
593 EmergencyDisallowed { at_version: u32 },
595}
596
597impl std::fmt::Display for ChainError {
598 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599 match self {
600 Self::MissingBootstrap => {
601 write!(
602 f,
603 "chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
604 )
605 }
606 Self::VersionSkip { expected, got } => {
607 write!(f, "version skip: expected {expected}, got {got}")
608 }
609 Self::PubkeyDiscontinuity { at_version } => {
610 write!(
611 f,
612 "pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
613 )
614 }
615 Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
616 Self::BadSignature { at_version, detail } => {
617 write!(f, "bad signature at key_version {at_version}: {detail}")
618 }
619 Self::EmergencyDisallowed { at_version } => {
620 write!(
621 f,
622 "emergency attestation at key_version {at_version} requires allow_emergency=true"
623 )
624 }
625 }
626 }
627}
628
629impl std::error::Error for ChainError {}
630
631pub fn verify_chain(
634 chain: &[RotationAttestation],
635 opts: ChainOptions,
636) -> std::result::Result<ChainOutcome, ChainError> {
637 if chain.is_empty() {
638 return Err(ChainError::MissingBootstrap);
639 }
640 let first = &chain[0];
641 if !first.bootstrap || first.new_key_version != 0 {
642 return Err(ChainError::MissingBootstrap);
643 }
644
645 let mut prev_pubkey = first.new_pubkey.clone();
646 let mut prev_version = 0u32;
647 let mut seen_versions = std::collections::HashSet::new();
648 seen_versions.insert(0u32);
649
650 for (i, a) in chain.iter().enumerate().skip(1) {
651 if !seen_versions.insert(a.new_key_version) {
653 return Err(ChainError::DuplicateVersion(a.new_key_version));
654 }
655 let expected = prev_version + 1;
657 if a.old_key_version != prev_version || a.new_key_version != expected {
658 return Err(ChainError::VersionSkip {
659 expected,
660 got: a.new_key_version,
661 });
662 }
663 if a.old_pubkey != prev_pubkey {
665 return Err(ChainError::PubkeyDiscontinuity {
666 at_version: a.new_key_version,
667 });
668 }
669 if a.reason == RotationReason::Emergency {
671 if !opts.allow_emergency {
672 return Err(ChainError::EmergencyDisallowed {
673 at_version: a.new_key_version,
674 });
675 }
676 if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
678 return Err(ChainError::BadSignature {
679 at_version: a.new_key_version,
680 detail: e.to_string(),
681 });
682 }
683 } else if let Err(e) = a.verify(&a.old_pubkey) {
684 return Err(ChainError::BadSignature {
685 at_version: a.new_key_version,
686 detail: e.to_string(),
687 });
688 }
689
690 prev_pubkey = a.new_pubkey.clone();
691 prev_version = a.new_key_version;
692 let _ = i; }
694
695 Ok(ChainOutcome {
696 head_key_version: prev_version,
697 head_pubkey: prev_pubkey,
698 length: chain.len(),
699 })
700}
701
702fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
706 let v: serde_json::Value =
709 serde_json::to_value(value).expect("serialize should not fail for our types");
710 let mut out = Vec::new();
711 write_canonical(&mut out, &v);
712 out
713}
714
715fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
716 use serde_json::Value;
717 match v {
718 Value::Null => out.extend_from_slice(b"null"),
719 Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
720 Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
721 Value::String(s) => {
722 let escaped = serde_json::to_string(s).unwrap();
724 out.extend_from_slice(escaped.as_bytes());
725 }
726 Value::Array(arr) => {
727 out.push(b'[');
728 for (i, item) in arr.iter().enumerate() {
729 if i > 0 {
730 out.push(b',');
731 }
732 write_canonical(out, item);
733 }
734 out.push(b']');
735 }
736 Value::Object(map) => {
737 let mut keys: Vec<&String> = map.keys().collect();
739 keys.sort();
740 out.push(b'{');
741 for (i, k) in keys.iter().enumerate() {
742 if i > 0 {
743 out.push(b',');
744 }
745 let kesc = serde_json::to_string(k).unwrap();
746 out.extend_from_slice(kesc.as_bytes());
747 out.push(b':');
748 write_canonical(out, &map[*k]);
749 }
750 out.push(b'}');
751 }
752 }
753}
754
755#[cfg(test)]
756mod identity_readability_tests {
757 use super::*;
758
759 #[cfg(unix)]
766 #[test]
767 fn an_unreadable_key_is_denied_not_notfound() {
768 use std::os::unix::fs::PermissionsExt;
769 let dir = tempfile::tempdir().unwrap();
770 AgentIdentity::generate().save(dir.path()).unwrap();
771 let key = dir.path().join("identity.key");
772
773 assert!(AgentIdentity::load(dir.path()).is_ok());
776
777 std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o000)).unwrap();
780 let unreadable = AgentIdentity::load(dir.path()).unwrap_err();
781 std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap();
782 assert!(
783 matches!(unreadable, IdentityError::Denied(_)),
784 "an unreadable key must be Denied, got {unreadable:?}"
785 );
786
787 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
793 let unstattable = AgentIdentity::load(dir.path()).unwrap_err();
794 let exists_lies = !key.exists();
795 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
796 assert!(
797 exists_lies,
798 "precondition: Path::exists() must be answering false here, or this \
799 case is not reproducing a sandbox deny"
800 );
801 assert!(
802 matches!(unstattable, IdentityError::Denied(_)),
803 "a key that cannot be STATted must be Denied, not NotFound, got {unstattable:?}"
804 );
805 }
806
807 #[test]
809 fn an_agent_key_moves_and_the_public_half_stays() {
810 let tmp = tempfile::tempdir().unwrap();
811 let mur = tmp.path();
812 let agent = mur.join("agents").join("pm");
813 AgentIdentity::generate().save(&agent).unwrap();
814
815 assert!(
816 mur.join("keys").join("pm").join("identity.key").exists(),
817 "private key must live under keys/"
818 );
819 assert!(
820 !agent.join("identity.key").exists(),
821 "the agents tree must hold no private key"
822 );
823 assert!(
824 agent.join("identity.pub").exists(),
825 "the public half must stay where peers read it"
826 );
827 }
828
829 #[test]
832 fn non_agent_identities_are_not_remapped() {
833 let tmp = tempfile::tempdir().unwrap();
834 let mur = tmp.path();
835 for dir in [
836 mur.to_path_buf(), mur.join("commander"), mur.join("publisher"), ] {
840 assert_eq!(
841 private_key_dir(&dir),
842 dir,
843 "{} must not be remapped",
844 dir.display()
845 );
846 AgentIdentity::generate().save(&dir).unwrap();
847 assert!(
848 dir.join("identity.key").exists(),
849 "{} lost its key to the remap",
850 dir.display()
851 );
852 }
853 }
854
855 #[test]
863 fn a_legacy_key_loads_only_after_migration() {
864 let tmp = tempfile::tempdir().unwrap();
865 let agent = tmp.path().join("agents").join("legacy");
866 std::fs::create_dir_all(&agent).unwrap();
867 let id = AgentIdentity::generate();
868 std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
869
870 assert!(
872 matches!(
873 AgentIdentity::load(&agent).unwrap_err(),
874 IdentityError::NotFound
875 ),
876 "a key in the legacy location must not be silently honoured"
877 );
878
879 assert!(migrate_private_key(&agent).unwrap());
881 assert_eq!(
882 AgentIdentity::load(&agent).unwrap().pubkey_text(),
883 id.pubkey_text()
884 );
885 }
886
887 #[test]
890 fn the_new_location_wins_over_a_leftover_legacy_key() {
891 let tmp = tempfile::tempdir().unwrap();
892 let mur = tmp.path();
893 let agent = mur.join("agents").join("dual");
894 let current = AgentIdentity::generate();
895 current.save(&agent).unwrap();
896 let stale = AgentIdentity::generate();
897 std::fs::create_dir_all(&agent).unwrap();
898 std::fs::write(agent.join("identity.key"), stale.signing.to_bytes()).unwrap();
899
900 let loaded = AgentIdentity::load(&agent).unwrap();
901 assert_eq!(
902 loaded.pubkey_text(),
903 current.pubkey_text(),
904 "the migrated key must win over the leftover"
905 );
906 }
907
908 #[test]
910 fn migration_moves_the_key_out_of_the_agents_tree() {
911 let tmp = tempfile::tempdir().unwrap();
912 let mur = tmp.path();
913 let agent = mur.join("agents").join("pm");
914 std::fs::create_dir_all(&agent).unwrap();
915 let id = AgentIdentity::generate();
916 std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
917
918 assert!(migrate_private_key(&agent).unwrap());
919
920 assert!(!agent.join("identity.key").exists(), "key left in agents/");
921 assert!(mur.join("keys/pm/identity.key").exists());
922 assert_eq!(
923 AgentIdentity::load(&agent).unwrap().pubkey_text(),
924 id.pubkey_text(),
925 "the same identity must load after the move"
926 );
927 }
928
929 #[test]
931 fn migration_is_idempotent() {
932 let tmp = tempfile::tempdir().unwrap();
933 let agent = tmp.path().join("agents").join("pm");
934 AgentIdentity::generate().save(&agent).unwrap(); assert!(!migrate_private_key(&agent).unwrap());
936 assert!(!migrate_private_key(&agent).unwrap());
937 }
938
939 #[test]
944 fn migration_refuses_when_the_destination_differs() {
945 let tmp = tempfile::tempdir().unwrap();
946 let mur = tmp.path();
947 let agent = mur.join("agents").join("pm");
948 let migrated = AgentIdentity::generate();
949 migrated.save(&agent).unwrap();
950 let stray = AgentIdentity::generate();
951 std::fs::write(agent.join("identity.key"), stray.signing.to_bytes()).unwrap();
952
953 let err = migrate_private_key(&agent).unwrap_err();
954
955 assert!(matches!(err, IdentityError::Exists(_)), "got {err:?}");
956 assert_eq!(
957 std::fs::read(mur.join("keys/pm/identity.key")).unwrap(),
958 migrated.signing.to_bytes().to_vec(),
959 "the destination key was modified despite the refusal"
960 );
961 assert!(
962 agent.join("identity.key").exists(),
963 "the source was removed despite the refusal"
964 );
965 }
966
967 #[test]
970 fn migration_clears_an_identical_leftover() {
971 let tmp = tempfile::tempdir().unwrap();
972 let agent = tmp.path().join("agents").join("pm");
973 let id = AgentIdentity::generate();
974 id.save(&agent).unwrap();
975 std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
976
977 assert!(!migrate_private_key(&agent).unwrap());
978 assert!(!agent.join("identity.key").exists());
979 }
980
981 #[test]
983 fn migration_skips_non_agent_identities() {
984 let tmp = tempfile::tempdir().unwrap();
985 let mur = tmp.path();
986 for dir in [
987 mur.to_path_buf(),
988 mur.join("commander"),
989 mur.join("publisher"),
990 ] {
991 AgentIdentity::generate().save(&dir).unwrap();
992 assert!(!migrate_private_key(&dir).unwrap());
993 assert!(dir.join("identity.key").exists(), "{}", dir.display());
994 }
995 }
996
997 #[test]
1005 fn save_refuses_to_overwrite_an_existing_key() {
1006 let dir = tempfile::tempdir().unwrap();
1007 let first = AgentIdentity::generate();
1008 first.save(dir.path()).unwrap();
1009 let original = std::fs::read(dir.path().join("identity.key")).unwrap();
1010
1011 let err = AgentIdentity::generate().save(dir.path()).unwrap_err();
1012
1013 assert!(
1014 matches!(err, IdentityError::Exists(_)),
1015 "expected Exists, got {err:?}"
1016 );
1017 assert_eq!(
1018 std::fs::read(dir.path().join("identity.key")).unwrap(),
1019 original,
1020 "the existing key was modified despite the refusal"
1021 );
1022 }
1023
1024 #[test]
1028 fn save_into_an_empty_directory_succeeds() {
1029 let dir = tempfile::tempdir().unwrap();
1030 let id = AgentIdentity::generate();
1031 id.save(dir.path()).unwrap();
1032 assert_eq!(
1033 AgentIdentity::load(dir.path()).unwrap().pubkey_text(),
1034 id.pubkey_text()
1035 );
1036 }
1037
1038 #[test]
1041 fn a_missing_key_is_still_notfound() {
1042 let dir = tempfile::tempdir().unwrap();
1043 assert!(matches!(
1044 AgentIdentity::load(dir.path()).unwrap_err(),
1045 IdentityError::NotFound
1046 ));
1047 }
1048}
1049
1050#[cfg(test)]
1051mod identity_x25519_tests {
1052 use super::*;
1053
1054 #[test]
1055 fn x25519_pub_matches_secret_derivation() {
1056 let id = AgentIdentity::generate();
1060 let from_secret = x25519_dalek::PublicKey::from(&id.to_x25519_static_secret());
1061 let from_pub = x25519_pub_from_multibase(&id.public_key_multibase()).unwrap();
1062 assert_eq!(from_secret.as_bytes(), &from_pub);
1063 }
1064}