Skip to main content

agent_mesh_protocol/
user_key.rs

1//! [`UserKey`] — the per-user root of trust.
2//!
3//! Every agent-mesh participant has exactly one `UserKey` (an ed25519
4//! keypair). All other identities — agent keys, GitHub bindings —
5//! derive their authority from this one signature. The private half
6//! lives on disk in PKCS#8 PEM with `0600` permissions; the public
7//! half is what peers compare against [`Fingerprint`]s.
8
9use 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
19/// A user-level ed25519 keypair. Root of trust for an agent mesh.
20///
21/// The private half is held in memory by this struct and zeroized on
22/// drop. Use [`save`](Self::save) to persist to disk (refuses to
23/// overwrite existing files) and [`load`](Self::load) to rehydrate.
24pub struct UserKey {
25    signing: SigningKey,
26}
27
28impl UserKey {
29    /// Generate a fresh user key from the operating system RNG.
30    #[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    /// Public verifying half of the key — safe to share with peers.
38    #[must_use]
39    pub fn public(&self) -> UserPublic {
40        UserPublic {
41            verifying: self.signing.verifying_key(),
42        }
43    }
44
45    /// BLAKE3 fingerprint of the public key bytes.
46    #[must_use]
47    pub fn fingerprint(&self) -> Fingerprint {
48        self.public().fingerprint()
49    }
50
51    /// Sign an arbitrary message with the user's root key.
52    ///
53    /// In practice this is called sparingly — typically just to
54    /// issue agent certificates and the one-time GitHub binding.
55    pub fn sign(&self, message: &[u8]) -> Signature {
56        self.signing.sign(message)
57    }
58
59    /// Save the private key to disk in PKCS#8 PEM format.
60    ///
61    /// Refuses to overwrite an existing file (returns
62    /// [`MeshError::Io`] with `AlreadyExists`). On Unix systems the
63    /// file is created `0600` *atomically* — the key is never
64    /// group/world-readable for any window, not even between create
65    /// and the first byte written. The parent directory is created if
66    /// it doesn't exist.
67    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        // `create_new(true)` folds in the old `path.exists()` guard:
84        // it returns `AlreadyExists` rather than truncating an
85        // existing key, preserving the refuse-to-overwrite contract.
86        opts.write(true).create_new(true);
87        // On Unix the file is born at 0600 — there is no umask-default
88        // (e.g. 0644) window where the key bytes are readable by
89        // group/world. Non-unix has no mode support, so it falls back
90        // to a plain create, unchanged from before.
91        #[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    /// Load a private key previously written by [`save`](Self::save).
106    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
114/// The `AlreadyExists` refusal [`save`](UserKey::save) returns rather than
115/// overwrite an existing key. Formats `path` via `Path::display()`, which is
116/// **lossy** for a non-UTF-8 path (the offending bytes render as U+FFFD) and
117/// never panics — the single source of truth for that message, so the formatting
118/// contract can be tested without forcing an illegal filename onto the disk.
119fn 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        // Deliberately do not print the private key bytes.
129        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        // Best-effort zeroize of the in-memory keypair. The dalek
138        // type itself zeroizes on drop too, but we explicitly scrub
139        // the byte copy we hand back to ourselves.
140        let mut bytes = self.signing.to_bytes();
141        bytes.zeroize();
142    }
143}
144
145/// Public verifying half of a [`UserKey`]. Cheap to clone, safe to
146/// share, and the thing peers actually exchange.
147#[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    /// BLAKE3 fingerprint of the underlying 32-byte ed25519 public
155    /// key.
156    #[must_use]
157    pub fn fingerprint(&self) -> Fingerprint {
158        Fingerprint::of_bytes(self.verifying.as_bytes())
159    }
160
161    /// Verify a signature was produced by this user's private key
162    /// over `message`.
163    pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<()> {
164        self.verifying
165            .verify(message, signature)
166            .map_err(|_| MeshError::BadSignature)
167    }
168
169    /// Raw 32-byte ed25519 public key.
170    #[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        // The key is created 0600 *atomically* (see `save`): the file
250        // is born with these bits via `OpenOptions::mode`, never at a
251        // umask default and narrowed afterward. There is therefore no
252        // window — not even between `create` and the first byte — in
253        // which the private key is group/world-readable. This test
254        // asserts the final mode is *exactly* 0600 (no wider bits set,
255        // hence no wider window the create could have transiently left
256        // behind).
257        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        // No group/world bits at all — the atomic create never widened
263        // the file even transiently.
264        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    // ------------------------------------------------------------------
333    // Regression tests for issue #17: `save` must create the key file
334    // atomically (O_CREAT|O_EXCL, mode 0600 at open) instead of the old
335    // exists()-check → fs::write → chmod sequence. Each test below
336    // documents whether it fails on the pre-fix implementation.
337    // ------------------------------------------------------------------
338
339    /// Regression test for issue #17 (symlink redirect, deterministic).
340    ///
341    /// The pre-fix code checked `path.exists()` — which follows
342    /// symlinks and returns `false` for a dangling one — and then
343    /// `fs::write(path, ..)`, which also follows symlinks. An attacker
344    /// who pre-planted a dangling symlink at the expected key path
345    /// could therefore redirect where the root key file was created
346    /// (and the trailing chmod was applied to the attacker-chosen
347    /// target). `create_new(true)` (O_CREAT|O_EXCL) refuses to traverse
348    /// any symlink: the open fails with EEXIST and nothing is created.
349    ///
350    /// FAILS on the pre-fix implementation: old `save` returns Ok and
351    /// creates the key at the symlink target.
352    #[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    /// Regression test for issue #17 (symlink chain, deterministic).
380    ///
381    /// Same attack as the dangling-symlink case but through a chain
382    /// (`a -> b -> <missing>`): the old `fs::write` resolves the whole
383    /// chain and creates the final target; O_EXCL fails on the first
384    /// link without resolving anything.
385    ///
386    /// FAILS on the pre-fix implementation.
387    #[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    /// Contract pin (passes on both implementations): a symlink to an
409    /// EXISTING file is refused, and the target's content and mode are
410    /// left untouched. The old code happened to refuse too (via the
411    /// followed `exists()` check), but only the new O_EXCL semantics
412    /// guarantee the target is never opened at all.
413    #[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    /// Regression test for issue #17 (exists-then-write race).
439    ///
440    /// N threads barrier-race `save` to one fresh path. O_CREAT|O_EXCL
441    /// guarantees that exactly one open can ever succeed per path, so
442    /// on the fixed code `exactly one Ok` holds unconditionally — this
443    /// test cannot flake. The pre-fix code window (all threads pass the
444    /// `exists()` check before any file appears, then `fs::write`
445    /// truncate-overwrite each other) makes multiple Oks — and torn
446    /// key files — overwhelmingly likely across the iterations.
447    ///
448    /// FAILS on the pre-fix implementation (probabilistically per
449    /// iteration, near-certainly across 50).
450    #[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                        // Generate before the barrier so every thread
467                        // hits open() at the same instant.
468                        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            // The winner's file must be a complete, loadable key —
491            // never a torn/truncated PEM.
492            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    /// Regression test for issue #17 (the 0600 window itself).
503    ///
504    /// An observer thread spin-stats the path while `save` runs; every
505    /// observation of the file must already be free of group/world
506    /// bits. With mode applied at open time this can never fail on the
507    /// fixed code (umask can only narrow 0600, never widen it). The
508    /// pre-fix code created the file at the umask default (typically
509    /// 0644) and only chmod'd it after the PEM bytes were written, so
510    /// the observer catches the wide window with high probability
511    /// across iterations.
512    ///
513    /// FAILS on the pre-fix implementation (probabilistically per
514    /// iteration, near-certainly across 200).
515    #[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    /// Contract pin: the refuse-to-overwrite error message format is
557    /// part of the API surface (callers and ops scripts match on it)
558    /// and must survive the create_new refactor byte-for-byte.
559    #[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    /// Contract pin: a refused save must leave the existing file's
576    /// content and mode untouched (no O_TRUNC side effects). The
577    /// concurrent-racers test covers the racing variant of this; this
578    /// is the deterministic single-threaded pin.
579    #[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    /// Contract pin: a directory squatting at the key path is refused
595    /// (O_EXCL reports EEXIST, surfaced through the same
596    /// refuse-to-overwrite mapping) and the directory survives intact.
597    #[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    /// Contract pin: a regular file squatting on a parent component
613    /// makes save fail cleanly (create_dir_all errors) without
614    /// touching the squatting file.
615    #[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    /// Contract pin: an unwritable parent directory surfaces a clean
632    /// PermissionDenied Io error (no panic, no partial file). Skipped
633    /// when running privileged (e.g. root in a CI container), where
634    /// permission bits don't bind.
635    #[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        // Privilege probe: root (or CAP_DAC_OVERRIDE) ignores mode
645        // bits entirely; the scenario is untestable there.
646        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    /// Contract pin (Linux / non-macOS unix): a **non-UTF-8** filename
663    /// round-trips through save/load and the refuse-to-overwrite path (the
664    /// error message formats the path lossily via `display()` without
665    /// panicking). This workspace has been bitten by encoding assumptions
666    /// before; the key store must not be.
667    ///
668    /// Excluded on macOS: APFS rejects a non-UTF-8 filename at creation
669    /// (`EILSEQ` / "Illegal byte sequence"), so the stressor cannot exist on
670    /// disk there. The `save_load_roundtrip_unusual_unicode_filename` test
671    /// below covers the identical save→load→refuse sequence on macOS with an
672    /// unusual — but APFS-valid — Unicode filename.
673    #[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        // Portability probe: the property under test is that the key
682        // store round-trips a non-UTF-8 *filename*, which is only
683        // observable on a filesystem that lets such a name exist. Linux
684        // CI (ext4/xfs/tmpfs) stores the bytes verbatim; macOS APFS/HFS+
685        // reject them at create with EILSEQ ("Illegal byte sequence",
686        // errno 92). Skip where the fs can't hold the name rather than
687        // panicking on a filesystem-encoding limitation we don't control.
688        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    /// Contract pin (macOS): the filesystem-behavior counterpart of
714    /// `save_load_roundtrip_non_utf8_filename`. APFS requires **valid UTF-8**
715    /// filenames, so a non-UTF-8 name cannot exist on disk there; the
716    /// platform-appropriate stressor is an unusual but valid Unicode filename —
717    /// non-ASCII, an emoji, and a combining diacritic. (APFS preserves the byte
718    /// representation as written and performs normalization-*insensitive* lookup,
719    /// so re-opening the exact path we wrote round-trips regardless of form.)
720    ///
721    /// This proves the **filesystem** half of the contract on macOS — save → load
722    /// round-trips, and the refuse-to-overwrite path formats a valid-Unicode path
723    /// and produces its message. It deliberately does NOT exercise lossy
724    /// formatting of *invalid* UTF-8 (this filename is valid UTF-8); that
725    /// formatting/error behavior is covered platform-independently by
726    /// `refuse_overwrite_message_is_lossy_for_a_non_utf8_path` below, and the
727    /// on-disk invalid-UTF-8 case by the Linux/non-macOS test above.
728    #[test]
729    #[cfg(target_os = "macos")]
730    fn save_load_roundtrip_unusual_unicode_filename() {
731        let dir = TempDir::new().unwrap();
732        // café (NFC é), a key emoji, and a bare combining acute accent — all
733        // valid UTF-8 that APFS accepts, stressing path handling with non-ASCII,
734        // multi-byte, and combining sequences.
735        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    /// Platform-independent pin for the **formatting/error** behavior (as opposed
753    /// to filesystem behavior): the refuse-to-overwrite message renders a
754    /// **non-UTF-8** path lossily and never panics. The invalid `OsStr` is built
755    /// in memory and passed straight to `refuse_overwrite_error` — it is never
756    /// written to disk, so this runs identically on Linux and macOS (where APFS
757    /// would reject the name). This isolates the lossy-`display()` contract from
758    /// any filesystem round-trip.
759    #[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    /// Regression test for issue #17 (live TOCTOU race, the literal
777    /// attack in the issue title).
778    ///
779    /// A planter thread races `save` to materialize the path first
780    /// with a dangling symlink. On the fixed code only two syscalls
781    /// can create the path — our open(O_CREAT|O_EXCL) and the
782    /// planter's symlink(2) — and each fails EEXIST if the other won,
783    /// so exactly one wins under every interleaving and the key can
784    /// NEVER appear at the symlink target. The pre-fix code had a
785    /// check-to-use gap: a symlink planted between `exists()` and
786    /// `fs::write` redirected the key to the target and both sides
787    /// "succeeded".
788    ///
789    /// FAILS on the pre-fix implementation (probabilistically per
790    /// iteration, near-certainly across 400).
791    #[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    /// Regression test for issue #17 (self-referential symlink,
857    /// deterministic).
858    ///
859    /// O_CREAT|O_EXCL fails EEXIST when the path names a symlink even
860    /// if resolving it would loop, so the fixed code refuses with the
861    /// standard overwrite message. The pre-fix `exists()` followed the
862    /// link, hit ELOOP, returned `false`, and `fs::write` then failed
863    /// with a raw filesystem-loop error — wrong kind, wrong message.
864    ///
865    /// FAILS on the pre-fix implementation.
866    #[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    /// Contract pin: concurrent saves to DISTINCT paths under one
886    /// not-yet-existing parent all succeed — `create_dir_all` is
887    /// concurrency-safe (treats EEXIST as success). Guards a future
888    /// refactor to bare `create_dir`, which would race-fail here.
889    #[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    /// Contract pin: a name longer than NAME_MAX must surface as a
927    /// plain Io error — the AlreadyExists-only `map_err` arm must not
928    /// swallow ENAMETOOLONG into the "refusing to overwrite" message.
929    /// (Deliberately does not assert the specific kind; its mapping is
930    /// Rust-version-dependent.)
931    #[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    /// Contract pin: the on-disk format is strict PKCS#8 PEM with LF
951    /// line endings, written exactly once. Guards against a future
952    /// "native line endings" change and against the open-then-write
953    /// sequence double-writing under refactor. Complements
954    /// `roundtrip_save_load_disk`, which only proves loadability.
955    #[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    /// Contract pin: unicode (multibyte UTF-8) filenames round-trip.
988    /// Portable companion to the unix-only non-UTF-8 test.
989    #[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    /// Contract pin: an existing but EMPTY file is still refused and
1000    /// left untouched — emptiness must not be mistaken for absence.
1001    #[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}