1use crate::fingerprint::Fingerprint;
10use crate::{MeshError, Result};
11use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
12use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey};
13use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
14use rand::rngs::OsRng;
15use serde::{Deserialize, Serialize};
16use std::path::Path;
17use zeroize::Zeroize;
18
19pub struct UserKey {
25 signing: SigningKey,
26}
27
28impl UserKey {
29 pub(crate) fn derived_agent_seed(&self, label: &str) -> [u8; 32] {
41 const CONTEXT: &str = "agent-mesh 2026-09-26 derived agent key v1";
44 let mut root = self.signing.to_bytes();
45 let mut subkey = blake3::derive_key(CONTEXT, &root);
46 root.zeroize();
47 let seed = *blake3::keyed_hash(&subkey, label.as_bytes()).as_bytes();
48 subkey.zeroize();
49 seed
50 }
51
52 #[must_use]
54 pub fn generate() -> Self {
55 let mut csprng = OsRng;
56 let signing = SigningKey::generate(&mut csprng);
57 Self { signing }
58 }
59
60 #[must_use]
62 pub fn public(&self) -> UserPublic {
63 UserPublic {
64 verifying: self.signing.verifying_key(),
65 }
66 }
67
68 #[must_use]
70 pub fn fingerprint(&self) -> Fingerprint {
71 self.public().fingerprint()
72 }
73
74 pub fn sign(&self, message: &[u8]) -> Signature {
79 self.signing.sign(message)
80 }
81
82 pub fn save(&self, path: &Path) -> Result<()> {
91 if let Some(parent) = path.parent() {
92 if !parent.as_os_str().is_empty() {
93 std::fs::create_dir_all(parent)?;
94 }
95 }
96 let pem = self
97 .signing
98 .to_pkcs8_pem(LineEnding::LF)
99 .map_err(|e| MeshError::InvalidKey(e.to_string()))?;
100
101 use std::io::Write;
102 #[cfg(unix)]
103 use std::os::unix::fs::OpenOptionsExt;
104
105 let mut opts = std::fs::OpenOptions::new();
106 opts.write(true).create_new(true);
110 #[cfg(unix)]
115 opts.mode(0o600);
116
117 let mut f = opts.open(path).map_err(|e| {
118 if e.kind() == std::io::ErrorKind::AlreadyExists {
119 refuse_overwrite_error(path)
120 } else {
121 MeshError::Io(e)
122 }
123 })?;
124 f.write_all(pem.as_bytes())?;
125 Ok(())
126 }
127
128 pub fn load(path: &Path) -> Result<Self> {
130 let pem = std::fs::read_to_string(path)?;
131 let signing =
132 SigningKey::from_pkcs8_pem(&pem).map_err(|e| MeshError::InvalidKey(e.to_string()))?;
133 Ok(Self { signing })
134 }
135}
136
137fn refuse_overwrite_error(path: &Path) -> MeshError {
143 MeshError::Io(std::io::Error::new(
144 std::io::ErrorKind::AlreadyExists,
145 format!("refusing to overwrite existing key at {}", path.display()),
146 ))
147}
148
149impl std::fmt::Debug for UserKey {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 f.debug_struct("UserKey")
153 .field("fingerprint", &self.fingerprint())
154 .finish_non_exhaustive()
155 }
156}
157
158impl Drop for UserKey {
159 fn drop(&mut self) {
160 let mut bytes = self.signing.to_bytes();
164 bytes.zeroize();
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct UserPublic {
172 #[serde(with = "verifying_key_serde")]
173 pub verifying: VerifyingKey,
174}
175
176impl UserPublic {
177 #[must_use]
180 pub fn fingerprint(&self) -> Fingerprint {
181 Fingerprint::of_bytes(self.verifying.as_bytes())
182 }
183
184 pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<()> {
187 self.verifying
188 .verify(message, signature)
189 .map_err(|_| MeshError::BadSignature)
190 }
191
192 #[must_use]
194 pub fn as_bytes(&self) -> [u8; 32] {
195 *self.verifying.as_bytes()
196 }
197}
198
199mod verifying_key_serde {
200 use ed25519_dalek::VerifyingKey;
201 use serde::{Deserialize, Deserializer, Serialize, Serializer};
202
203 pub fn serialize<S: Serializer>(key: &VerifyingKey, ser: S) -> Result<S::Ok, S::Error> {
204 let bytes: &[u8] = key.as_bytes();
205 bytes.serialize(ser)
206 }
207
208 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<VerifyingKey, D::Error> {
209 let bytes: Vec<u8> = Vec::deserialize(de)?;
210 if bytes.len() != 32 {
211 return Err(serde::de::Error::custom("expected 32 bytes"));
212 }
213 let mut arr = [0u8; 32];
214 arr.copy_from_slice(&bytes);
215 VerifyingKey::from_bytes(&arr).map_err(serde::de::Error::custom)
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use tempfile::TempDir;
223
224 #[test]
229 fn derived_agent_seed_matches_its_pinned_vector() {
230 let user = UserKey {
231 signing: SigningKey::from_bytes(&[7u8; 32]),
232 };
233 let seed = user.derived_agent_seed("dock/hub");
234 assert_eq!(
235 hex::encode(seed),
236 "52335d4284e638ccc1ffc036573c7d4b9e92e7c2591247a83c000fe9fc22085d"
237 );
238 }
239
240 #[test]
241 fn generate_different_keys() {
242 let a = UserKey::generate();
243 let b = UserKey::generate();
244 assert_ne!(
245 a.fingerprint(),
246 b.fingerprint(),
247 "two fresh keys must not collide"
248 );
249 }
250
251 #[test]
252 fn roundtrip_save_load_disk() {
253 let dir = TempDir::new().unwrap();
254 let path = dir.path().join("user.key");
255 let key = UserKey::generate();
256 let fp = key.fingerprint();
257 key.save(&path).expect("save");
258 let loaded = UserKey::load(&path).expect("load");
259 assert_eq!(loaded.fingerprint(), fp);
260 }
261
262 #[test]
263 fn save_refuses_overwrite() {
264 let dir = TempDir::new().unwrap();
265 let path = dir.path().join("user.key");
266 let key = UserKey::generate();
267 key.save(&path).expect("first save");
268 let key2 = UserKey::generate();
269 let err = key2.save(&path).expect_err("must refuse");
270 match err {
271 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
272 other => panic!("expected Io(AlreadyExists), got {other:?}"),
273 }
274 }
275
276 #[test]
277 fn save_creates_parent_directory() {
278 let dir = TempDir::new().unwrap();
279 let path = dir.path().join("nested").join("dir").join("user.key");
280 UserKey::generate().save(&path).expect("save with mkdir -p");
281 assert!(path.exists());
282 }
283
284 #[test]
285 #[cfg(unix)]
286 fn save_sets_0600_permissions() {
287 use std::os::unix::fs::PermissionsExt;
288 let dir = TempDir::new().unwrap();
297 let path = dir.path().join("user.key");
298 UserKey::generate().save(&path).expect("save");
299 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
300 assert_eq!(mode & 0o777, 0o600, "expected exactly 0600, got {mode:o}");
301 assert_eq!(mode & 0o077, 0, "no group/world bits, got {mode:o}");
304 }
305
306 #[test]
307 fn sign_verify() {
308 let key = UserKey::generate();
309 let pubk = key.public();
310 let msg = b"hello agent-mesh";
311 let sig = key.sign(msg);
312 pubk.verify(msg, &sig).expect("verify own signature");
313 }
314
315 #[test]
316 fn wrong_message_fails_verify() {
317 let key = UserKey::generate();
318 let pubk = key.public();
319 let sig = key.sign(b"original");
320 let err = pubk.verify(b"tampered", &sig).expect_err("must fail");
321 assert!(matches!(err, MeshError::BadSignature));
322 }
323
324 #[test]
325 fn fingerprint_stable_across_loads() {
326 let dir = TempDir::new().unwrap();
327 let path = dir.path().join("user.key");
328 let key = UserKey::generate();
329 let fp1 = key.fingerprint();
330 key.save(&path).unwrap();
331 drop(key);
332 let loaded = UserKey::load(&path).unwrap();
333 let fp2 = loaded.fingerprint();
334 assert_eq!(fp1, fp2);
335 }
336
337 #[test]
338 fn serde_roundtrip_public() {
339 let key = UserKey::generate();
340 let pubk = key.public();
341 let json = serde_json::to_string(&pubk).unwrap();
342 let parsed: UserPublic = serde_json::from_str(&json).unwrap();
343 assert_eq!(parsed, pubk);
344 assert_eq!(parsed.fingerprint(), pubk.fingerprint());
345 }
346
347 #[test]
348 fn public_as_bytes_is_32() {
349 let key = UserKey::generate();
350 let bytes = key.public().as_bytes();
351 assert_eq!(bytes.len(), 32);
352 }
353
354 #[test]
355 fn load_fails_on_missing_file() {
356 let dir = TempDir::new().unwrap();
357 let path = dir.path().join("nope.key");
358 let err = UserKey::load(&path).expect_err("must fail");
359 assert!(matches!(err, MeshError::Io(_)));
360 }
361
362 #[test]
363 fn load_fails_on_garbage_file() {
364 let dir = TempDir::new().unwrap();
365 let path = dir.path().join("garbage");
366 std::fs::write(&path, b"not a pem").unwrap();
367 let err = UserKey::load(&path).expect_err("must fail");
368 assert!(matches!(err, MeshError::InvalidKey(_)));
369 }
370
371 #[test]
392 #[cfg(unix)]
393 fn save_refuses_dangling_symlink_and_creates_nothing() {
394 let dir = TempDir::new().unwrap();
395 let target_dir = TempDir::new().unwrap();
396 let path = dir.path().join("user.key");
397 let target = target_dir.path().join("redirected.key");
398 std::os::unix::fs::symlink(&target, &path).unwrap();
399
400 let err = UserKey::generate()
401 .save(&path)
402 .expect_err("must refuse to save through a dangling symlink");
403 match err {
404 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
405 other => panic!("expected Io(AlreadyExists), got {other:?}"),
406 }
407 assert!(
408 !target.exists(),
409 "key must not be created at the symlink target"
410 );
411 let meta = std::fs::symlink_metadata(&path).expect("symlink still present");
412 assert!(
413 meta.file_type().is_symlink(),
414 "the planted symlink must be left untouched"
415 );
416 }
417
418 #[test]
427 #[cfg(unix)]
428 fn save_refuses_symlink_chain_and_creates_nothing() {
429 let dir = TempDir::new().unwrap();
430 let target_dir = TempDir::new().unwrap();
431 let path = dir.path().join("user.key");
432 let mid = dir.path().join("mid.link");
433 let target = target_dir.path().join("end.key");
434 std::os::unix::fs::symlink(&target, &mid).unwrap();
435 std::os::unix::fs::symlink(&mid, &path).unwrap();
436
437 let err = UserKey::generate()
438 .save(&path)
439 .expect_err("must refuse to save through a symlink chain");
440 assert!(matches!(err, MeshError::Io(_)));
441 assert!(
442 !target.exists(),
443 "key must not be created at the end of the chain"
444 );
445 }
446
447 #[test]
453 #[cfg(unix)]
454 fn save_refuses_symlink_to_existing_file_without_touching_target() {
455 use std::os::unix::fs::PermissionsExt;
456 let dir = TempDir::new().unwrap();
457 let target_dir = TempDir::new().unwrap();
458 let path = dir.path().join("user.key");
459 let target = target_dir.path().join("victim.txt");
460 std::fs::write(&target, b"victim content").unwrap();
461 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
462 std::os::unix::fs::symlink(&target, &path).unwrap();
463
464 let err = UserKey::generate()
465 .save(&path)
466 .expect_err("must refuse symlink to existing file");
467 assert!(matches!(err, MeshError::Io(_)));
468 assert_eq!(
469 std::fs::read(&target).unwrap(),
470 b"victim content",
471 "target content must be untouched"
472 );
473 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
474 assert_eq!(mode & 0o777, 0o644, "target mode must be untouched");
475 }
476
477 #[test]
490 fn save_concurrent_racers_exactly_one_wins() {
491 use std::sync::{Arc, Barrier};
492 const THREADS: usize = 8;
493 const ITERS: usize = 50;
494
495 for i in 0..ITERS {
496 let dir = TempDir::new().unwrap();
497 let path = Arc::new(dir.path().join(format!("user-{i}.key")));
498 let barrier = Arc::new(Barrier::new(THREADS));
499
500 let handles: Vec<_> = (0..THREADS)
501 .map(|_| {
502 let path = Arc::clone(&path);
503 let barrier = Arc::clone(&barrier);
504 std::thread::spawn(move || {
505 let key = UserKey::generate();
508 barrier.wait();
509 key.save(&path)
510 })
511 })
512 .collect();
513
514 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
515 let oks = results.iter().filter(|r| r.is_ok()).count();
516 assert_eq!(
517 oks, 1,
518 "exactly one concurrent save must win (iteration {i}), got {oks}"
519 );
520 for r in &results {
521 if let Err(MeshError::Io(e)) = r {
522 assert_eq!(
523 e.kind(),
524 std::io::ErrorKind::AlreadyExists,
525 "losers must see AlreadyExists"
526 );
527 }
528 }
529 UserKey::load(&path).expect("winner must have written a complete valid key");
532 #[cfg(unix)]
533 {
534 use std::os::unix::fs::PermissionsExt;
535 let mode = std::fs::metadata(&*path).unwrap().permissions().mode();
536 assert_eq!(mode & 0o077, 0, "winner file must have no group/world bits");
537 }
538 }
539 }
540
541 #[test]
555 #[cfg(unix)]
556 fn save_never_exposes_group_world_readable_window() {
557 use std::os::unix::fs::PermissionsExt;
558 use std::sync::atomic::{AtomicBool, Ordering};
559 use std::sync::Arc;
560 const ITERS: usize = 200;
561
562 let dir = TempDir::new().unwrap();
563 for i in 0..ITERS {
564 let path = Arc::new(dir.path().join(format!("probe-{i}.key")));
565 let stop = Arc::new(AtomicBool::new(false));
566 let saw_wide = Arc::new(AtomicBool::new(false));
567
568 let observer = {
569 let path = Arc::clone(&path);
570 let stop = Arc::clone(&stop);
571 let saw_wide = Arc::clone(&saw_wide);
572 std::thread::spawn(move || {
573 while !stop.load(Ordering::Relaxed) {
574 if let Ok(meta) = std::fs::symlink_metadata(&*path) {
575 if meta.permissions().mode() & 0o077 != 0 {
576 saw_wide.store(true, Ordering::Relaxed);
577 }
578 }
579 std::hint::spin_loop();
580 }
581 })
582 };
583
584 UserKey::generate().save(&path).expect("save");
585 stop.store(true, Ordering::Relaxed);
586 observer.join().unwrap();
587
588 assert!(
589 !saw_wide.load(Ordering::Relaxed),
590 "key file was observable with group/world bits (iteration {i})"
591 );
592 }
593 }
594
595 #[test]
599 fn save_refuse_overwrite_message_pins_path() {
600 let dir = TempDir::new().unwrap();
601 let path = dir.path().join("user.key");
602 UserKey::generate().save(&path).expect("first save");
603 let err = UserKey::generate().save(&path).expect_err("must refuse");
604 let msg = err.to_string();
605 assert!(
606 msg.contains(&format!(
607 "refusing to overwrite existing key at {}",
608 path.display()
609 )),
610 "unexpected error message: {msg}"
611 );
612 }
613
614 #[test]
619 fn save_refuse_overwrite_leaves_existing_content_untouched() {
620 let dir = TempDir::new().unwrap();
621 let path = dir.path().join("user.key");
622 std::fs::write(&path, b"pre-existing bytes, not even a key").unwrap();
623
624 let err = UserKey::generate().save(&path).expect_err("must refuse");
625 assert!(matches!(err, MeshError::Io(_)));
626 assert_eq!(
627 std::fs::read(&path).unwrap(),
628 b"pre-existing bytes, not even a key",
629 "existing file must not be truncated or rewritten"
630 );
631 }
632
633 #[test]
637 fn save_refuses_directory_at_path() {
638 let dir = TempDir::new().unwrap();
639 let path = dir.path().join("user.key");
640 std::fs::create_dir(&path).unwrap();
641 std::fs::write(path.join("inner.txt"), b"keep me").unwrap();
642
643 let err = UserKey::generate()
644 .save(&path)
645 .expect_err("must refuse dir");
646 assert!(matches!(err, MeshError::Io(_)));
647 assert!(path.is_dir(), "directory must survive");
648 assert_eq!(std::fs::read(path.join("inner.txt")).unwrap(), b"keep me");
649 }
650
651 #[test]
655 fn save_errors_when_parent_component_is_a_file() {
656 let dir = TempDir::new().unwrap();
657 let blocker = dir.path().join("blocker");
658 std::fs::write(&blocker, b"i am a file").unwrap();
659 let path = blocker.join("sub").join("user.key");
660
661 let err = UserKey::generate().save(&path).expect_err("must fail");
662 assert!(matches!(err, MeshError::Io(_)));
663 assert_eq!(
664 std::fs::read(&blocker).unwrap(),
665 b"i am a file",
666 "blocking file must be untouched"
667 );
668 }
669
670 #[test]
675 #[cfg(unix)]
676 fn save_errors_on_readonly_parent_dir() {
677 use std::os::unix::fs::PermissionsExt;
678 let dir = TempDir::new().unwrap();
679 let parent = dir.path().join("locked");
680 std::fs::create_dir(&parent).unwrap();
681 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap();
682
683 if std::fs::File::create(parent.join(".probe")).is_ok() {
686 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
687 eprintln!("skipping: running privileged, mode bits don't bind");
688 return;
689 }
690
691 let path = parent.join("user.key");
692 let err = UserKey::generate().save(&path).expect_err("must fail");
693 match &err {
694 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
695 other => panic!("expected Io(PermissionDenied), got {other:?}"),
696 }
697 assert!(!path.exists(), "no partial file may appear");
698 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
699 }
700
701 #[test]
713 #[cfg(all(unix, not(target_os = "macos")))]
714 fn save_load_roundtrip_non_utf8_filename() {
715 use std::ffi::OsStr;
716 use std::os::unix::ffi::OsStrExt;
717 let dir = TempDir::new().unwrap();
718 let path = dir.path().join(OsStr::from_bytes(b"user-\xff\xfe.key"));
719
720 match std::fs::File::create(&path) {
728 Ok(_) => std::fs::remove_file(&path).expect("remove probe file"),
729 Err(e) => {
730 eprintln!(
731 "skipping: filesystem rejects non-UTF-8 filenames \
732 (e.g. macOS APFS EILSEQ): {e}"
733 );
734 return;
735 }
736 }
737
738 let key = UserKey::generate();
739 let fp = key.fingerprint();
740 key.save(&path).expect("save with non-UTF-8 filename");
741 let loaded = UserKey::load(&path).expect("load with non-UTF-8 filename");
742 assert_eq!(loaded.fingerprint(), fp);
743
744 let err = UserKey::generate().save(&path).expect_err("must refuse");
745 let msg = err.to_string();
746 assert!(
747 msg.contains("refusing to overwrite existing key at "),
748 "lossy display must still produce the refuse message: {msg}"
749 );
750 }
751
752 #[test]
768 #[cfg(target_os = "macos")]
769 fn save_load_roundtrip_unusual_unicode_filename() {
770 let dir = TempDir::new().unwrap();
771 let path = dir.path().join("user-caf\u{00e9}-\u{1f511}-a\u{0301}.key");
775
776 let key = UserKey::generate();
777 let fp = key.fingerprint();
778 key.save(&path)
779 .expect("save with an unusual Unicode filename");
780 let loaded = UserKey::load(&path).expect("load with an unusual Unicode filename");
781 assert_eq!(loaded.fingerprint(), fp);
782
783 let err = UserKey::generate().save(&path).expect_err("must refuse");
784 let msg = err.to_string();
785 assert!(
786 msg.contains("refusing to overwrite existing key at "),
787 "display must still produce the refuse message for a Unicode path: {msg}"
788 );
789 }
790
791 #[test]
799 #[cfg(unix)]
800 fn refuse_overwrite_message_is_lossy_for_a_non_utf8_path() {
801 use std::ffi::OsStr;
802 use std::os::unix::ffi::OsStrExt;
803 let path = Path::new(OsStr::from_bytes(b"user-\xff\xfe.key"));
804 let msg = refuse_overwrite_error(path).to_string();
805 assert!(
806 msg.contains("refusing to overwrite existing key at "),
807 "the refuse message must survive a non-UTF-8 path: {msg}"
808 );
809 assert!(
810 msg.contains('\u{FFFD}'),
811 "non-UTF-8 bytes must render as the U+FFFD replacement char (lossy), not panic: {msg}"
812 );
813 }
814
815 #[test]
831 #[cfg(unix)]
832 fn save_racing_symlink_plant_never_redirects_key() {
833 use std::sync::{Arc, Barrier};
834 const ITERS: usize = 400;
835
836 let dir = TempDir::new().unwrap();
837 for i in 0..ITERS {
838 let path = Arc::new(dir.path().join(format!("race-{i}.key")));
839 let target = Arc::new(dir.path().join(format!("target-{i}.key")));
840 let barrier = Arc::new(Barrier::new(2));
841
842 let saver = {
843 let path = Arc::clone(&path);
844 let barrier = Arc::clone(&barrier);
845 std::thread::spawn(move || {
846 let key = UserKey::generate();
847 barrier.wait();
848 key.save(&path)
849 })
850 };
851 let planter = {
852 let path = Arc::clone(&path);
853 let target = Arc::clone(&target);
854 let barrier = Arc::clone(&barrier);
855 std::thread::spawn(move || {
856 barrier.wait();
857 std::os::unix::fs::symlink(&*target, &*path)
858 })
859 };
860
861 let save_res = saver.join().unwrap();
862 let plant_res = planter.join().unwrap();
863
864 assert!(
865 !target.exists(),
866 "key must never be written through a planted symlink (iteration {i})"
867 );
868 assert!(
869 save_res.is_ok() != plant_res.is_ok(),
870 "exactly one of save/plant must win (iteration {i}): \
871 save={save_res:?} plant_ok={}",
872 plant_res.is_ok()
873 );
874 let meta = std::fs::symlink_metadata(&*path).unwrap();
875 if save_res.is_ok() {
876 assert!(
877 meta.file_type().is_file(),
878 "winner save must leave a regular file"
879 );
880 UserKey::load(&path).expect("saved key must be complete and loadable");
881 } else {
882 assert!(
883 meta.file_type().is_symlink(),
884 "winner plant must leave the symlink"
885 );
886 if let Err(MeshError::Io(e)) = &save_res {
887 assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
888 } else {
889 panic!("losing save must be Io(AlreadyExists), got {save_res:?}");
890 }
891 }
892 }
893 }
894
895 #[test]
906 #[cfg(unix)]
907 fn save_refuses_self_loop_symlink_with_already_exists() {
908 let dir = TempDir::new().unwrap();
909 let path = dir.path().join("loop.key");
910 std::os::unix::fs::symlink(&path, &path).unwrap();
911
912 let err = UserKey::generate().save(&path).expect_err("must refuse");
913 match &err {
914 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
915 other => panic!("expected Io(AlreadyExists), got {other:?}"),
916 }
917 assert!(
918 err.to_string()
919 .contains("refusing to overwrite existing key at "),
920 "self-loop must surface the standard refusal, got: {err}"
921 );
922 }
923
924 #[test]
929 fn save_concurrent_distinct_paths_shared_new_parent_all_win() {
930 use std::sync::{Arc, Barrier};
931 const THREADS: usize = 8;
932
933 let dir = TempDir::new().unwrap();
934 let parent = Arc::new(dir.path().join("deep").join("nest"));
935 let barrier = Arc::new(Barrier::new(THREADS));
936
937 let handles: Vec<_> = (0..THREADS)
938 .map(|t| {
939 let parent = Arc::clone(&parent);
940 let barrier = Arc::clone(&barrier);
941 std::thread::spawn(move || {
942 let key = UserKey::generate();
943 let fp = key.fingerprint();
944 let path = parent.join(format!("k{t}.key"));
945 barrier.wait();
946 key.save(&path).expect("distinct-path save must succeed");
947 (path, fp)
948 })
949 })
950 .collect();
951
952 for h in handles {
953 let (path, fp) = h.join().unwrap();
954 let loaded = UserKey::load(&path).expect("each saved key must load");
955 assert_eq!(loaded.fingerprint(), fp);
956 #[cfg(unix)]
957 {
958 use std::os::unix::fs::PermissionsExt;
959 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
960 assert_eq!(mode & 0o777, 0o600, "expected 0600, got {mode:o}");
961 }
962 }
963 }
964
965 #[test]
971 #[cfg(unix)]
972 fn save_name_too_long_is_plain_io_error_not_refusal() {
973 let dir = TempDir::new().unwrap();
974 let path = dir.path().join("k".repeat(300));
975
976 let err = UserKey::generate().save(&path).expect_err("must fail");
977 match &err {
978 MeshError::Io(e) => {
979 assert_ne!(e.kind(), std::io::ErrorKind::AlreadyExists);
980 }
981 other => panic!("expected Io, got {other:?}"),
982 }
983 assert!(
984 !err.to_string().contains("refusing to overwrite"),
985 "ENAMETOOLONG must not masquerade as the overwrite refusal: {err}"
986 );
987 }
988
989 #[test]
995 fn saved_pem_is_strict_pkcs8_with_lf_endings() {
996 let dir = TempDir::new().unwrap();
997 let path = dir.path().join("user.key");
998 let key = UserKey::generate();
999 let fp = key.fingerprint();
1000 key.save(&path).expect("save");
1001
1002 let bytes = std::fs::read(&path).unwrap();
1003 assert!(
1004 bytes.starts_with(b"-----BEGIN PRIVATE KEY-----\n"),
1005 "PEM header missing or not LF-terminated"
1006 );
1007 assert!(
1008 bytes.ends_with(b"-----END PRIVATE KEY-----\n"),
1009 "PEM trailer missing or not LF-terminated"
1010 );
1011 assert!(!bytes.contains(&b'\r'), "PEM must use LF only, found CR");
1012 assert!(
1013 bytes.len() < 512,
1014 "one ed25519 PKCS#8 PEM expected, got {} bytes (double write?)",
1015 bytes.len()
1016 );
1017 let pem = String::from_utf8(bytes).expect("PEM is ASCII");
1018 let signing = SigningKey::from_pkcs8_pem(&pem).expect("strict PKCS#8 parse");
1019 assert_eq!(
1020 Fingerprint::of_bytes(signing.verifying_key().as_bytes()),
1021 fp,
1022 "parsed key must match the saved key"
1023 );
1024 }
1025
1026 #[test]
1029 fn save_load_roundtrip_unicode_filename() {
1030 let dir = TempDir::new().unwrap();
1031 let path = dir.path().join("clé-ключ-鍵-🔑.key");
1032 let key = UserKey::generate();
1033 let fp = key.fingerprint();
1034 key.save(&path).expect("save with unicode filename");
1035 assert_eq!(UserKey::load(&path).expect("load").fingerprint(), fp);
1036 }
1037
1038 #[test]
1041 fn save_refuses_existing_empty_file_untouched() {
1042 let dir = TempDir::new().unwrap();
1043 let path = dir.path().join("user.key");
1044 std::fs::write(&path, b"").unwrap();
1045
1046 let err = UserKey::generate().save(&path).expect_err("must refuse");
1047 assert!(matches!(err, MeshError::Io(_)));
1048 assert_eq!(
1049 std::fs::metadata(&path).unwrap().len(),
1050 0,
1051 "empty file must remain exactly as it was"
1052 );
1053 }
1054}