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