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 #[must_use]
31 pub fn generate() -> Self {
32 let mut csprng = OsRng;
33 let signing = SigningKey::generate(&mut csprng);
34 Self { signing }
35 }
36
37 #[must_use]
39 pub fn public(&self) -> UserPublic {
40 UserPublic {
41 verifying: self.signing.verifying_key(),
42 }
43 }
44
45 #[must_use]
47 pub fn fingerprint(&self) -> Fingerprint {
48 self.public().fingerprint()
49 }
50
51 pub fn sign(&self, message: &[u8]) -> Signature {
56 self.signing.sign(message)
57 }
58
59 pub fn save(&self, path: &Path) -> Result<()> {
68 if let Some(parent) = path.parent() {
69 if !parent.as_os_str().is_empty() {
70 std::fs::create_dir_all(parent)?;
71 }
72 }
73 let pem = self
74 .signing
75 .to_pkcs8_pem(LineEnding::LF)
76 .map_err(|e| MeshError::InvalidKey(e.to_string()))?;
77
78 use std::io::Write;
79 #[cfg(unix)]
80 use std::os::unix::fs::OpenOptionsExt;
81
82 let mut opts = std::fs::OpenOptions::new();
83 opts.write(true).create_new(true);
87 #[cfg(unix)]
92 opts.mode(0o600);
93
94 let mut f = opts.open(path).map_err(|e| {
95 if e.kind() == std::io::ErrorKind::AlreadyExists {
96 refuse_overwrite_error(path)
97 } else {
98 MeshError::Io(e)
99 }
100 })?;
101 f.write_all(pem.as_bytes())?;
102 Ok(())
103 }
104
105 pub fn load(path: &Path) -> Result<Self> {
107 let pem = std::fs::read_to_string(path)?;
108 let signing =
109 SigningKey::from_pkcs8_pem(&pem).map_err(|e| MeshError::InvalidKey(e.to_string()))?;
110 Ok(Self { signing })
111 }
112}
113
114fn refuse_overwrite_error(path: &Path) -> MeshError {
120 MeshError::Io(std::io::Error::new(
121 std::io::ErrorKind::AlreadyExists,
122 format!("refusing to overwrite existing key at {}", path.display()),
123 ))
124}
125
126impl std::fmt::Debug for UserKey {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.debug_struct("UserKey")
130 .field("fingerprint", &self.fingerprint())
131 .finish_non_exhaustive()
132 }
133}
134
135impl Drop for UserKey {
136 fn drop(&mut self) {
137 let mut bytes = self.signing.to_bytes();
141 bytes.zeroize();
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct UserPublic {
149 #[serde(with = "verifying_key_serde")]
150 pub verifying: VerifyingKey,
151}
152
153impl UserPublic {
154 #[must_use]
157 pub fn fingerprint(&self) -> Fingerprint {
158 Fingerprint::of_bytes(self.verifying.as_bytes())
159 }
160
161 pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<()> {
164 self.verifying
165 .verify(message, signature)
166 .map_err(|_| MeshError::BadSignature)
167 }
168
169 #[must_use]
171 pub fn as_bytes(&self) -> [u8; 32] {
172 *self.verifying.as_bytes()
173 }
174}
175
176mod verifying_key_serde {
177 use ed25519_dalek::VerifyingKey;
178 use serde::{Deserialize, Deserializer, Serialize, Serializer};
179
180 pub fn serialize<S: Serializer>(key: &VerifyingKey, ser: S) -> Result<S::Ok, S::Error> {
181 let bytes: &[u8] = key.as_bytes();
182 bytes.serialize(ser)
183 }
184
185 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<VerifyingKey, D::Error> {
186 let bytes: Vec<u8> = Vec::deserialize(de)?;
187 if bytes.len() != 32 {
188 return Err(serde::de::Error::custom("expected 32 bytes"));
189 }
190 let mut arr = [0u8; 32];
191 arr.copy_from_slice(&bytes);
192 VerifyingKey::from_bytes(&arr).map_err(serde::de::Error::custom)
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use tempfile::TempDir;
200
201 #[test]
202 fn generate_different_keys() {
203 let a = UserKey::generate();
204 let b = UserKey::generate();
205 assert_ne!(
206 a.fingerprint(),
207 b.fingerprint(),
208 "two fresh keys must not collide"
209 );
210 }
211
212 #[test]
213 fn roundtrip_save_load_disk() {
214 let dir = TempDir::new().unwrap();
215 let path = dir.path().join("user.key");
216 let key = UserKey::generate();
217 let fp = key.fingerprint();
218 key.save(&path).expect("save");
219 let loaded = UserKey::load(&path).expect("load");
220 assert_eq!(loaded.fingerprint(), fp);
221 }
222
223 #[test]
224 fn save_refuses_overwrite() {
225 let dir = TempDir::new().unwrap();
226 let path = dir.path().join("user.key");
227 let key = UserKey::generate();
228 key.save(&path).expect("first save");
229 let key2 = UserKey::generate();
230 let err = key2.save(&path).expect_err("must refuse");
231 match err {
232 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
233 other => panic!("expected Io(AlreadyExists), got {other:?}"),
234 }
235 }
236
237 #[test]
238 fn save_creates_parent_directory() {
239 let dir = TempDir::new().unwrap();
240 let path = dir.path().join("nested").join("dir").join("user.key");
241 UserKey::generate().save(&path).expect("save with mkdir -p");
242 assert!(path.exists());
243 }
244
245 #[test]
246 #[cfg(unix)]
247 fn save_sets_0600_permissions() {
248 use std::os::unix::fs::PermissionsExt;
249 let dir = TempDir::new().unwrap();
258 let path = dir.path().join("user.key");
259 UserKey::generate().save(&path).expect("save");
260 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
261 assert_eq!(mode & 0o777, 0o600, "expected exactly 0600, got {mode:o}");
262 assert_eq!(mode & 0o077, 0, "no group/world bits, got {mode:o}");
265 }
266
267 #[test]
268 fn sign_verify() {
269 let key = UserKey::generate();
270 let pubk = key.public();
271 let msg = b"hello agent-mesh";
272 let sig = key.sign(msg);
273 pubk.verify(msg, &sig).expect("verify own signature");
274 }
275
276 #[test]
277 fn wrong_message_fails_verify() {
278 let key = UserKey::generate();
279 let pubk = key.public();
280 let sig = key.sign(b"original");
281 let err = pubk.verify(b"tampered", &sig).expect_err("must fail");
282 assert!(matches!(err, MeshError::BadSignature));
283 }
284
285 #[test]
286 fn fingerprint_stable_across_loads() {
287 let dir = TempDir::new().unwrap();
288 let path = dir.path().join("user.key");
289 let key = UserKey::generate();
290 let fp1 = key.fingerprint();
291 key.save(&path).unwrap();
292 drop(key);
293 let loaded = UserKey::load(&path).unwrap();
294 let fp2 = loaded.fingerprint();
295 assert_eq!(fp1, fp2);
296 }
297
298 #[test]
299 fn serde_roundtrip_public() {
300 let key = UserKey::generate();
301 let pubk = key.public();
302 let json = serde_json::to_string(&pubk).unwrap();
303 let parsed: UserPublic = serde_json::from_str(&json).unwrap();
304 assert_eq!(parsed, pubk);
305 assert_eq!(parsed.fingerprint(), pubk.fingerprint());
306 }
307
308 #[test]
309 fn public_as_bytes_is_32() {
310 let key = UserKey::generate();
311 let bytes = key.public().as_bytes();
312 assert_eq!(bytes.len(), 32);
313 }
314
315 #[test]
316 fn load_fails_on_missing_file() {
317 let dir = TempDir::new().unwrap();
318 let path = dir.path().join("nope.key");
319 let err = UserKey::load(&path).expect_err("must fail");
320 assert!(matches!(err, MeshError::Io(_)));
321 }
322
323 #[test]
324 fn load_fails_on_garbage_file() {
325 let dir = TempDir::new().unwrap();
326 let path = dir.path().join("garbage");
327 std::fs::write(&path, b"not a pem").unwrap();
328 let err = UserKey::load(&path).expect_err("must fail");
329 assert!(matches!(err, MeshError::InvalidKey(_)));
330 }
331
332 #[test]
353 #[cfg(unix)]
354 fn save_refuses_dangling_symlink_and_creates_nothing() {
355 let dir = TempDir::new().unwrap();
356 let target_dir = TempDir::new().unwrap();
357 let path = dir.path().join("user.key");
358 let target = target_dir.path().join("redirected.key");
359 std::os::unix::fs::symlink(&target, &path).unwrap();
360
361 let err = UserKey::generate()
362 .save(&path)
363 .expect_err("must refuse to save through a dangling symlink");
364 match err {
365 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
366 other => panic!("expected Io(AlreadyExists), got {other:?}"),
367 }
368 assert!(
369 !target.exists(),
370 "key must not be created at the symlink target"
371 );
372 let meta = std::fs::symlink_metadata(&path).expect("symlink still present");
373 assert!(
374 meta.file_type().is_symlink(),
375 "the planted symlink must be left untouched"
376 );
377 }
378
379 #[test]
388 #[cfg(unix)]
389 fn save_refuses_symlink_chain_and_creates_nothing() {
390 let dir = TempDir::new().unwrap();
391 let target_dir = TempDir::new().unwrap();
392 let path = dir.path().join("user.key");
393 let mid = dir.path().join("mid.link");
394 let target = target_dir.path().join("end.key");
395 std::os::unix::fs::symlink(&target, &mid).unwrap();
396 std::os::unix::fs::symlink(&mid, &path).unwrap();
397
398 let err = UserKey::generate()
399 .save(&path)
400 .expect_err("must refuse to save through a symlink chain");
401 assert!(matches!(err, MeshError::Io(_)));
402 assert!(
403 !target.exists(),
404 "key must not be created at the end of the chain"
405 );
406 }
407
408 #[test]
414 #[cfg(unix)]
415 fn save_refuses_symlink_to_existing_file_without_touching_target() {
416 use std::os::unix::fs::PermissionsExt;
417 let dir = TempDir::new().unwrap();
418 let target_dir = TempDir::new().unwrap();
419 let path = dir.path().join("user.key");
420 let target = target_dir.path().join("victim.txt");
421 std::fs::write(&target, b"victim content").unwrap();
422 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
423 std::os::unix::fs::symlink(&target, &path).unwrap();
424
425 let err = UserKey::generate()
426 .save(&path)
427 .expect_err("must refuse symlink to existing file");
428 assert!(matches!(err, MeshError::Io(_)));
429 assert_eq!(
430 std::fs::read(&target).unwrap(),
431 b"victim content",
432 "target content must be untouched"
433 );
434 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
435 assert_eq!(mode & 0o777, 0o644, "target mode must be untouched");
436 }
437
438 #[test]
451 fn save_concurrent_racers_exactly_one_wins() {
452 use std::sync::{Arc, Barrier};
453 const THREADS: usize = 8;
454 const ITERS: usize = 50;
455
456 for i in 0..ITERS {
457 let dir = TempDir::new().unwrap();
458 let path = Arc::new(dir.path().join(format!("user-{i}.key")));
459 let barrier = Arc::new(Barrier::new(THREADS));
460
461 let handles: Vec<_> = (0..THREADS)
462 .map(|_| {
463 let path = Arc::clone(&path);
464 let barrier = Arc::clone(&barrier);
465 std::thread::spawn(move || {
466 let key = UserKey::generate();
469 barrier.wait();
470 key.save(&path)
471 })
472 })
473 .collect();
474
475 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
476 let oks = results.iter().filter(|r| r.is_ok()).count();
477 assert_eq!(
478 oks, 1,
479 "exactly one concurrent save must win (iteration {i}), got {oks}"
480 );
481 for r in &results {
482 if let Err(MeshError::Io(e)) = r {
483 assert_eq!(
484 e.kind(),
485 std::io::ErrorKind::AlreadyExists,
486 "losers must see AlreadyExists"
487 );
488 }
489 }
490 UserKey::load(&path).expect("winner must have written a complete valid key");
493 #[cfg(unix)]
494 {
495 use std::os::unix::fs::PermissionsExt;
496 let mode = std::fs::metadata(&*path).unwrap().permissions().mode();
497 assert_eq!(mode & 0o077, 0, "winner file must have no group/world bits");
498 }
499 }
500 }
501
502 #[test]
516 #[cfg(unix)]
517 fn save_never_exposes_group_world_readable_window() {
518 use std::os::unix::fs::PermissionsExt;
519 use std::sync::atomic::{AtomicBool, Ordering};
520 use std::sync::Arc;
521 const ITERS: usize = 200;
522
523 let dir = TempDir::new().unwrap();
524 for i in 0..ITERS {
525 let path = Arc::new(dir.path().join(format!("probe-{i}.key")));
526 let stop = Arc::new(AtomicBool::new(false));
527 let saw_wide = Arc::new(AtomicBool::new(false));
528
529 let observer = {
530 let path = Arc::clone(&path);
531 let stop = Arc::clone(&stop);
532 let saw_wide = Arc::clone(&saw_wide);
533 std::thread::spawn(move || {
534 while !stop.load(Ordering::Relaxed) {
535 if let Ok(meta) = std::fs::symlink_metadata(&*path) {
536 if meta.permissions().mode() & 0o077 != 0 {
537 saw_wide.store(true, Ordering::Relaxed);
538 }
539 }
540 std::hint::spin_loop();
541 }
542 })
543 };
544
545 UserKey::generate().save(&path).expect("save");
546 stop.store(true, Ordering::Relaxed);
547 observer.join().unwrap();
548
549 assert!(
550 !saw_wide.load(Ordering::Relaxed),
551 "key file was observable with group/world bits (iteration {i})"
552 );
553 }
554 }
555
556 #[test]
560 fn save_refuse_overwrite_message_pins_path() {
561 let dir = TempDir::new().unwrap();
562 let path = dir.path().join("user.key");
563 UserKey::generate().save(&path).expect("first save");
564 let err = UserKey::generate().save(&path).expect_err("must refuse");
565 let msg = err.to_string();
566 assert!(
567 msg.contains(&format!(
568 "refusing to overwrite existing key at {}",
569 path.display()
570 )),
571 "unexpected error message: {msg}"
572 );
573 }
574
575 #[test]
580 fn save_refuse_overwrite_leaves_existing_content_untouched() {
581 let dir = TempDir::new().unwrap();
582 let path = dir.path().join("user.key");
583 std::fs::write(&path, b"pre-existing bytes, not even a key").unwrap();
584
585 let err = UserKey::generate().save(&path).expect_err("must refuse");
586 assert!(matches!(err, MeshError::Io(_)));
587 assert_eq!(
588 std::fs::read(&path).unwrap(),
589 b"pre-existing bytes, not even a key",
590 "existing file must not be truncated or rewritten"
591 );
592 }
593
594 #[test]
598 fn save_refuses_directory_at_path() {
599 let dir = TempDir::new().unwrap();
600 let path = dir.path().join("user.key");
601 std::fs::create_dir(&path).unwrap();
602 std::fs::write(path.join("inner.txt"), b"keep me").unwrap();
603
604 let err = UserKey::generate()
605 .save(&path)
606 .expect_err("must refuse dir");
607 assert!(matches!(err, MeshError::Io(_)));
608 assert!(path.is_dir(), "directory must survive");
609 assert_eq!(std::fs::read(path.join("inner.txt")).unwrap(), b"keep me");
610 }
611
612 #[test]
616 fn save_errors_when_parent_component_is_a_file() {
617 let dir = TempDir::new().unwrap();
618 let blocker = dir.path().join("blocker");
619 std::fs::write(&blocker, b"i am a file").unwrap();
620 let path = blocker.join("sub").join("user.key");
621
622 let err = UserKey::generate().save(&path).expect_err("must fail");
623 assert!(matches!(err, MeshError::Io(_)));
624 assert_eq!(
625 std::fs::read(&blocker).unwrap(),
626 b"i am a file",
627 "blocking file must be untouched"
628 );
629 }
630
631 #[test]
636 #[cfg(unix)]
637 fn save_errors_on_readonly_parent_dir() {
638 use std::os::unix::fs::PermissionsExt;
639 let dir = TempDir::new().unwrap();
640 let parent = dir.path().join("locked");
641 std::fs::create_dir(&parent).unwrap();
642 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap();
643
644 if std::fs::File::create(parent.join(".probe")).is_ok() {
647 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
648 eprintln!("skipping: running privileged, mode bits don't bind");
649 return;
650 }
651
652 let path = parent.join("user.key");
653 let err = UserKey::generate().save(&path).expect_err("must fail");
654 match &err {
655 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
656 other => panic!("expected Io(PermissionDenied), got {other:?}"),
657 }
658 assert!(!path.exists(), "no partial file may appear");
659 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
660 }
661
662 #[test]
674 #[cfg(all(unix, not(target_os = "macos")))]
675 fn save_load_roundtrip_non_utf8_filename() {
676 use std::ffi::OsStr;
677 use std::os::unix::ffi::OsStrExt;
678 let dir = TempDir::new().unwrap();
679 let path = dir.path().join(OsStr::from_bytes(b"user-\xff\xfe.key"));
680
681 match std::fs::File::create(&path) {
689 Ok(_) => std::fs::remove_file(&path).expect("remove probe file"),
690 Err(e) => {
691 eprintln!(
692 "skipping: filesystem rejects non-UTF-8 filenames \
693 (e.g. macOS APFS EILSEQ): {e}"
694 );
695 return;
696 }
697 }
698
699 let key = UserKey::generate();
700 let fp = key.fingerprint();
701 key.save(&path).expect("save with non-UTF-8 filename");
702 let loaded = UserKey::load(&path).expect("load with non-UTF-8 filename");
703 assert_eq!(loaded.fingerprint(), fp);
704
705 let err = UserKey::generate().save(&path).expect_err("must refuse");
706 let msg = err.to_string();
707 assert!(
708 msg.contains("refusing to overwrite existing key at "),
709 "lossy display must still produce the refuse message: {msg}"
710 );
711 }
712
713 #[test]
729 #[cfg(target_os = "macos")]
730 fn save_load_roundtrip_unusual_unicode_filename() {
731 let dir = TempDir::new().unwrap();
732 let path = dir.path().join("user-caf\u{00e9}-\u{1f511}-a\u{0301}.key");
736
737 let key = UserKey::generate();
738 let fp = key.fingerprint();
739 key.save(&path)
740 .expect("save with an unusual Unicode filename");
741 let loaded = UserKey::load(&path).expect("load with an unusual Unicode 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 "display must still produce the refuse message for a Unicode path: {msg}"
749 );
750 }
751
752 #[test]
760 #[cfg(unix)]
761 fn refuse_overwrite_message_is_lossy_for_a_non_utf8_path() {
762 use std::ffi::OsStr;
763 use std::os::unix::ffi::OsStrExt;
764 let path = Path::new(OsStr::from_bytes(b"user-\xff\xfe.key"));
765 let msg = refuse_overwrite_error(path).to_string();
766 assert!(
767 msg.contains("refusing to overwrite existing key at "),
768 "the refuse message must survive a non-UTF-8 path: {msg}"
769 );
770 assert!(
771 msg.contains('\u{FFFD}'),
772 "non-UTF-8 bytes must render as the U+FFFD replacement char (lossy), not panic: {msg}"
773 );
774 }
775
776 #[test]
792 #[cfg(unix)]
793 fn save_racing_symlink_plant_never_redirects_key() {
794 use std::sync::{Arc, Barrier};
795 const ITERS: usize = 400;
796
797 let dir = TempDir::new().unwrap();
798 for i in 0..ITERS {
799 let path = Arc::new(dir.path().join(format!("race-{i}.key")));
800 let target = Arc::new(dir.path().join(format!("target-{i}.key")));
801 let barrier = Arc::new(Barrier::new(2));
802
803 let saver = {
804 let path = Arc::clone(&path);
805 let barrier = Arc::clone(&barrier);
806 std::thread::spawn(move || {
807 let key = UserKey::generate();
808 barrier.wait();
809 key.save(&path)
810 })
811 };
812 let planter = {
813 let path = Arc::clone(&path);
814 let target = Arc::clone(&target);
815 let barrier = Arc::clone(&barrier);
816 std::thread::spawn(move || {
817 barrier.wait();
818 std::os::unix::fs::symlink(&*target, &*path)
819 })
820 };
821
822 let save_res = saver.join().unwrap();
823 let plant_res = planter.join().unwrap();
824
825 assert!(
826 !target.exists(),
827 "key must never be written through a planted symlink (iteration {i})"
828 );
829 assert!(
830 save_res.is_ok() != plant_res.is_ok(),
831 "exactly one of save/plant must win (iteration {i}): \
832 save={save_res:?} plant_ok={}",
833 plant_res.is_ok()
834 );
835 let meta = std::fs::symlink_metadata(&*path).unwrap();
836 if save_res.is_ok() {
837 assert!(
838 meta.file_type().is_file(),
839 "winner save must leave a regular file"
840 );
841 UserKey::load(&path).expect("saved key must be complete and loadable");
842 } else {
843 assert!(
844 meta.file_type().is_symlink(),
845 "winner plant must leave the symlink"
846 );
847 if let Err(MeshError::Io(e)) = &save_res {
848 assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
849 } else {
850 panic!("losing save must be Io(AlreadyExists), got {save_res:?}");
851 }
852 }
853 }
854 }
855
856 #[test]
867 #[cfg(unix)]
868 fn save_refuses_self_loop_symlink_with_already_exists() {
869 let dir = TempDir::new().unwrap();
870 let path = dir.path().join("loop.key");
871 std::os::unix::fs::symlink(&path, &path).unwrap();
872
873 let err = UserKey::generate().save(&path).expect_err("must refuse");
874 match &err {
875 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
876 other => panic!("expected Io(AlreadyExists), got {other:?}"),
877 }
878 assert!(
879 err.to_string()
880 .contains("refusing to overwrite existing key at "),
881 "self-loop must surface the standard refusal, got: {err}"
882 );
883 }
884
885 #[test]
890 fn save_concurrent_distinct_paths_shared_new_parent_all_win() {
891 use std::sync::{Arc, Barrier};
892 const THREADS: usize = 8;
893
894 let dir = TempDir::new().unwrap();
895 let parent = Arc::new(dir.path().join("deep").join("nest"));
896 let barrier = Arc::new(Barrier::new(THREADS));
897
898 let handles: Vec<_> = (0..THREADS)
899 .map(|t| {
900 let parent = Arc::clone(&parent);
901 let barrier = Arc::clone(&barrier);
902 std::thread::spawn(move || {
903 let key = UserKey::generate();
904 let fp = key.fingerprint();
905 let path = parent.join(format!("k{t}.key"));
906 barrier.wait();
907 key.save(&path).expect("distinct-path save must succeed");
908 (path, fp)
909 })
910 })
911 .collect();
912
913 for h in handles {
914 let (path, fp) = h.join().unwrap();
915 let loaded = UserKey::load(&path).expect("each saved key must load");
916 assert_eq!(loaded.fingerprint(), fp);
917 #[cfg(unix)]
918 {
919 use std::os::unix::fs::PermissionsExt;
920 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
921 assert_eq!(mode & 0o777, 0o600, "expected 0600, got {mode:o}");
922 }
923 }
924 }
925
926 #[test]
932 #[cfg(unix)]
933 fn save_name_too_long_is_plain_io_error_not_refusal() {
934 let dir = TempDir::new().unwrap();
935 let path = dir.path().join("k".repeat(300));
936
937 let err = UserKey::generate().save(&path).expect_err("must fail");
938 match &err {
939 MeshError::Io(e) => {
940 assert_ne!(e.kind(), std::io::ErrorKind::AlreadyExists);
941 }
942 other => panic!("expected Io, got {other:?}"),
943 }
944 assert!(
945 !err.to_string().contains("refusing to overwrite"),
946 "ENAMETOOLONG must not masquerade as the overwrite refusal: {err}"
947 );
948 }
949
950 #[test]
956 fn saved_pem_is_strict_pkcs8_with_lf_endings() {
957 let dir = TempDir::new().unwrap();
958 let path = dir.path().join("user.key");
959 let key = UserKey::generate();
960 let fp = key.fingerprint();
961 key.save(&path).expect("save");
962
963 let bytes = std::fs::read(&path).unwrap();
964 assert!(
965 bytes.starts_with(b"-----BEGIN PRIVATE KEY-----\n"),
966 "PEM header missing or not LF-terminated"
967 );
968 assert!(
969 bytes.ends_with(b"-----END PRIVATE KEY-----\n"),
970 "PEM trailer missing or not LF-terminated"
971 );
972 assert!(!bytes.contains(&b'\r'), "PEM must use LF only, found CR");
973 assert!(
974 bytes.len() < 512,
975 "one ed25519 PKCS#8 PEM expected, got {} bytes (double write?)",
976 bytes.len()
977 );
978 let pem = String::from_utf8(bytes).expect("PEM is ASCII");
979 let signing = SigningKey::from_pkcs8_pem(&pem).expect("strict PKCS#8 parse");
980 assert_eq!(
981 Fingerprint::of_bytes(signing.verifying_key().as_bytes()),
982 fp,
983 "parsed key must match the saved key"
984 );
985 }
986
987 #[test]
990 fn save_load_roundtrip_unicode_filename() {
991 let dir = TempDir::new().unwrap();
992 let path = dir.path().join("clé-ключ-鍵-🔑.key");
993 let key = UserKey::generate();
994 let fp = key.fingerprint();
995 key.save(&path).expect("save with unicode filename");
996 assert_eq!(UserKey::load(&path).expect("load").fingerprint(), fp);
997 }
998
999 #[test]
1002 fn save_refuses_existing_empty_file_untouched() {
1003 let dir = TempDir::new().unwrap();
1004 let path = dir.path().join("user.key");
1005 std::fs::write(&path, b"").unwrap();
1006
1007 let err = UserKey::generate().save(&path).expect_err("must refuse");
1008 assert!(matches!(err, MeshError::Io(_)));
1009 assert_eq!(
1010 std::fs::metadata(&path).unwrap().len(),
1011 0,
1012 "empty file must remain exactly as it was"
1013 );
1014 }
1015}