Skip to main content

agent_mesh_protocol/
user_key.rs

1//! [`UserKey`] — the per-user root of trust.
2//!
3//! Every agent-mesh participant has exactly one `UserKey` (an ed25519
4//! keypair). All other identities — agent keys, GitHub bindings —
5//! derive their authority from this one signature. The private half
6//! lives on disk in PKCS#8 PEM with `0600` permissions; the public
7//! half is what peers compare against [`Fingerprint`]s.
8
9use crate::fingerprint::Fingerprint;
10use crate::{MeshError, Result};
11use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
12use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey};
13use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
14use rand::rngs::OsRng;
15use serde::{Deserialize, Serialize};
16use std::path::Path;
17use zeroize::Zeroize;
18
19/// A user-level ed25519 keypair. Root of trust for an agent mesh.
20///
21/// The private half is held in memory by this struct and zeroized on
22/// drop. Use [`save`](Self::save) to persist to disk (refuses to
23/// overwrite existing files) and [`load`](Self::load) to rehydrate.
24pub struct UserKey {
25    signing: SigningKey,
26}
27
28impl UserKey {
29    /// Generate a fresh user key from the operating system RNG.
30    #[must_use]
31    pub fn generate() -> Self {
32        let mut csprng = OsRng;
33        let signing = SigningKey::generate(&mut csprng);
34        Self { signing }
35    }
36
37    /// Public verifying half of the key — safe to share with peers.
38    #[must_use]
39    pub fn public(&self) -> UserPublic {
40        UserPublic {
41            verifying: self.signing.verifying_key(),
42        }
43    }
44
45    /// BLAKE3 fingerprint of the public key bytes.
46    #[must_use]
47    pub fn fingerprint(&self) -> Fingerprint {
48        self.public().fingerprint()
49    }
50
51    /// Sign an arbitrary message with the user's root key.
52    ///
53    /// In practice this is called sparingly — typically just to
54    /// issue agent certificates and the one-time GitHub binding.
55    pub fn sign(&self, message: &[u8]) -> Signature {
56        self.signing.sign(message)
57    }
58
59    /// Save the private key to disk in PKCS#8 PEM format.
60    ///
61    /// Refuses to overwrite an existing file (returns
62    /// [`MeshError::Io`] with `AlreadyExists`). On Unix systems the
63    /// resulting file is `chmod 0600`. The parent directory is
64    /// created if it doesn't exist.
65    pub fn save(&self, path: &Path) -> Result<()> {
66        if path.exists() {
67            return Err(MeshError::Io(std::io::Error::new(
68                std::io::ErrorKind::AlreadyExists,
69                format!("refusing to overwrite existing key at {}", path.display()),
70            )));
71        }
72        if let Some(parent) = path.parent() {
73            if !parent.as_os_str().is_empty() {
74                std::fs::create_dir_all(parent)?;
75            }
76        }
77        let pem = self
78            .signing
79            .to_pkcs8_pem(LineEnding::LF)
80            .map_err(|e| MeshError::InvalidKey(e.to_string()))?;
81        std::fs::write(path, pem.as_bytes())?;
82        #[cfg(unix)]
83        {
84            use std::os::unix::fs::PermissionsExt;
85            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
86        }
87        Ok(())
88    }
89
90    /// Load a private key previously written by [`save`](Self::save).
91    pub fn load(path: &Path) -> Result<Self> {
92        let pem = std::fs::read_to_string(path)?;
93        let signing =
94            SigningKey::from_pkcs8_pem(&pem).map_err(|e| MeshError::InvalidKey(e.to_string()))?;
95        Ok(Self { signing })
96    }
97}
98
99impl std::fmt::Debug for UserKey {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        // Deliberately do not print the private key bytes.
102        f.debug_struct("UserKey")
103            .field("fingerprint", &self.fingerprint())
104            .finish_non_exhaustive()
105    }
106}
107
108impl Drop for UserKey {
109    fn drop(&mut self) {
110        // Best-effort zeroize of the in-memory keypair. The dalek
111        // type itself zeroizes on drop too, but we explicitly scrub
112        // the byte copy we hand back to ourselves.
113        let mut bytes = self.signing.to_bytes();
114        bytes.zeroize();
115    }
116}
117
118/// Public verifying half of a [`UserKey`]. Cheap to clone, safe to
119/// share, and the thing peers actually exchange.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct UserPublic {
122    #[serde(with = "verifying_key_serde")]
123    pub verifying: VerifyingKey,
124}
125
126impl UserPublic {
127    /// BLAKE3 fingerprint of the underlying 32-byte ed25519 public
128    /// key.
129    #[must_use]
130    pub fn fingerprint(&self) -> Fingerprint {
131        Fingerprint::of_bytes(self.verifying.as_bytes())
132    }
133
134    /// Verify a signature was produced by this user's private key
135    /// over `message`.
136    pub fn verify(&self, message: &[u8], signature: &Signature) -> Result<()> {
137        self.verifying
138            .verify(message, signature)
139            .map_err(|_| MeshError::BadSignature)
140    }
141
142    /// Raw 32-byte ed25519 public key.
143    #[must_use]
144    pub fn as_bytes(&self) -> [u8; 32] {
145        *self.verifying.as_bytes()
146    }
147}
148
149mod verifying_key_serde {
150    use ed25519_dalek::VerifyingKey;
151    use serde::{Deserialize, Deserializer, Serialize, Serializer};
152
153    pub fn serialize<S: Serializer>(key: &VerifyingKey, ser: S) -> Result<S::Ok, S::Error> {
154        let bytes: &[u8] = key.as_bytes();
155        bytes.serialize(ser)
156    }
157
158    pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<VerifyingKey, D::Error> {
159        let bytes: Vec<u8> = Vec::deserialize(de)?;
160        if bytes.len() != 32 {
161            return Err(serde::de::Error::custom("expected 32 bytes"));
162        }
163        let mut arr = [0u8; 32];
164        arr.copy_from_slice(&bytes);
165        VerifyingKey::from_bytes(&arr).map_err(serde::de::Error::custom)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use tempfile::TempDir;
173
174    #[test]
175    fn generate_different_keys() {
176        let a = UserKey::generate();
177        let b = UserKey::generate();
178        assert_ne!(
179            a.fingerprint(),
180            b.fingerprint(),
181            "two fresh keys must not collide"
182        );
183    }
184
185    #[test]
186    fn roundtrip_save_load_disk() {
187        let dir = TempDir::new().unwrap();
188        let path = dir.path().join("user.key");
189        let key = UserKey::generate();
190        let fp = key.fingerprint();
191        key.save(&path).expect("save");
192        let loaded = UserKey::load(&path).expect("load");
193        assert_eq!(loaded.fingerprint(), fp);
194    }
195
196    #[test]
197    fn save_refuses_overwrite() {
198        let dir = TempDir::new().unwrap();
199        let path = dir.path().join("user.key");
200        let key = UserKey::generate();
201        key.save(&path).expect("first save");
202        let key2 = UserKey::generate();
203        let err = key2.save(&path).expect_err("must refuse");
204        match err {
205            MeshError::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists),
206            other => panic!("expected Io(AlreadyExists), got {other:?}"),
207        }
208    }
209
210    #[test]
211    fn save_creates_parent_directory() {
212        let dir = TempDir::new().unwrap();
213        let path = dir.path().join("nested").join("dir").join("user.key");
214        UserKey::generate().save(&path).expect("save with mkdir -p");
215        assert!(path.exists());
216    }
217
218    #[test]
219    #[cfg(unix)]
220    fn save_sets_0600_permissions() {
221        use std::os::unix::fs::PermissionsExt;
222        let dir = TempDir::new().unwrap();
223        let path = dir.path().join("user.key");
224        UserKey::generate().save(&path).expect("save");
225        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
226        assert_eq!(mode & 0o777, 0o600, "expected 0600, got {mode:o}");
227    }
228
229    #[test]
230    fn sign_verify() {
231        let key = UserKey::generate();
232        let pubk = key.public();
233        let msg = b"hello agent-mesh";
234        let sig = key.sign(msg);
235        pubk.verify(msg, &sig).expect("verify own signature");
236    }
237
238    #[test]
239    fn wrong_message_fails_verify() {
240        let key = UserKey::generate();
241        let pubk = key.public();
242        let sig = key.sign(b"original");
243        let err = pubk.verify(b"tampered", &sig).expect_err("must fail");
244        assert!(matches!(err, MeshError::BadSignature));
245    }
246
247    #[test]
248    fn fingerprint_stable_across_loads() {
249        let dir = TempDir::new().unwrap();
250        let path = dir.path().join("user.key");
251        let key = UserKey::generate();
252        let fp1 = key.fingerprint();
253        key.save(&path).unwrap();
254        drop(key);
255        let loaded = UserKey::load(&path).unwrap();
256        let fp2 = loaded.fingerprint();
257        assert_eq!(fp1, fp2);
258    }
259
260    #[test]
261    fn serde_roundtrip_public() {
262        let key = UserKey::generate();
263        let pubk = key.public();
264        let json = serde_json::to_string(&pubk).unwrap();
265        let parsed: UserPublic = serde_json::from_str(&json).unwrap();
266        assert_eq!(parsed, pubk);
267        assert_eq!(parsed.fingerprint(), pubk.fingerprint());
268    }
269
270    #[test]
271    fn public_as_bytes_is_32() {
272        let key = UserKey::generate();
273        let bytes = key.public().as_bytes();
274        assert_eq!(bytes.len(), 32);
275    }
276
277    #[test]
278    fn load_fails_on_missing_file() {
279        let dir = TempDir::new().unwrap();
280        let path = dir.path().join("nope.key");
281        let err = UserKey::load(&path).expect_err("must fail");
282        assert!(matches!(err, MeshError::Io(_)));
283    }
284
285    #[test]
286    fn load_fails_on_garbage_file() {
287        let dir = TempDir::new().unwrap();
288        let path = dir.path().join("garbage");
289        std::fs::write(&path, b"not a pem").unwrap();
290        let err = UserKey::load(&path).expect_err("must fail");
291        assert!(matches!(err, MeshError::InvalidKey(_)));
292    }
293}