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 MeshError::Io(std::io::Error::new(
97 std::io::ErrorKind::AlreadyExists,
98 format!("refusing to overwrite existing key at {}", path.display()),
99 ))
100 } else {
101 MeshError::Io(e)
102 }
103 })?;
104 f.write_all(pem.as_bytes())?;
105 Ok(())
106 }
107
108 pub fn load(path: &Path) -> Result<Self> {
110 let pem = std::fs::read_to_string(path)?;
111 let signing =
112 SigningKey::from_pkcs8_pem(&pem).map_err(|e| MeshError::InvalidKey(e.to_string()))?;
113 Ok(Self { signing })
114 }
115}
116
117impl std::fmt::Debug for UserKey {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 f.debug_struct("UserKey")
121 .field("fingerprint", &self.fingerprint())
122 .finish_non_exhaustive()
123 }
124}
125
126impl Drop for UserKey {
127 fn drop(&mut self) {
128 let mut bytes = self.signing.to_bytes();
132 bytes.zeroize();
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct UserPublic {
140 #[serde(with = "verifying_key_serde")]
141 pub verifying: VerifyingKey,
142}
143
144impl UserPublic {
145 #[must_use]
148 pub fn fingerprint(&self) -> Fingerprint {
149 Fingerprint::of_bytes(self.verifying.as_bytes())
150 }
151
152 pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<()> {
155 self.verifying
156 .verify(message, signature)
157 .map_err(|_| MeshError::BadSignature)
158 }
159
160 #[must_use]
162 pub fn as_bytes(&self) -> [u8; 32] {
163 *self.verifying.as_bytes()
164 }
165}
166
167mod verifying_key_serde {
168 use ed25519_dalek::VerifyingKey;
169 use serde::{Deserialize, Deserializer, Serialize, Serializer};
170
171 pub fn serialize<S: Serializer>(key: &VerifyingKey, ser: S) -> Result<S::Ok, S::Error> {
172 let bytes: &[u8] = key.as_bytes();
173 bytes.serialize(ser)
174 }
175
176 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<VerifyingKey, D::Error> {
177 let bytes: Vec<u8> = Vec::deserialize(de)?;
178 if bytes.len() != 32 {
179 return Err(serde::de::Error::custom("expected 32 bytes"));
180 }
181 let mut arr = [0u8; 32];
182 arr.copy_from_slice(&bytes);
183 VerifyingKey::from_bytes(&arr).map_err(serde::de::Error::custom)
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use tempfile::TempDir;
191
192 #[test]
193 fn generate_different_keys() {
194 let a = UserKey::generate();
195 let b = UserKey::generate();
196 assert_ne!(
197 a.fingerprint(),
198 b.fingerprint(),
199 "two fresh keys must not collide"
200 );
201 }
202
203 #[test]
204 fn roundtrip_save_load_disk() {
205 let dir = TempDir::new().unwrap();
206 let path = dir.path().join("user.key");
207 let key = UserKey::generate();
208 let fp = key.fingerprint();
209 key.save(&path).expect("save");
210 let loaded = UserKey::load(&path).expect("load");
211 assert_eq!(loaded.fingerprint(), fp);
212 }
213
214 #[test]
215 fn save_refuses_overwrite() {
216 let dir = TempDir::new().unwrap();
217 let path = dir.path().join("user.key");
218 let key = UserKey::generate();
219 key.save(&path).expect("first save");
220 let key2 = UserKey::generate();
221 let err = key2.save(&path).expect_err("must refuse");
222 match err {
223 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
224 other => panic!("expected Io(AlreadyExists), got {other:?}"),
225 }
226 }
227
228 #[test]
229 fn save_creates_parent_directory() {
230 let dir = TempDir::new().unwrap();
231 let path = dir.path().join("nested").join("dir").join("user.key");
232 UserKey::generate().save(&path).expect("save with mkdir -p");
233 assert!(path.exists());
234 }
235
236 #[test]
237 #[cfg(unix)]
238 fn save_sets_0600_permissions() {
239 use std::os::unix::fs::PermissionsExt;
240 let dir = TempDir::new().unwrap();
249 let path = dir.path().join("user.key");
250 UserKey::generate().save(&path).expect("save");
251 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
252 assert_eq!(mode & 0o777, 0o600, "expected exactly 0600, got {mode:o}");
253 assert_eq!(mode & 0o077, 0, "no group/world bits, got {mode:o}");
256 }
257
258 #[test]
259 fn sign_verify() {
260 let key = UserKey::generate();
261 let pubk = key.public();
262 let msg = b"hello agent-mesh";
263 let sig = key.sign(msg);
264 pubk.verify(msg, &sig).expect("verify own signature");
265 }
266
267 #[test]
268 fn wrong_message_fails_verify() {
269 let key = UserKey::generate();
270 let pubk = key.public();
271 let sig = key.sign(b"original");
272 let err = pubk.verify(b"tampered", &sig).expect_err("must fail");
273 assert!(matches!(err, MeshError::BadSignature));
274 }
275
276 #[test]
277 fn fingerprint_stable_across_loads() {
278 let dir = TempDir::new().unwrap();
279 let path = dir.path().join("user.key");
280 let key = UserKey::generate();
281 let fp1 = key.fingerprint();
282 key.save(&path).unwrap();
283 drop(key);
284 let loaded = UserKey::load(&path).unwrap();
285 let fp2 = loaded.fingerprint();
286 assert_eq!(fp1, fp2);
287 }
288
289 #[test]
290 fn serde_roundtrip_public() {
291 let key = UserKey::generate();
292 let pubk = key.public();
293 let json = serde_json::to_string(&pubk).unwrap();
294 let parsed: UserPublic = serde_json::from_str(&json).unwrap();
295 assert_eq!(parsed, pubk);
296 assert_eq!(parsed.fingerprint(), pubk.fingerprint());
297 }
298
299 #[test]
300 fn public_as_bytes_is_32() {
301 let key = UserKey::generate();
302 let bytes = key.public().as_bytes();
303 assert_eq!(bytes.len(), 32);
304 }
305
306 #[test]
307 fn load_fails_on_missing_file() {
308 let dir = TempDir::new().unwrap();
309 let path = dir.path().join("nope.key");
310 let err = UserKey::load(&path).expect_err("must fail");
311 assert!(matches!(err, MeshError::Io(_)));
312 }
313
314 #[test]
315 fn load_fails_on_garbage_file() {
316 let dir = TempDir::new().unwrap();
317 let path = dir.path().join("garbage");
318 std::fs::write(&path, b"not a pem").unwrap();
319 let err = UserKey::load(&path).expect_err("must fail");
320 assert!(matches!(err, MeshError::InvalidKey(_)));
321 }
322
323 #[test]
344 #[cfg(unix)]
345 fn save_refuses_dangling_symlink_and_creates_nothing() {
346 let dir = TempDir::new().unwrap();
347 let target_dir = TempDir::new().unwrap();
348 let path = dir.path().join("user.key");
349 let target = target_dir.path().join("redirected.key");
350 std::os::unix::fs::symlink(&target, &path).unwrap();
351
352 let err = UserKey::generate()
353 .save(&path)
354 .expect_err("must refuse to save through a dangling symlink");
355 match err {
356 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
357 other => panic!("expected Io(AlreadyExists), got {other:?}"),
358 }
359 assert!(
360 !target.exists(),
361 "key must not be created at the symlink target"
362 );
363 let meta = std::fs::symlink_metadata(&path).expect("symlink still present");
364 assert!(
365 meta.file_type().is_symlink(),
366 "the planted symlink must be left untouched"
367 );
368 }
369
370 #[test]
379 #[cfg(unix)]
380 fn save_refuses_symlink_chain_and_creates_nothing() {
381 let dir = TempDir::new().unwrap();
382 let target_dir = TempDir::new().unwrap();
383 let path = dir.path().join("user.key");
384 let mid = dir.path().join("mid.link");
385 let target = target_dir.path().join("end.key");
386 std::os::unix::fs::symlink(&target, &mid).unwrap();
387 std::os::unix::fs::symlink(&mid, &path).unwrap();
388
389 let err = UserKey::generate()
390 .save(&path)
391 .expect_err("must refuse to save through a symlink chain");
392 assert!(matches!(err, MeshError::Io(_)));
393 assert!(
394 !target.exists(),
395 "key must not be created at the end of the chain"
396 );
397 }
398
399 #[test]
405 #[cfg(unix)]
406 fn save_refuses_symlink_to_existing_file_without_touching_target() {
407 use std::os::unix::fs::PermissionsExt;
408 let dir = TempDir::new().unwrap();
409 let target_dir = TempDir::new().unwrap();
410 let path = dir.path().join("user.key");
411 let target = target_dir.path().join("victim.txt");
412 std::fs::write(&target, b"victim content").unwrap();
413 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
414 std::os::unix::fs::symlink(&target, &path).unwrap();
415
416 let err = UserKey::generate()
417 .save(&path)
418 .expect_err("must refuse symlink to existing file");
419 assert!(matches!(err, MeshError::Io(_)));
420 assert_eq!(
421 std::fs::read(&target).unwrap(),
422 b"victim content",
423 "target content must be untouched"
424 );
425 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
426 assert_eq!(mode & 0o777, 0o644, "target mode must be untouched");
427 }
428
429 #[test]
442 fn save_concurrent_racers_exactly_one_wins() {
443 use std::sync::{Arc, Barrier};
444 const THREADS: usize = 8;
445 const ITERS: usize = 50;
446
447 for i in 0..ITERS {
448 let dir = TempDir::new().unwrap();
449 let path = Arc::new(dir.path().join(format!("user-{i}.key")));
450 let barrier = Arc::new(Barrier::new(THREADS));
451
452 let handles: Vec<_> = (0..THREADS)
453 .map(|_| {
454 let path = Arc::clone(&path);
455 let barrier = Arc::clone(&barrier);
456 std::thread::spawn(move || {
457 let key = UserKey::generate();
460 barrier.wait();
461 key.save(&path)
462 })
463 })
464 .collect();
465
466 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
467 let oks = results.iter().filter(|r| r.is_ok()).count();
468 assert_eq!(
469 oks, 1,
470 "exactly one concurrent save must win (iteration {i}), got {oks}"
471 );
472 for r in &results {
473 if let Err(MeshError::Io(e)) = r {
474 assert_eq!(
475 e.kind(),
476 std::io::ErrorKind::AlreadyExists,
477 "losers must see AlreadyExists"
478 );
479 }
480 }
481 UserKey::load(&path).expect("winner must have written a complete valid key");
484 #[cfg(unix)]
485 {
486 use std::os::unix::fs::PermissionsExt;
487 let mode = std::fs::metadata(&*path).unwrap().permissions().mode();
488 assert_eq!(mode & 0o077, 0, "winner file must have no group/world bits");
489 }
490 }
491 }
492
493 #[test]
507 #[cfg(unix)]
508 fn save_never_exposes_group_world_readable_window() {
509 use std::os::unix::fs::PermissionsExt;
510 use std::sync::atomic::{AtomicBool, Ordering};
511 use std::sync::Arc;
512 const ITERS: usize = 200;
513
514 let dir = TempDir::new().unwrap();
515 for i in 0..ITERS {
516 let path = Arc::new(dir.path().join(format!("probe-{i}.key")));
517 let stop = Arc::new(AtomicBool::new(false));
518 let saw_wide = Arc::new(AtomicBool::new(false));
519
520 let observer = {
521 let path = Arc::clone(&path);
522 let stop = Arc::clone(&stop);
523 let saw_wide = Arc::clone(&saw_wide);
524 std::thread::spawn(move || {
525 while !stop.load(Ordering::Relaxed) {
526 if let Ok(meta) = std::fs::symlink_metadata(&*path) {
527 if meta.permissions().mode() & 0o077 != 0 {
528 saw_wide.store(true, Ordering::Relaxed);
529 }
530 }
531 std::hint::spin_loop();
532 }
533 })
534 };
535
536 UserKey::generate().save(&path).expect("save");
537 stop.store(true, Ordering::Relaxed);
538 observer.join().unwrap();
539
540 assert!(
541 !saw_wide.load(Ordering::Relaxed),
542 "key file was observable with group/world bits (iteration {i})"
543 );
544 }
545 }
546
547 #[test]
551 fn save_refuse_overwrite_message_pins_path() {
552 let dir = TempDir::new().unwrap();
553 let path = dir.path().join("user.key");
554 UserKey::generate().save(&path).expect("first save");
555 let err = UserKey::generate().save(&path).expect_err("must refuse");
556 let msg = err.to_string();
557 assert!(
558 msg.contains(&format!(
559 "refusing to overwrite existing key at {}",
560 path.display()
561 )),
562 "unexpected error message: {msg}"
563 );
564 }
565
566 #[test]
571 fn save_refuse_overwrite_leaves_existing_content_untouched() {
572 let dir = TempDir::new().unwrap();
573 let path = dir.path().join("user.key");
574 std::fs::write(&path, b"pre-existing bytes, not even a key").unwrap();
575
576 let err = UserKey::generate().save(&path).expect_err("must refuse");
577 assert!(matches!(err, MeshError::Io(_)));
578 assert_eq!(
579 std::fs::read(&path).unwrap(),
580 b"pre-existing bytes, not even a key",
581 "existing file must not be truncated or rewritten"
582 );
583 }
584
585 #[test]
589 fn save_refuses_directory_at_path() {
590 let dir = TempDir::new().unwrap();
591 let path = dir.path().join("user.key");
592 std::fs::create_dir(&path).unwrap();
593 std::fs::write(path.join("inner.txt"), b"keep me").unwrap();
594
595 let err = UserKey::generate()
596 .save(&path)
597 .expect_err("must refuse dir");
598 assert!(matches!(err, MeshError::Io(_)));
599 assert!(path.is_dir(), "directory must survive");
600 assert_eq!(std::fs::read(path.join("inner.txt")).unwrap(), b"keep me");
601 }
602
603 #[test]
607 fn save_errors_when_parent_component_is_a_file() {
608 let dir = TempDir::new().unwrap();
609 let blocker = dir.path().join("blocker");
610 std::fs::write(&blocker, b"i am a file").unwrap();
611 let path = blocker.join("sub").join("user.key");
612
613 let err = UserKey::generate().save(&path).expect_err("must fail");
614 assert!(matches!(err, MeshError::Io(_)));
615 assert_eq!(
616 std::fs::read(&blocker).unwrap(),
617 b"i am a file",
618 "blocking file must be untouched"
619 );
620 }
621
622 #[test]
627 #[cfg(unix)]
628 fn save_errors_on_readonly_parent_dir() {
629 use std::os::unix::fs::PermissionsExt;
630 let dir = TempDir::new().unwrap();
631 let parent = dir.path().join("locked");
632 std::fs::create_dir(&parent).unwrap();
633 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap();
634
635 if std::fs::File::create(parent.join(".probe")).is_ok() {
638 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
639 eprintln!("skipping: running privileged, mode bits don't bind");
640 return;
641 }
642
643 let path = parent.join("user.key");
644 let err = UserKey::generate().save(&path).expect_err("must fail");
645 match &err {
646 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
647 other => panic!("expected Io(PermissionDenied), got {other:?}"),
648 }
649 assert!(!path.exists(), "no partial file may appear");
650 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
651 }
652
653 #[test]
659 #[cfg(unix)]
660 fn save_load_roundtrip_non_utf8_filename() {
661 use std::ffi::OsStr;
662 use std::os::unix::ffi::OsStrExt;
663 let dir = TempDir::new().unwrap();
664 let path = dir.path().join(OsStr::from_bytes(b"user-\xff\xfe.key"));
665
666 let key = UserKey::generate();
667 let fp = key.fingerprint();
668 key.save(&path).expect("save with non-UTF-8 filename");
669 let loaded = UserKey::load(&path).expect("load with non-UTF-8 filename");
670 assert_eq!(loaded.fingerprint(), fp);
671
672 let err = UserKey::generate().save(&path).expect_err("must refuse");
673 let msg = err.to_string();
674 assert!(
675 msg.contains("refusing to overwrite existing key at "),
676 "lossy display must still produce the refuse message: {msg}"
677 );
678 }
679
680 #[test]
696 #[cfg(unix)]
697 fn save_racing_symlink_plant_never_redirects_key() {
698 use std::sync::{Arc, Barrier};
699 const ITERS: usize = 400;
700
701 let dir = TempDir::new().unwrap();
702 for i in 0..ITERS {
703 let path = Arc::new(dir.path().join(format!("race-{i}.key")));
704 let target = Arc::new(dir.path().join(format!("target-{i}.key")));
705 let barrier = Arc::new(Barrier::new(2));
706
707 let saver = {
708 let path = Arc::clone(&path);
709 let barrier = Arc::clone(&barrier);
710 std::thread::spawn(move || {
711 let key = UserKey::generate();
712 barrier.wait();
713 key.save(&path)
714 })
715 };
716 let planter = {
717 let path = Arc::clone(&path);
718 let target = Arc::clone(&target);
719 let barrier = Arc::clone(&barrier);
720 std::thread::spawn(move || {
721 barrier.wait();
722 std::os::unix::fs::symlink(&*target, &*path)
723 })
724 };
725
726 let save_res = saver.join().unwrap();
727 let plant_res = planter.join().unwrap();
728
729 assert!(
730 !target.exists(),
731 "key must never be written through a planted symlink (iteration {i})"
732 );
733 assert!(
734 save_res.is_ok() != plant_res.is_ok(),
735 "exactly one of save/plant must win (iteration {i}): \
736 save={save_res:?} plant_ok={}",
737 plant_res.is_ok()
738 );
739 let meta = std::fs::symlink_metadata(&*path).unwrap();
740 if save_res.is_ok() {
741 assert!(
742 meta.file_type().is_file(),
743 "winner save must leave a regular file"
744 );
745 UserKey::load(&path).expect("saved key must be complete and loadable");
746 } else {
747 assert!(
748 meta.file_type().is_symlink(),
749 "winner plant must leave the symlink"
750 );
751 if let Err(MeshError::Io(e)) = &save_res {
752 assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
753 } else {
754 panic!("losing save must be Io(AlreadyExists), got {save_res:?}");
755 }
756 }
757 }
758 }
759
760 #[test]
771 #[cfg(unix)]
772 fn save_refuses_self_loop_symlink_with_already_exists() {
773 let dir = TempDir::new().unwrap();
774 let path = dir.path().join("loop.key");
775 std::os::unix::fs::symlink(&path, &path).unwrap();
776
777 let err = UserKey::generate().save(&path).expect_err("must refuse");
778 match &err {
779 MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
780 other => panic!("expected Io(AlreadyExists), got {other:?}"),
781 }
782 assert!(
783 err.to_string()
784 .contains("refusing to overwrite existing key at "),
785 "self-loop must surface the standard refusal, got: {err}"
786 );
787 }
788
789 #[test]
794 fn save_concurrent_distinct_paths_shared_new_parent_all_win() {
795 use std::sync::{Arc, Barrier};
796 const THREADS: usize = 8;
797
798 let dir = TempDir::new().unwrap();
799 let parent = Arc::new(dir.path().join("deep").join("nest"));
800 let barrier = Arc::new(Barrier::new(THREADS));
801
802 let handles: Vec<_> = (0..THREADS)
803 .map(|t| {
804 let parent = Arc::clone(&parent);
805 let barrier = Arc::clone(&barrier);
806 std::thread::spawn(move || {
807 let key = UserKey::generate();
808 let fp = key.fingerprint();
809 let path = parent.join(format!("k{t}.key"));
810 barrier.wait();
811 key.save(&path).expect("distinct-path save must succeed");
812 (path, fp)
813 })
814 })
815 .collect();
816
817 for h in handles {
818 let (path, fp) = h.join().unwrap();
819 let loaded = UserKey::load(&path).expect("each saved key must load");
820 assert_eq!(loaded.fingerprint(), fp);
821 #[cfg(unix)]
822 {
823 use std::os::unix::fs::PermissionsExt;
824 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
825 assert_eq!(mode & 0o777, 0o600, "expected 0600, got {mode:o}");
826 }
827 }
828 }
829
830 #[test]
836 #[cfg(unix)]
837 fn save_name_too_long_is_plain_io_error_not_refusal() {
838 let dir = TempDir::new().unwrap();
839 let path = dir.path().join("k".repeat(300));
840
841 let err = UserKey::generate().save(&path).expect_err("must fail");
842 match &err {
843 MeshError::Io(e) => {
844 assert_ne!(e.kind(), std::io::ErrorKind::AlreadyExists);
845 }
846 other => panic!("expected Io, got {other:?}"),
847 }
848 assert!(
849 !err.to_string().contains("refusing to overwrite"),
850 "ENAMETOOLONG must not masquerade as the overwrite refusal: {err}"
851 );
852 }
853
854 #[test]
860 fn saved_pem_is_strict_pkcs8_with_lf_endings() {
861 let dir = TempDir::new().unwrap();
862 let path = dir.path().join("user.key");
863 let key = UserKey::generate();
864 let fp = key.fingerprint();
865 key.save(&path).expect("save");
866
867 let bytes = std::fs::read(&path).unwrap();
868 assert!(
869 bytes.starts_with(b"-----BEGIN PRIVATE KEY-----\n"),
870 "PEM header missing or not LF-terminated"
871 );
872 assert!(
873 bytes.ends_with(b"-----END PRIVATE KEY-----\n"),
874 "PEM trailer missing or not LF-terminated"
875 );
876 assert!(!bytes.contains(&b'\r'), "PEM must use LF only, found CR");
877 assert!(
878 bytes.len() < 512,
879 "one ed25519 PKCS#8 PEM expected, got {} bytes (double write?)",
880 bytes.len()
881 );
882 let pem = String::from_utf8(bytes).expect("PEM is ASCII");
883 let signing = SigningKey::from_pkcs8_pem(&pem).expect("strict PKCS#8 parse");
884 assert_eq!(
885 Fingerprint::of_bytes(signing.verifying_key().as_bytes()),
886 fp,
887 "parsed key must match the saved key"
888 );
889 }
890
891 #[test]
894 fn save_load_roundtrip_unicode_filename() {
895 let dir = TempDir::new().unwrap();
896 let path = dir.path().join("clé-ключ-鍵-🔑.key");
897 let key = UserKey::generate();
898 let fp = key.fingerprint();
899 key.save(&path).expect("save with unicode filename");
900 assert_eq!(UserKey::load(&path).expect("load").fingerprint(), fp);
901 }
902
903 #[test]
906 fn save_refuses_existing_empty_file_untouched() {
907 let dir = TempDir::new().unwrap();
908 let path = dir.path().join("user.key");
909 std::fs::write(&path, b"").unwrap();
910
911 let err = UserKey::generate().save(&path).expect_err("must refuse");
912 assert!(matches!(err, MeshError::Io(_)));
913 assert_eq!(
914 std::fs::metadata(&path).unwrap().len(),
915 0,
916 "empty file must remain exactly as it was"
917 );
918 }
919}