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