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    /// Derive the 32-byte ed25519 seed of the agent key named `label`.
30    ///
31    /// Deterministic: the same user key and label always give the same seed,
32    /// so an agent whose identity must outlive its process (a dock endpoint a
33    /// peer has pinned) is *recomputed* at start-up instead of persisted —
34    /// nothing beyond the user key is ever written to disk. Different labels,
35    /// or different user keys, give unrelated seeds.
36    ///
37    /// Only a holder of this user key can derive, and that holder can already
38    /// issue any agent key; derivation adds no authority. Rotate a derived
39    /// identity by changing its label.
40    pub(crate) fn derived_agent_seed(&self, label: &str) -> [u8; 32] {
41        // BLAKE3's KDF mode: a fixed, globally unique context string, then a
42        // keyed hash binds the caller's label under that subkey.
43        const CONTEXT: &str = "agent-mesh 2026-09-26 derived agent key v1";
44        let mut root = self.signing.to_bytes();
45        let mut subkey = blake3::derive_key(CONTEXT, &root);
46        root.zeroize();
47        let seed = *blake3::keyed_hash(&subkey, label.as_bytes()).as_bytes();
48        subkey.zeroize();
49        seed
50    }
51
52    /// Generate a fresh user key from the operating system RNG.
53    #[must_use]
54    pub fn generate() -> Self {
55        let mut csprng = OsRng;
56        let signing = SigningKey::generate(&mut csprng);
57        Self { signing }
58    }
59
60    /// Public verifying half of the key — safe to share with peers.
61    #[must_use]
62    pub fn public(&self) -> UserPublic {
63        UserPublic {
64            verifying: self.signing.verifying_key(),
65        }
66    }
67
68    /// BLAKE3 fingerprint of the public key bytes.
69    #[must_use]
70    pub fn fingerprint(&self) -> Fingerprint {
71        self.public().fingerprint()
72    }
73
74    /// Sign an arbitrary message with the user's root key.
75    ///
76    /// In practice this is called sparingly — typically just to
77    /// issue agent certificates and the one-time GitHub binding.
78    pub fn sign(&self, message: &[u8]) -> Signature {
79        self.signing.sign(message)
80    }
81
82    /// Save the private key to disk in PKCS#8 PEM format.
83    ///
84    /// Refuses to overwrite an existing file (returns
85    /// [`MeshError::Io`] with `AlreadyExists`). On Unix systems the
86    /// file is created `0600` *atomically* — the key is never
87    /// group/world-readable for any window, not even between create
88    /// and the first byte written. The parent directory is created if
89    /// it doesn't exist.
90    pub fn save(&self, path: &Path) -> Result<()> {
91        if let Some(parent) = path.parent() {
92            if !parent.as_os_str().is_empty() {
93                std::fs::create_dir_all(parent)?;
94            }
95        }
96        let pem = self
97            .signing
98            .to_pkcs8_pem(LineEnding::LF)
99            .map_err(|e| MeshError::InvalidKey(e.to_string()))?;
100
101        use std::io::Write;
102        #[cfg(unix)]
103        use std::os::unix::fs::OpenOptionsExt;
104
105        let mut opts = std::fs::OpenOptions::new();
106        // `create_new(true)` folds in the old `path.exists()` guard:
107        // it returns `AlreadyExists` rather than truncating an
108        // existing key, preserving the refuse-to-overwrite contract.
109        opts.write(true).create_new(true);
110        // On Unix the file is born at 0600 — there is no umask-default
111        // (e.g. 0644) window where the key bytes are readable by
112        // group/world. Non-unix has no mode support, so it falls back
113        // to a plain create, unchanged from before.
114        #[cfg(unix)]
115        opts.mode(0o600);
116
117        let mut f = opts.open(path).map_err(|e| {
118            if e.kind() == std::io::ErrorKind::AlreadyExists {
119                refuse_overwrite_error(path)
120            } else {
121                MeshError::Io(e)
122            }
123        })?;
124        f.write_all(pem.as_bytes())?;
125        Ok(())
126    }
127
128    /// Load a private key previously written by [`save`](Self::save).
129    pub fn load(path: &Path) -> Result<Self> {
130        let pem = std::fs::read_to_string(path)?;
131        let signing =
132            SigningKey::from_pkcs8_pem(&pem).map_err(|e| MeshError::InvalidKey(e.to_string()))?;
133        Ok(Self { signing })
134    }
135}
136
137/// The `AlreadyExists` refusal [`save`](UserKey::save) returns rather than
138/// overwrite an existing key. Formats `path` via `Path::display()`, which is
139/// **lossy** for a non-UTF-8 path (the offending bytes render as U+FFFD) and
140/// never panics — the single source of truth for that message, so the formatting
141/// contract can be tested without forcing an illegal filename onto the disk.
142fn refuse_overwrite_error(path: &Path) -> MeshError {
143    MeshError::Io(std::io::Error::new(
144        std::io::ErrorKind::AlreadyExists,
145        format!("refusing to overwrite existing key at {}", path.display()),
146    ))
147}
148
149impl std::fmt::Debug for UserKey {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        // Deliberately do not print the private key bytes.
152        f.debug_struct("UserKey")
153            .field("fingerprint", &self.fingerprint())
154            .finish_non_exhaustive()
155    }
156}
157
158impl Drop for UserKey {
159    fn drop(&mut self) {
160        // Best-effort zeroize of the in-memory keypair. The dalek
161        // type itself zeroizes on drop too, but we explicitly scrub
162        // the byte copy we hand back to ourselves.
163        let mut bytes = self.signing.to_bytes();
164        bytes.zeroize();
165    }
166}
167
168/// Public verifying half of a [`UserKey`]. Cheap to clone, safe to
169/// share, and the thing peers actually exchange.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct UserPublic {
172    #[serde(with = "verifying_key_serde")]
173    pub verifying: VerifyingKey,
174}
175
176impl UserPublic {
177    /// BLAKE3 fingerprint of the underlying 32-byte ed25519 public
178    /// key.
179    #[must_use]
180    pub fn fingerprint(&self) -> Fingerprint {
181        Fingerprint::of_bytes(self.verifying.as_bytes())
182    }
183
184    /// Verify a signature was produced by this user's private key
185    /// over `message`.
186    pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<()> {
187        self.verifying
188            .verify(message, signature)
189            .map_err(|_| MeshError::BadSignature)
190    }
191
192    /// Raw 32-byte ed25519 public key.
193    #[must_use]
194    pub fn as_bytes(&self) -> [u8; 32] {
195        *self.verifying.as_bytes()
196    }
197}
198
199mod verifying_key_serde {
200    use ed25519_dalek::VerifyingKey;
201    use serde::{Deserialize, Deserializer, Serialize, Serializer};
202
203    pub fn serialize<S: Serializer>(key: &VerifyingKey, ser: S) -> Result<S::Ok, S::Error> {
204        let bytes: &[u8] = key.as_bytes();
205        bytes.serialize(ser)
206    }
207
208    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<VerifyingKey, D::Error> {
209        let bytes: Vec<u8> = Vec::deserialize(de)?;
210        if bytes.len() != 32 {
211            return Err(serde::de::Error::custom("expected 32 bytes"));
212        }
213        let mut arr = [0u8; 32];
214        arr.copy_from_slice(&bytes);
215        VerifyingKey::from_bytes(&arr).map_err(serde::de::Error::custom)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use tempfile::TempDir;
223
224    /// Pins the derivation itself. Every identity a peer has pinned is a
225    /// function of this KDF, so an accidental change here would silently
226    /// re-key every derived agent. Changing it on purpose means a new
227    /// context string (a new `vN`) and a new vector.
228    #[test]
229    fn derived_agent_seed_matches_its_pinned_vector() {
230        let user = UserKey {
231            signing: SigningKey::from_bytes(&[7u8; 32]),
232        };
233        let seed = user.derived_agent_seed("dock/hub");
234        assert_eq!(
235            hex::encode(seed),
236            "52335d4284e638ccc1ffc036573c7d4b9e92e7c2591247a83c000fe9fc22085d"
237        );
238    }
239
240    #[test]
241    fn generate_different_keys() {
242        let a = UserKey::generate();
243        let b = UserKey::generate();
244        assert_ne!(
245            a.fingerprint(),
246            b.fingerprint(),
247            "two fresh keys must not collide"
248        );
249    }
250
251    #[test]
252    fn roundtrip_save_load_disk() {
253        let dir = TempDir::new().unwrap();
254        let path = dir.path().join("user.key");
255        let key = UserKey::generate();
256        let fp = key.fingerprint();
257        key.save(&path).expect("save");
258        let loaded = UserKey::load(&path).expect("load");
259        assert_eq!(loaded.fingerprint(), fp);
260    }
261
262    #[test]
263    fn save_refuses_overwrite() {
264        let dir = TempDir::new().unwrap();
265        let path = dir.path().join("user.key");
266        let key = UserKey::generate();
267        key.save(&path).expect("first save");
268        let key2 = UserKey::generate();
269        let err = key2.save(&path).expect_err("must refuse");
270        match err {
271            MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
272            other => panic!("expected Io(AlreadyExists), got {other:?}"),
273        }
274    }
275
276    #[test]
277    fn save_creates_parent_directory() {
278        let dir = TempDir::new().unwrap();
279        let path = dir.path().join("nested").join("dir").join("user.key");
280        UserKey::generate().save(&path).expect("save with mkdir -p");
281        assert!(path.exists());
282    }
283
284    #[test]
285    #[cfg(unix)]
286    fn save_sets_0600_permissions() {
287        use std::os::unix::fs::PermissionsExt;
288        // The key is created 0600 *atomically* (see `save`): the file
289        // is born with these bits via `OpenOptions::mode`, never at a
290        // umask default and narrowed afterward. There is therefore no
291        // window — not even between `create` and the first byte — in
292        // which the private key is group/world-readable. This test
293        // asserts the final mode is *exactly* 0600 (no wider bits set,
294        // hence no wider window the create could have transiently left
295        // behind).
296        let dir = TempDir::new().unwrap();
297        let path = dir.path().join("user.key");
298        UserKey::generate().save(&path).expect("save");
299        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
300        assert_eq!(mode & 0o777, 0o600, "expected exactly 0600, got {mode:o}");
301        // No group/world bits at all — the atomic create never widened
302        // the file even transiently.
303        assert_eq!(mode & 0o077, 0, "no group/world bits, got {mode:o}");
304    }
305
306    #[test]
307    fn sign_verify() {
308        let key = UserKey::generate();
309        let pubk = key.public();
310        let msg = b"hello agent-mesh";
311        let sig = key.sign(msg);
312        pubk.verify(msg, &sig).expect("verify own signature");
313    }
314
315    #[test]
316    fn wrong_message_fails_verify() {
317        let key = UserKey::generate();
318        let pubk = key.public();
319        let sig = key.sign(b"original");
320        let err = pubk.verify(b"tampered", &sig).expect_err("must fail");
321        assert!(matches!(err, MeshError::BadSignature));
322    }
323
324    #[test]
325    fn fingerprint_stable_across_loads() {
326        let dir = TempDir::new().unwrap();
327        let path = dir.path().join("user.key");
328        let key = UserKey::generate();
329        let fp1 = key.fingerprint();
330        key.save(&path).unwrap();
331        drop(key);
332        let loaded = UserKey::load(&path).unwrap();
333        let fp2 = loaded.fingerprint();
334        assert_eq!(fp1, fp2);
335    }
336
337    #[test]
338    fn serde_roundtrip_public() {
339        let key = UserKey::generate();
340        let pubk = key.public();
341        let json = serde_json::to_string(&pubk).unwrap();
342        let parsed: UserPublic = serde_json::from_str(&json).unwrap();
343        assert_eq!(parsed, pubk);
344        assert_eq!(parsed.fingerprint(), pubk.fingerprint());
345    }
346
347    #[test]
348    fn public_as_bytes_is_32() {
349        let key = UserKey::generate();
350        let bytes = key.public().as_bytes();
351        assert_eq!(bytes.len(), 32);
352    }
353
354    #[test]
355    fn load_fails_on_missing_file() {
356        let dir = TempDir::new().unwrap();
357        let path = dir.path().join("nope.key");
358        let err = UserKey::load(&path).expect_err("must fail");
359        assert!(matches!(err, MeshError::Io(_)));
360    }
361
362    #[test]
363    fn load_fails_on_garbage_file() {
364        let dir = TempDir::new().unwrap();
365        let path = dir.path().join("garbage");
366        std::fs::write(&path, b"not a pem").unwrap();
367        let err = UserKey::load(&path).expect_err("must fail");
368        assert!(matches!(err, MeshError::InvalidKey(_)));
369    }
370
371    // ------------------------------------------------------------------
372    // Regression tests for issue #17: `save` must create the key file
373    // atomically (O_CREAT|O_EXCL, mode 0600 at open) instead of the old
374    // exists()-check → fs::write → chmod sequence. Each test below
375    // documents whether it fails on the pre-fix implementation.
376    // ------------------------------------------------------------------
377
378    /// Regression test for issue #17 (symlink redirect, deterministic).
379    ///
380    /// The pre-fix code checked `path.exists()` — which follows
381    /// symlinks and returns `false` for a dangling one — and then
382    /// `fs::write(path, ..)`, which also follows symlinks. An attacker
383    /// who pre-planted a dangling symlink at the expected key path
384    /// could therefore redirect where the root key file was created
385    /// (and the trailing chmod was applied to the attacker-chosen
386    /// target). `create_new(true)` (O_CREAT|O_EXCL) refuses to traverse
387    /// any symlink: the open fails with EEXIST and nothing is created.
388    ///
389    /// FAILS on the pre-fix implementation: old `save` returns Ok and
390    /// creates the key at the symlink target.
391    #[test]
392    #[cfg(unix)]
393    fn save_refuses_dangling_symlink_and_creates_nothing() {
394        let dir = TempDir::new().unwrap();
395        let target_dir = TempDir::new().unwrap();
396        let path = dir.path().join("user.key");
397        let target = target_dir.path().join("redirected.key");
398        std::os::unix::fs::symlink(&target, &path).unwrap();
399
400        let err = UserKey::generate()
401            .save(&path)
402            .expect_err("must refuse to save through a dangling symlink");
403        match err {
404            MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
405            other => panic!("expected Io(AlreadyExists), got {other:?}"),
406        }
407        assert!(
408            !target.exists(),
409            "key must not be created at the symlink target"
410        );
411        let meta = std::fs::symlink_metadata(&path).expect("symlink still present");
412        assert!(
413            meta.file_type().is_symlink(),
414            "the planted symlink must be left untouched"
415        );
416    }
417
418    /// Regression test for issue #17 (symlink chain, deterministic).
419    ///
420    /// Same attack as the dangling-symlink case but through a chain
421    /// (`a -> b -> <missing>`): the old `fs::write` resolves the whole
422    /// chain and creates the final target; O_EXCL fails on the first
423    /// link without resolving anything.
424    ///
425    /// FAILS on the pre-fix implementation.
426    #[test]
427    #[cfg(unix)]
428    fn save_refuses_symlink_chain_and_creates_nothing() {
429        let dir = TempDir::new().unwrap();
430        let target_dir = TempDir::new().unwrap();
431        let path = dir.path().join("user.key");
432        let mid = dir.path().join("mid.link");
433        let target = target_dir.path().join("end.key");
434        std::os::unix::fs::symlink(&target, &mid).unwrap();
435        std::os::unix::fs::symlink(&mid, &path).unwrap();
436
437        let err = UserKey::generate()
438            .save(&path)
439            .expect_err("must refuse to save through a symlink chain");
440        assert!(matches!(err, MeshError::Io(_)));
441        assert!(
442            !target.exists(),
443            "key must not be created at the end of the chain"
444        );
445    }
446
447    /// Contract pin (passes on both implementations): a symlink to an
448    /// EXISTING file is refused, and the target's content and mode are
449    /// left untouched. The old code happened to refuse too (via the
450    /// followed `exists()` check), but only the new O_EXCL semantics
451    /// guarantee the target is never opened at all.
452    #[test]
453    #[cfg(unix)]
454    fn save_refuses_symlink_to_existing_file_without_touching_target() {
455        use std::os::unix::fs::PermissionsExt;
456        let dir = TempDir::new().unwrap();
457        let target_dir = TempDir::new().unwrap();
458        let path = dir.path().join("user.key");
459        let target = target_dir.path().join("victim.txt");
460        std::fs::write(&target, b"victim content").unwrap();
461        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
462        std::os::unix::fs::symlink(&target, &path).unwrap();
463
464        let err = UserKey::generate()
465            .save(&path)
466            .expect_err("must refuse symlink to existing file");
467        assert!(matches!(err, MeshError::Io(_)));
468        assert_eq!(
469            std::fs::read(&target).unwrap(),
470            b"victim content",
471            "target content must be untouched"
472        );
473        let mode = std::fs::metadata(&target).unwrap().permissions().mode();
474        assert_eq!(mode & 0o777, 0o644, "target mode must be untouched");
475    }
476
477    /// Regression test for issue #17 (exists-then-write race).
478    ///
479    /// N threads barrier-race `save` to one fresh path. O_CREAT|O_EXCL
480    /// guarantees that exactly one open can ever succeed per path, so
481    /// on the fixed code `exactly one Ok` holds unconditionally — this
482    /// test cannot flake. The pre-fix code window (all threads pass the
483    /// `exists()` check before any file appears, then `fs::write`
484    /// truncate-overwrite each other) makes multiple Oks — and torn
485    /// key files — overwhelmingly likely across the iterations.
486    ///
487    /// FAILS on the pre-fix implementation (probabilistically per
488    /// iteration, near-certainly across 50).
489    #[test]
490    fn save_concurrent_racers_exactly_one_wins() {
491        use std::sync::{Arc, Barrier};
492        const THREADS: usize = 8;
493        const ITERS: usize = 50;
494
495        for i in 0..ITERS {
496            let dir = TempDir::new().unwrap();
497            let path = Arc::new(dir.path().join(format!("user-{i}.key")));
498            let barrier = Arc::new(Barrier::new(THREADS));
499
500            let handles: Vec<_> = (0..THREADS)
501                .map(|_| {
502                    let path = Arc::clone(&path);
503                    let barrier = Arc::clone(&barrier);
504                    std::thread::spawn(move || {
505                        // Generate before the barrier so every thread
506                        // hits open() at the same instant.
507                        let key = UserKey::generate();
508                        barrier.wait();
509                        key.save(&path)
510                    })
511                })
512                .collect();
513
514            let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
515            let oks = results.iter().filter(|r| r.is_ok()).count();
516            assert_eq!(
517                oks, 1,
518                "exactly one concurrent save must win (iteration {i}), got {oks}"
519            );
520            for r in &results {
521                if let Err(MeshError::Io(e)) = r {
522                    assert_eq!(
523                        e.kind(),
524                        std::io::ErrorKind::AlreadyExists,
525                        "losers must see AlreadyExists"
526                    );
527                }
528            }
529            // The winner's file must be a complete, loadable key —
530            // never a torn/truncated PEM.
531            UserKey::load(&path).expect("winner must have written a complete valid key");
532            #[cfg(unix)]
533            {
534                use std::os::unix::fs::PermissionsExt;
535                let mode = std::fs::metadata(&*path).unwrap().permissions().mode();
536                assert_eq!(mode & 0o077, 0, "winner file must have no group/world bits");
537            }
538        }
539    }
540
541    /// Regression test for issue #17 (the 0600 window itself).
542    ///
543    /// An observer thread spin-stats the path while `save` runs; every
544    /// observation of the file must already be free of group/world
545    /// bits. With mode applied at open time this can never fail on the
546    /// fixed code (umask can only narrow 0600, never widen it). The
547    /// pre-fix code created the file at the umask default (typically
548    /// 0644) and only chmod'd it after the PEM bytes were written, so
549    /// the observer catches the wide window with high probability
550    /// across iterations.
551    ///
552    /// FAILS on the pre-fix implementation (probabilistically per
553    /// iteration, near-certainly across 200).
554    #[test]
555    #[cfg(unix)]
556    fn save_never_exposes_group_world_readable_window() {
557        use std::os::unix::fs::PermissionsExt;
558        use std::sync::atomic::{AtomicBool, Ordering};
559        use std::sync::Arc;
560        const ITERS: usize = 200;
561
562        let dir = TempDir::new().unwrap();
563        for i in 0..ITERS {
564            let path = Arc::new(dir.path().join(format!("probe-{i}.key")));
565            let stop = Arc::new(AtomicBool::new(false));
566            let saw_wide = Arc::new(AtomicBool::new(false));
567
568            let observer = {
569                let path = Arc::clone(&path);
570                let stop = Arc::clone(&stop);
571                let saw_wide = Arc::clone(&saw_wide);
572                std::thread::spawn(move || {
573                    while !stop.load(Ordering::Relaxed) {
574                        if let Ok(meta) = std::fs::symlink_metadata(&*path) {
575                            if meta.permissions().mode() & 0o077 != 0 {
576                                saw_wide.store(true, Ordering::Relaxed);
577                            }
578                        }
579                        std::hint::spin_loop();
580                    }
581                })
582            };
583
584            UserKey::generate().save(&path).expect("save");
585            stop.store(true, Ordering::Relaxed);
586            observer.join().unwrap();
587
588            assert!(
589                !saw_wide.load(Ordering::Relaxed),
590                "key file was observable with group/world bits (iteration {i})"
591            );
592        }
593    }
594
595    /// Contract pin: the refuse-to-overwrite error message format is
596    /// part of the API surface (callers and ops scripts match on it)
597    /// and must survive the create_new refactor byte-for-byte.
598    #[test]
599    fn save_refuse_overwrite_message_pins_path() {
600        let dir = TempDir::new().unwrap();
601        let path = dir.path().join("user.key");
602        UserKey::generate().save(&path).expect("first save");
603        let err = UserKey::generate().save(&path).expect_err("must refuse");
604        let msg = err.to_string();
605        assert!(
606            msg.contains(&format!(
607                "refusing to overwrite existing key at {}",
608                path.display()
609            )),
610            "unexpected error message: {msg}"
611        );
612    }
613
614    /// Contract pin: a refused save must leave the existing file's
615    /// content and mode untouched (no O_TRUNC side effects). The
616    /// concurrent-racers test covers the racing variant of this; this
617    /// is the deterministic single-threaded pin.
618    #[test]
619    fn save_refuse_overwrite_leaves_existing_content_untouched() {
620        let dir = TempDir::new().unwrap();
621        let path = dir.path().join("user.key");
622        std::fs::write(&path, b"pre-existing bytes, not even a key").unwrap();
623
624        let err = UserKey::generate().save(&path).expect_err("must refuse");
625        assert!(matches!(err, MeshError::Io(_)));
626        assert_eq!(
627            std::fs::read(&path).unwrap(),
628            b"pre-existing bytes, not even a key",
629            "existing file must not be truncated or rewritten"
630        );
631    }
632
633    /// Contract pin: a directory squatting at the key path is refused
634    /// (O_EXCL reports EEXIST, surfaced through the same
635    /// refuse-to-overwrite mapping) and the directory survives intact.
636    #[test]
637    fn save_refuses_directory_at_path() {
638        let dir = TempDir::new().unwrap();
639        let path = dir.path().join("user.key");
640        std::fs::create_dir(&path).unwrap();
641        std::fs::write(path.join("inner.txt"), b"keep me").unwrap();
642
643        let err = UserKey::generate()
644            .save(&path)
645            .expect_err("must refuse dir");
646        assert!(matches!(err, MeshError::Io(_)));
647        assert!(path.is_dir(), "directory must survive");
648        assert_eq!(std::fs::read(path.join("inner.txt")).unwrap(), b"keep me");
649    }
650
651    /// Contract pin: a regular file squatting on a parent component
652    /// makes save fail cleanly (create_dir_all errors) without
653    /// touching the squatting file.
654    #[test]
655    fn save_errors_when_parent_component_is_a_file() {
656        let dir = TempDir::new().unwrap();
657        let blocker = dir.path().join("blocker");
658        std::fs::write(&blocker, b"i am a file").unwrap();
659        let path = blocker.join("sub").join("user.key");
660
661        let err = UserKey::generate().save(&path).expect_err("must fail");
662        assert!(matches!(err, MeshError::Io(_)));
663        assert_eq!(
664            std::fs::read(&blocker).unwrap(),
665            b"i am a file",
666            "blocking file must be untouched"
667        );
668    }
669
670    /// Contract pin: an unwritable parent directory surfaces a clean
671    /// PermissionDenied Io error (no panic, no partial file). Skipped
672    /// when running privileged (e.g. root in a CI container), where
673    /// permission bits don't bind.
674    #[test]
675    #[cfg(unix)]
676    fn save_errors_on_readonly_parent_dir() {
677        use std::os::unix::fs::PermissionsExt;
678        let dir = TempDir::new().unwrap();
679        let parent = dir.path().join("locked");
680        std::fs::create_dir(&parent).unwrap();
681        std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o555)).unwrap();
682
683        // Privilege probe: root (or CAP_DAC_OVERRIDE) ignores mode
684        // bits entirely; the scenario is untestable there.
685        if std::fs::File::create(parent.join(".probe")).is_ok() {
686            std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
687            eprintln!("skipping: running privileged, mode bits don't bind");
688            return;
689        }
690
691        let path = parent.join("user.key");
692        let err = UserKey::generate().save(&path).expect_err("must fail");
693        match &err {
694            MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied),
695            other => panic!("expected Io(PermissionDenied), got {other:?}"),
696        }
697        assert!(!path.exists(), "no partial file may appear");
698        std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
699    }
700
701    /// Contract pin (Linux / non-macOS unix): a **non-UTF-8** filename
702    /// round-trips through save/load and the refuse-to-overwrite path (the
703    /// error message formats the path lossily via `display()` without
704    /// panicking). This workspace has been bitten by encoding assumptions
705    /// before; the key store must not be.
706    ///
707    /// Excluded on macOS: APFS rejects a non-UTF-8 filename at creation
708    /// (`EILSEQ` / "Illegal byte sequence"), so the stressor cannot exist on
709    /// disk there. The `save_load_roundtrip_unusual_unicode_filename` test
710    /// below covers the identical save→load→refuse sequence on macOS with an
711    /// unusual — but APFS-valid — Unicode filename.
712    #[test]
713    #[cfg(all(unix, not(target_os = "macos")))]
714    fn save_load_roundtrip_non_utf8_filename() {
715        use std::ffi::OsStr;
716        use std::os::unix::ffi::OsStrExt;
717        let dir = TempDir::new().unwrap();
718        let path = dir.path().join(OsStr::from_bytes(b"user-\xff\xfe.key"));
719
720        // Portability probe: the property under test is that the key
721        // store round-trips a non-UTF-8 *filename*, which is only
722        // observable on a filesystem that lets such a name exist. Linux
723        // CI (ext4/xfs/tmpfs) stores the bytes verbatim; macOS APFS/HFS+
724        // reject them at create with EILSEQ ("Illegal byte sequence",
725        // errno 92). Skip where the fs can't hold the name rather than
726        // panicking on a filesystem-encoding limitation we don't control.
727        match std::fs::File::create(&path) {
728            Ok(_) => std::fs::remove_file(&path).expect("remove probe file"),
729            Err(e) => {
730                eprintln!(
731                    "skipping: filesystem rejects non-UTF-8 filenames \
732                     (e.g. macOS APFS EILSEQ): {e}"
733                );
734                return;
735            }
736        }
737
738        let key = UserKey::generate();
739        let fp = key.fingerprint();
740        key.save(&path).expect("save with non-UTF-8 filename");
741        let loaded = UserKey::load(&path).expect("load with non-UTF-8 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            "lossy display must still produce the refuse message: {msg}"
749        );
750    }
751
752    /// Contract pin (macOS): the filesystem-behavior counterpart of
753    /// `save_load_roundtrip_non_utf8_filename`. APFS requires **valid UTF-8**
754    /// filenames, so a non-UTF-8 name cannot exist on disk there; the
755    /// platform-appropriate stressor is an unusual but valid Unicode filename —
756    /// non-ASCII, an emoji, and a combining diacritic. (APFS preserves the byte
757    /// representation as written and performs normalization-*insensitive* lookup,
758    /// so re-opening the exact path we wrote round-trips regardless of form.)
759    ///
760    /// This proves the **filesystem** half of the contract on macOS — save → load
761    /// round-trips, and the refuse-to-overwrite path formats a valid-Unicode path
762    /// and produces its message. It deliberately does NOT exercise lossy
763    /// formatting of *invalid* UTF-8 (this filename is valid UTF-8); that
764    /// formatting/error behavior is covered platform-independently by
765    /// `refuse_overwrite_message_is_lossy_for_a_non_utf8_path` below, and the
766    /// on-disk invalid-UTF-8 case by the Linux/non-macOS test above.
767    #[test]
768    #[cfg(target_os = "macos")]
769    fn save_load_roundtrip_unusual_unicode_filename() {
770        let dir = TempDir::new().unwrap();
771        // café (NFC é), a key emoji, and a bare combining acute accent — all
772        // valid UTF-8 that APFS accepts, stressing path handling with non-ASCII,
773        // multi-byte, and combining sequences.
774        let path = dir.path().join("user-caf\u{00e9}-\u{1f511}-a\u{0301}.key");
775
776        let key = UserKey::generate();
777        let fp = key.fingerprint();
778        key.save(&path)
779            .expect("save with an unusual Unicode filename");
780        let loaded = UserKey::load(&path).expect("load with an unusual Unicode filename");
781        assert_eq!(loaded.fingerprint(), fp);
782
783        let err = UserKey::generate().save(&path).expect_err("must refuse");
784        let msg = err.to_string();
785        assert!(
786            msg.contains("refusing to overwrite existing key at "),
787            "display must still produce the refuse message for a Unicode path: {msg}"
788        );
789    }
790
791    /// Platform-independent pin for the **formatting/error** behavior (as opposed
792    /// to filesystem behavior): the refuse-to-overwrite message renders a
793    /// **non-UTF-8** path lossily and never panics. The invalid `OsStr` is built
794    /// in memory and passed straight to `refuse_overwrite_error` — it is never
795    /// written to disk, so this runs identically on Linux and macOS (where APFS
796    /// would reject the name). This isolates the lossy-`display()` contract from
797    /// any filesystem round-trip.
798    #[test]
799    #[cfg(unix)]
800    fn refuse_overwrite_message_is_lossy_for_a_non_utf8_path() {
801        use std::ffi::OsStr;
802        use std::os::unix::ffi::OsStrExt;
803        let path = Path::new(OsStr::from_bytes(b"user-\xff\xfe.key"));
804        let msg = refuse_overwrite_error(path).to_string();
805        assert!(
806            msg.contains("refusing to overwrite existing key at "),
807            "the refuse message must survive a non-UTF-8 path: {msg}"
808        );
809        assert!(
810            msg.contains('\u{FFFD}'),
811            "non-UTF-8 bytes must render as the U+FFFD replacement char (lossy), not panic: {msg}"
812        );
813    }
814
815    /// Regression test for issue #17 (live TOCTOU race, the literal
816    /// attack in the issue title).
817    ///
818    /// A planter thread races `save` to materialize the path first
819    /// with a dangling symlink. On the fixed code only two syscalls
820    /// can create the path — our open(O_CREAT|O_EXCL) and the
821    /// planter's symlink(2) — and each fails EEXIST if the other won,
822    /// so exactly one wins under every interleaving and the key can
823    /// NEVER appear at the symlink target. The pre-fix code had a
824    /// check-to-use gap: a symlink planted between `exists()` and
825    /// `fs::write` redirected the key to the target and both sides
826    /// "succeeded".
827    ///
828    /// FAILS on the pre-fix implementation (probabilistically per
829    /// iteration, near-certainly across 400).
830    #[test]
831    #[cfg(unix)]
832    fn save_racing_symlink_plant_never_redirects_key() {
833        use std::sync::{Arc, Barrier};
834        const ITERS: usize = 400;
835
836        let dir = TempDir::new().unwrap();
837        for i in 0..ITERS {
838            let path = Arc::new(dir.path().join(format!("race-{i}.key")));
839            let target = Arc::new(dir.path().join(format!("target-{i}.key")));
840            let barrier = Arc::new(Barrier::new(2));
841
842            let saver = {
843                let path = Arc::clone(&path);
844                let barrier = Arc::clone(&barrier);
845                std::thread::spawn(move || {
846                    let key = UserKey::generate();
847                    barrier.wait();
848                    key.save(&path)
849                })
850            };
851            let planter = {
852                let path = Arc::clone(&path);
853                let target = Arc::clone(&target);
854                let barrier = Arc::clone(&barrier);
855                std::thread::spawn(move || {
856                    barrier.wait();
857                    std::os::unix::fs::symlink(&*target, &*path)
858                })
859            };
860
861            let save_res = saver.join().unwrap();
862            let plant_res = planter.join().unwrap();
863
864            assert!(
865                !target.exists(),
866                "key must never be written through a planted symlink (iteration {i})"
867            );
868            assert!(
869                save_res.is_ok() != plant_res.is_ok(),
870                "exactly one of save/plant must win (iteration {i}): \
871                 save={save_res:?} plant_ok={}",
872                plant_res.is_ok()
873            );
874            let meta = std::fs::symlink_metadata(&*path).unwrap();
875            if save_res.is_ok() {
876                assert!(
877                    meta.file_type().is_file(),
878                    "winner save must leave a regular file"
879                );
880                UserKey::load(&path).expect("saved key must be complete and loadable");
881            } else {
882                assert!(
883                    meta.file_type().is_symlink(),
884                    "winner plant must leave the symlink"
885                );
886                if let Err(MeshError::Io(e)) = &save_res {
887                    assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
888                } else {
889                    panic!("losing save must be Io(AlreadyExists), got {save_res:?}");
890                }
891            }
892        }
893    }
894
895    /// Regression test for issue #17 (self-referential symlink,
896    /// deterministic).
897    ///
898    /// O_CREAT|O_EXCL fails EEXIST when the path names a symlink even
899    /// if resolving it would loop, so the fixed code refuses with the
900    /// standard overwrite message. The pre-fix `exists()` followed the
901    /// link, hit ELOOP, returned `false`, and `fs::write` then failed
902    /// with a raw filesystem-loop error — wrong kind, wrong message.
903    ///
904    /// FAILS on the pre-fix implementation.
905    #[test]
906    #[cfg(unix)]
907    fn save_refuses_self_loop_symlink_with_already_exists() {
908        let dir = TempDir::new().unwrap();
909        let path = dir.path().join("loop.key");
910        std::os::unix::fs::symlink(&path, &path).unwrap();
911
912        let err = UserKey::generate().save(&path).expect_err("must refuse");
913        match &err {
914            MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
915            other => panic!("expected Io(AlreadyExists), got {other:?}"),
916        }
917        assert!(
918            err.to_string()
919                .contains("refusing to overwrite existing key at "),
920            "self-loop must surface the standard refusal, got: {err}"
921        );
922    }
923
924    /// Contract pin: concurrent saves to DISTINCT paths under one
925    /// not-yet-existing parent all succeed — `create_dir_all` is
926    /// concurrency-safe (treats EEXIST as success). Guards a future
927    /// refactor to bare `create_dir`, which would race-fail here.
928    #[test]
929    fn save_concurrent_distinct_paths_shared_new_parent_all_win() {
930        use std::sync::{Arc, Barrier};
931        const THREADS: usize = 8;
932
933        let dir = TempDir::new().unwrap();
934        let parent = Arc::new(dir.path().join("deep").join("nest"));
935        let barrier = Arc::new(Barrier::new(THREADS));
936
937        let handles: Vec<_> = (0..THREADS)
938            .map(|t| {
939                let parent = Arc::clone(&parent);
940                let barrier = Arc::clone(&barrier);
941                std::thread::spawn(move || {
942                    let key = UserKey::generate();
943                    let fp = key.fingerprint();
944                    let path = parent.join(format!("k{t}.key"));
945                    barrier.wait();
946                    key.save(&path).expect("distinct-path save must succeed");
947                    (path, fp)
948                })
949            })
950            .collect();
951
952        for h in handles {
953            let (path, fp) = h.join().unwrap();
954            let loaded = UserKey::load(&path).expect("each saved key must load");
955            assert_eq!(loaded.fingerprint(), fp);
956            #[cfg(unix)]
957            {
958                use std::os::unix::fs::PermissionsExt;
959                let mode = std::fs::metadata(&path).unwrap().permissions().mode();
960                assert_eq!(mode & 0o777, 0o600, "expected 0600, got {mode:o}");
961            }
962        }
963    }
964
965    /// Contract pin: a name longer than NAME_MAX must surface as a
966    /// plain Io error — the AlreadyExists-only `map_err` arm must not
967    /// swallow ENAMETOOLONG into the "refusing to overwrite" message.
968    /// (Deliberately does not assert the specific kind; its mapping is
969    /// Rust-version-dependent.)
970    #[test]
971    #[cfg(unix)]
972    fn save_name_too_long_is_plain_io_error_not_refusal() {
973        let dir = TempDir::new().unwrap();
974        let path = dir.path().join("k".repeat(300));
975
976        let err = UserKey::generate().save(&path).expect_err("must fail");
977        match &err {
978            MeshError::Io(e) => {
979                assert_ne!(e.kind(), std::io::ErrorKind::AlreadyExists);
980            }
981            other => panic!("expected Io, got {other:?}"),
982        }
983        assert!(
984            !err.to_string().contains("refusing to overwrite"),
985            "ENAMETOOLONG must not masquerade as the overwrite refusal: {err}"
986        );
987    }
988
989    /// Contract pin: the on-disk format is strict PKCS#8 PEM with LF
990    /// line endings, written exactly once. Guards against a future
991    /// "native line endings" change and against the open-then-write
992    /// sequence double-writing under refactor. Complements
993    /// `roundtrip_save_load_disk`, which only proves loadability.
994    #[test]
995    fn saved_pem_is_strict_pkcs8_with_lf_endings() {
996        let dir = TempDir::new().unwrap();
997        let path = dir.path().join("user.key");
998        let key = UserKey::generate();
999        let fp = key.fingerprint();
1000        key.save(&path).expect("save");
1001
1002        let bytes = std::fs::read(&path).unwrap();
1003        assert!(
1004            bytes.starts_with(b"-----BEGIN PRIVATE KEY-----\n"),
1005            "PEM header missing or not LF-terminated"
1006        );
1007        assert!(
1008            bytes.ends_with(b"-----END PRIVATE KEY-----\n"),
1009            "PEM trailer missing or not LF-terminated"
1010        );
1011        assert!(!bytes.contains(&b'\r'), "PEM must use LF only, found CR");
1012        assert!(
1013            bytes.len() < 512,
1014            "one ed25519 PKCS#8 PEM expected, got {} bytes (double write?)",
1015            bytes.len()
1016        );
1017        let pem = String::from_utf8(bytes).expect("PEM is ASCII");
1018        let signing = SigningKey::from_pkcs8_pem(&pem).expect("strict PKCS#8 parse");
1019        assert_eq!(
1020            Fingerprint::of_bytes(signing.verifying_key().as_bytes()),
1021            fp,
1022            "parsed key must match the saved key"
1023        );
1024    }
1025
1026    /// Contract pin: unicode (multibyte UTF-8) filenames round-trip.
1027    /// Portable companion to the unix-only non-UTF-8 test.
1028    #[test]
1029    fn save_load_roundtrip_unicode_filename() {
1030        let dir = TempDir::new().unwrap();
1031        let path = dir.path().join("clé-ключ-鍵-🔑.key");
1032        let key = UserKey::generate();
1033        let fp = key.fingerprint();
1034        key.save(&path).expect("save with unicode filename");
1035        assert_eq!(UserKey::load(&path).expect("load").fingerprint(), fp);
1036    }
1037
1038    /// Contract pin: an existing but EMPTY file is still refused and
1039    /// left untouched — emptiness must not be mistaken for absence.
1040    #[test]
1041    fn save_refuses_existing_empty_file_untouched() {
1042        let dir = TempDir::new().unwrap();
1043        let path = dir.path().join("user.key");
1044        std::fs::write(&path, b"").unwrap();
1045
1046        let err = UserKey::generate().save(&path).expect_err("must refuse");
1047        assert!(matches!(err, MeshError::Io(_)));
1048        assert_eq!(
1049            std::fs::metadata(&path).unwrap().len(),
1050            0,
1051            "empty file must remain exactly as it was"
1052        );
1053    }
1054}