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                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    /// Load a private key previously written by [`save`](Self::save).
109    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        // Deliberately do not print the private key bytes.
120        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        // Best-effort zeroize of the in-memory keypair. The dalek
129        // type itself zeroizes on drop too, but we explicitly scrub
130        // the byte copy we hand back to ourselves.
131        let mut bytes = self.signing.to_bytes();
132        bytes.zeroize();
133    }
134}
135
136/// Public verifying half of a [`UserKey`]. Cheap to clone, safe to
137/// share, and the thing peers actually exchange.
138#[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    /// BLAKE3 fingerprint of the underlying 32-byte ed25519 public
146    /// key.
147    #[must_use]
148    pub fn fingerprint(&self) -> Fingerprint {
149        Fingerprint::of_bytes(self.verifying.as_bytes())
150    }
151
152    /// Verify a signature was produced by this user's private key
153    /// over `message`.
154    pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<()> {
155        self.verifying
156            .verify(message, signature)
157            .map_err(|_| MeshError::BadSignature)
158    }
159
160    /// Raw 32-byte ed25519 public key.
161    #[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        // The key is created 0600 *atomically* (see `save`): the file
241        // is born with these bits via `OpenOptions::mode`, never at a
242        // umask default and narrowed afterward. There is therefore no
243        // window — not even between `create` and the first byte — in
244        // which the private key is group/world-readable. This test
245        // asserts the final mode is *exactly* 0600 (no wider bits set,
246        // hence no wider window the create could have transiently left
247        // behind).
248        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        // No group/world bits at all — the atomic create never widened
254        // the file even transiently.
255        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    // ------------------------------------------------------------------
324    // Regression tests for issue #17: `save` must create the key file
325    // atomically (O_CREAT|O_EXCL, mode 0600 at open) instead of the old
326    // exists()-check → fs::write → chmod sequence. Each test below
327    // documents whether it fails on the pre-fix implementation.
328    // ------------------------------------------------------------------
329
330    /// Regression test for issue #17 (symlink redirect, deterministic).
331    ///
332    /// The pre-fix code checked `path.exists()` — which follows
333    /// symlinks and returns `false` for a dangling one — and then
334    /// `fs::write(path, ..)`, which also follows symlinks. An attacker
335    /// who pre-planted a dangling symlink at the expected key path
336    /// could therefore redirect where the root key file was created
337    /// (and the trailing chmod was applied to the attacker-chosen
338    /// target). `create_new(true)` (O_CREAT|O_EXCL) refuses to traverse
339    /// any symlink: the open fails with EEXIST and nothing is created.
340    ///
341    /// FAILS on the pre-fix implementation: old `save` returns Ok and
342    /// creates the key at the symlink target.
343    #[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    /// Regression test for issue #17 (symlink chain, deterministic).
371    ///
372    /// Same attack as the dangling-symlink case but through a chain
373    /// (`a -> b -> <missing>`): the old `fs::write` resolves the whole
374    /// chain and creates the final target; O_EXCL fails on the first
375    /// link without resolving anything.
376    ///
377    /// FAILS on the pre-fix implementation.
378    #[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    /// Contract pin (passes on both implementations): a symlink to an
400    /// EXISTING file is refused, and the target's content and mode are
401    /// left untouched. The old code happened to refuse too (via the
402    /// followed `exists()` check), but only the new O_EXCL semantics
403    /// guarantee the target is never opened at all.
404    #[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    /// Regression test for issue #17 (exists-then-write race).
430    ///
431    /// N threads barrier-race `save` to one fresh path. O_CREAT|O_EXCL
432    /// guarantees that exactly one open can ever succeed per path, so
433    /// on the fixed code `exactly one Ok` holds unconditionally — this
434    /// test cannot flake. The pre-fix code window (all threads pass the
435    /// `exists()` check before any file appears, then `fs::write`
436    /// truncate-overwrite each other) makes multiple Oks — and torn
437    /// key files — overwhelmingly likely across the iterations.
438    ///
439    /// FAILS on the pre-fix implementation (probabilistically per
440    /// iteration, near-certainly across 50).
441    #[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                        // Generate before the barrier so every thread
458                        // hits open() at the same instant.
459                        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            // The winner's file must be a complete, loadable key —
482            // never a torn/truncated PEM.
483            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    /// Regression test for issue #17 (the 0600 window itself).
494    ///
495    /// An observer thread spin-stats the path while `save` runs; every
496    /// observation of the file must already be free of group/world
497    /// bits. With mode applied at open time this can never fail on the
498    /// fixed code (umask can only narrow 0600, never widen it). The
499    /// pre-fix code created the file at the umask default (typically
500    /// 0644) and only chmod'd it after the PEM bytes were written, so
501    /// the observer catches the wide window with high probability
502    /// across iterations.
503    ///
504    /// FAILS on the pre-fix implementation (probabilistically per
505    /// iteration, near-certainly across 200).
506    #[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    /// Contract pin: the refuse-to-overwrite error message format is
548    /// part of the API surface (callers and ops scripts match on it)
549    /// and must survive the create_new refactor byte-for-byte.
550    #[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    /// Contract pin: a refused save must leave the existing file's
567    /// content and mode untouched (no O_TRUNC side effects). The
568    /// concurrent-racers test covers the racing variant of this; this
569    /// is the deterministic single-threaded pin.
570    #[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    /// Contract pin: a directory squatting at the key path is refused
586    /// (O_EXCL reports EEXIST, surfaced through the same
587    /// refuse-to-overwrite mapping) and the directory survives intact.
588    #[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    /// Contract pin: a regular file squatting on a parent component
604    /// makes save fail cleanly (create_dir_all errors) without
605    /// touching the squatting file.
606    #[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    /// Contract pin: an unwritable parent directory surfaces a clean
623    /// PermissionDenied Io error (no panic, no partial file). Skipped
624    /// when running privileged (e.g. root in a CI container), where
625    /// permission bits don't bind.
626    #[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        // Privilege probe: root (or CAP_DAC_OVERRIDE) ignores mode
636        // bits entirely; the scenario is untestable there.
637        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    /// Contract pin: non-UTF-8 filenames round-trip through save/load
654    /// and the refuse-to-overwrite path (the error message formats the
655    /// path lossily via `display()` without panicking). This workspace
656    /// has been bitten by encoding assumptions before; the key store
657    /// must not be.
658    #[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    /// Regression test for issue #17 (live TOCTOU race, the literal
681    /// attack in the issue title).
682    ///
683    /// A planter thread races `save` to materialize the path first
684    /// with a dangling symlink. On the fixed code only two syscalls
685    /// can create the path — our open(O_CREAT|O_EXCL) and the
686    /// planter's symlink(2) — and each fails EEXIST if the other won,
687    /// so exactly one wins under every interleaving and the key can
688    /// NEVER appear at the symlink target. The pre-fix code had a
689    /// check-to-use gap: a symlink planted between `exists()` and
690    /// `fs::write` redirected the key to the target and both sides
691    /// "succeeded".
692    ///
693    /// FAILS on the pre-fix implementation (probabilistically per
694    /// iteration, near-certainly across 400).
695    #[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    /// Regression test for issue #17 (self-referential symlink,
761    /// deterministic).
762    ///
763    /// O_CREAT|O_EXCL fails EEXIST when the path names a symlink even
764    /// if resolving it would loop, so the fixed code refuses with the
765    /// standard overwrite message. The pre-fix `exists()` followed the
766    /// link, hit ELOOP, returned `false`, and `fs::write` then failed
767    /// with a raw filesystem-loop error — wrong kind, wrong message.
768    ///
769    /// FAILS on the pre-fix implementation.
770    #[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    /// Contract pin: concurrent saves to DISTINCT paths under one
790    /// not-yet-existing parent all succeed — `create_dir_all` is
791    /// concurrency-safe (treats EEXIST as success). Guards a future
792    /// refactor to bare `create_dir`, which would race-fail here.
793    #[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    /// Contract pin: a name longer than NAME_MAX must surface as a
831    /// plain Io error — the AlreadyExists-only `map_err` arm must not
832    /// swallow ENAMETOOLONG into the "refusing to overwrite" message.
833    /// (Deliberately does not assert the specific kind; its mapping is
834    /// Rust-version-dependent.)
835    #[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    /// Contract pin: the on-disk format is strict PKCS#8 PEM with LF
855    /// line endings, written exactly once. Guards against a future
856    /// "native line endings" change and against the open-then-write
857    /// sequence double-writing under refactor. Complements
858    /// `roundtrip_save_load_disk`, which only proves loadability.
859    #[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    /// Contract pin: unicode (multibyte UTF-8) filenames round-trip.
892    /// Portable companion to the unix-only non-UTF-8 test.
893    #[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    /// Contract pin: an existing but EMPTY file is still refused and
904    /// left untouched — emptiness must not be mistaken for absence.
905    #[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}