Skip to main content

meerkat_mobkit/auth/
peer_keys.rs

1//! Gateway Ed25519 keypair — long-lived signing identity for cross-process
2//! mob peering.
3//!
4//! The mobkit gateway needs a stable Ed25519 keypair so other gateways can
5//! validate the signatures on envelopes it sends across TCP / UDS. Inproc
6//! peering is unaffected: the in-process router authorises by identity map
7//! and never inspects signatures, so an inproc-only gateway can stay on an
8//! ephemeral key without persistence.
9//!
10//! Two construction modes:
11//!
12//! * [`GatewayPeerKeys::load_or_create`] — pass a state directory. The key
13//!   is read from `<state_dir>/peer_key.ed25519` (raw 32-byte secret) if
14//!   present; otherwise a fresh keypair is minted and persisted with
15//!   `0o600` permissions on Unix.
16//! * [`GatewayPeerKeys::ephemeral`] — mint a fresh keypair held in memory
17//!   only. Tests and gateway processes that do not own a state directory
18//!   use this.
19//!
20//! The 32-byte raw seed format keeps the on-disk key drop-in compatible
21//! with `ed25519-dalek::SigningKey::from_bytes` and lets ops sites swap
22//! a key in by writing 32 bytes — no PEM/CBOR parsing required.
23
24use std::fs;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27
28use base64::Engine;
29use base64::engine::general_purpose::STANDARD as BASE64;
30use ed25519_dalek::{SecretKey, SigningKey, VerifyingKey};
31
32/// File name used inside the gateway state directory.
33pub const KEY_FILE_NAME: &str = "peer_key.ed25519";
34
35/// A long-lived Ed25519 signing identity for the local gateway.
36///
37/// Cheap to clone (the underlying `SigningKey` is small and `Arc`-shared
38/// at the wrapper level). Treat as opaque — callers should reach for
39/// [`Self::verifying_key`] / [`Self::pubkey_bytes`] / [`Self::pubkey_b64`]
40/// rather than touching the secret material.
41#[derive(Clone)]
42pub struct GatewayPeerKeys {
43    inner: Arc<GatewayPeerKeysInner>,
44}
45
46struct GatewayPeerKeysInner {
47    signing: SigningKey,
48    public: VerifyingKey,
49}
50
51impl GatewayPeerKeys {
52    /// Load the keypair from `<state_dir>/peer_key.ed25519` if present, or
53    /// mint a fresh one and persist it.
54    ///
55    /// The state directory is created if it does not exist. On Unix the
56    /// key file is written with `0o600` so other users on the host cannot
57    /// impersonate this gateway.
58    pub fn load_or_create(state_dir: &Path) -> Result<Self, GatewayPeerKeyError> {
59        let key_path = state_dir.join(KEY_FILE_NAME);
60        if key_path.exists() {
61            return Self::load(&key_path);
62        }
63        fs::create_dir_all(state_dir).map_err(|source| GatewayPeerKeyError::Io {
64            path: state_dir.to_path_buf(),
65            source,
66        })?;
67        let keys = Self::ephemeral();
68        keys.persist_to(&key_path)?;
69        Ok(keys)
70    }
71
72    /// Mint a fresh keypair held in memory only.
73    ///
74    /// Intended for tests and gateway profiles that do not own a state
75    /// directory. The pubkey is still stable for the lifetime of the
76    /// process — peers that fetch it via `mobkit/peer_pubkey` will see
77    /// a consistent identity until restart.
78    pub fn ephemeral() -> Self {
79        let mut rng = rand_core::OsRng;
80        let signing = SigningKey::generate(&mut rng);
81        let public = signing.verifying_key();
82        Self {
83            inner: Arc::new(GatewayPeerKeysInner { signing, public }),
84        }
85    }
86
87    fn load(path: &Path) -> Result<Self, GatewayPeerKeyError> {
88        let bytes = fs::read(path).map_err(|source| GatewayPeerKeyError::Io {
89            path: path.to_path_buf(),
90            source,
91        })?;
92        if bytes.len() != 32 {
93            return Err(GatewayPeerKeyError::InvalidLength {
94                path: path.to_path_buf(),
95                actual: bytes.len(),
96            });
97        }
98        let mut secret: SecretKey = [0u8; 32];
99        secret.copy_from_slice(&bytes);
100        let signing = SigningKey::from_bytes(&secret);
101        let public = signing.verifying_key();
102        Ok(Self {
103            inner: Arc::new(GatewayPeerKeysInner { signing, public }),
104        })
105    }
106
107    fn persist_to(&self, path: &Path) -> Result<(), GatewayPeerKeyError> {
108        let bytes = self.inner.signing.to_bytes();
109        // On Unix, create the file with mode 0o600 ATOMICALLY (the mode is set
110        // at creation, before any bytes are written), so the 32-byte Ed25519
111        // secret seed is never momentarily world/group-readable. `fs::write`
112        // would create with `0o666 & !umask` (typically 0o644) and only chmod
113        // afterwards, leaving a window in which a co-tenant could read the
114        // signing key and impersonate this gateway across TCP/UDS.
115        #[cfg(unix)]
116        {
117            use std::io::Write;
118            use std::os::unix::fs::OpenOptionsExt;
119            let mut file = fs::OpenOptions::new()
120                .write(true)
121                .create_new(true)
122                .mode(0o600)
123                .open(path)
124                .map_err(|source| GatewayPeerKeyError::Io {
125                    path: path.to_path_buf(),
126                    source,
127                })?;
128            file.write_all(&bytes)
129                .map_err(|source| GatewayPeerKeyError::Io {
130                    path: path.to_path_buf(),
131                    source,
132                })?;
133        }
134        #[cfg(not(unix))]
135        {
136            fs::write(path, bytes).map_err(|source| GatewayPeerKeyError::Io {
137                path: path.to_path_buf(),
138                source,
139            })?;
140        }
141        Ok(())
142    }
143
144    /// 32-byte Ed25519 verifying key, suitable for stamping onto a
145    /// `TrustedPeerDescriptor`.
146    pub fn pubkey_bytes(&self) -> [u8; 32] {
147        self.inner.public.to_bytes()
148    }
149
150    /// Borrow the verifying key.
151    pub fn verifying_key(&self) -> &VerifyingKey {
152        &self.inner.public
153    }
154
155    /// Borrow the signing key. Tests use this to build inbound envelopes
156    /// that need to be signed by the peer; production callers should not
157    /// reach for this directly.
158    pub fn signing_key(&self) -> &SigningKey {
159        &self.inner.signing
160    }
161
162    /// Standard-base64 encoding of the 32-byte verifying key. Used by the
163    /// `mobkit/peer_pubkey` RPC and bootstrap-by-fetch flows.
164    pub fn pubkey_b64(&self) -> String {
165        BASE64.encode(self.inner.public.to_bytes())
166    }
167}
168
169impl std::fmt::Debug for GatewayPeerKeys {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        f.debug_struct("GatewayPeerKeys")
172            .field("pubkey_b64", &self.pubkey_b64())
173            .finish()
174    }
175}
176
177/// Errors loading or persisting the gateway keypair.
178#[derive(Debug)]
179pub enum GatewayPeerKeyError {
180    /// The on-disk key file was the wrong size — typically corruption or
181    /// a non-32-byte format that needs manual cleanup.
182    InvalidLength { path: PathBuf, actual: usize },
183    /// Filesystem failure (read / write / mkdir).
184    Io {
185        path: PathBuf,
186        source: std::io::Error,
187    },
188}
189
190impl std::fmt::Display for GatewayPeerKeyError {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        match self {
193            Self::InvalidLength { path, actual } => write!(
194                f,
195                "gateway peer key file {} is {actual} bytes (expected 32)",
196                path.display()
197            ),
198            Self::Io { path, source } => {
199                write!(
200                    f,
201                    "gateway peer key io error for {}: {source}",
202                    path.display()
203                )
204            }
205        }
206    }
207}
208
209impl std::error::Error for GatewayPeerKeyError {
210    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
211        match self {
212            Self::Io { source, .. } => Some(source),
213            Self::InvalidLength { .. } => None,
214        }
215    }
216}
217
218/// Decode a `"<base64>"` (32-byte) Ed25519 pubkey string.
219///
220/// Used by both the contact-directory loader (where pubkey lives in TOML)
221/// and clients consuming the `mobkit/peer_pubkey` RPC response. Accepts
222/// the bare standard-base64 form; the `ed25519:` prefix used inside
223/// meerkat-comms trust files is stripped if present so callers can paste
224/// either shape.
225pub fn decode_pubkey_b64(text: &str) -> Result<[u8; 32], PubkeyDecodeError> {
226    let trimmed = text.trim();
227    let body = trimmed.strip_prefix("ed25519:").unwrap_or(trimmed);
228    let bytes = BASE64.decode(body).map_err(PubkeyDecodeError::Base64)?;
229    if bytes.len() != 32 {
230        return Err(PubkeyDecodeError::WrongLength(bytes.len()));
231    }
232    let mut out = [0u8; 32];
233    out.copy_from_slice(&bytes);
234    Ok(out)
235}
236
237/// Errors decoding a base64-encoded Ed25519 pubkey.
238#[derive(Debug)]
239pub enum PubkeyDecodeError {
240    Base64(base64::DecodeError),
241    WrongLength(usize),
242}
243
244impl std::fmt::Display for PubkeyDecodeError {
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        match self {
247            Self::Base64(e) => write!(f, "invalid pubkey base64: {e}"),
248            Self::WrongLength(n) => write!(f, "pubkey must be 32 bytes, got {n}"),
249        }
250    }
251}
252
253impl std::error::Error for PubkeyDecodeError {}
254
255#[cfg(test)]
256#[allow(clippy::unwrap_used, clippy::expect_used)]
257mod tests {
258    use super::*;
259    use tempfile::tempdir;
260
261    #[test]
262    fn ephemeral_minted_keys_are_unique() {
263        let a = GatewayPeerKeys::ephemeral();
264        let b = GatewayPeerKeys::ephemeral();
265        assert_ne!(
266            a.pubkey_bytes(),
267            b.pubkey_bytes(),
268            "fresh ephemeral keys must be distinct"
269        );
270    }
271
272    #[test]
273    fn load_or_create_persists_and_round_trips() {
274        let dir = tempdir().expect("tempdir");
275        let first = GatewayPeerKeys::load_or_create(dir.path()).expect("create");
276        let pubkey = first.pubkey_bytes();
277        // Second call returns the persisted key, not a fresh one.
278        let second = GatewayPeerKeys::load_or_create(dir.path()).expect("load");
279        assert_eq!(second.pubkey_bytes(), pubkey, "key must persist");
280        assert!(dir.path().join(KEY_FILE_NAME).exists());
281    }
282
283    #[test]
284    fn load_or_create_rejects_short_file() {
285        let dir = tempdir().expect("tempdir");
286        std::fs::write(dir.path().join(KEY_FILE_NAME), b"too short").expect("write");
287        let err = GatewayPeerKeys::load_or_create(dir.path()).expect_err("short file must fail");
288        assert!(matches!(err, GatewayPeerKeyError::InvalidLength { .. }));
289    }
290
291    #[cfg(unix)]
292    #[test]
293    fn persisted_secret_key_is_created_0o600_not_world_readable() {
294        // Regression: the secret seed must be created with mode 0o600 so it is
295        // never momentarily world/group-readable (no create-then-chmod window).
296        use std::os::unix::fs::PermissionsExt;
297        let dir = tempdir().expect("tempdir");
298        GatewayPeerKeys::load_or_create(dir.path()).expect("create");
299        let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
300            .expect("key metadata")
301            .permissions()
302            .mode()
303            & 0o777;
304        assert_eq!(
305            mode, 0o600,
306            "peer secret key file must be 0o600, got {mode:o}"
307        );
308    }
309
310    #[test]
311    fn pubkey_b64_round_trips() {
312        let keys = GatewayPeerKeys::ephemeral();
313        let encoded = keys.pubkey_b64();
314        let decoded = decode_pubkey_b64(&encoded).expect("decode");
315        assert_eq!(decoded, keys.pubkey_bytes());
316    }
317
318    #[test]
319    fn decode_strips_ed25519_prefix() {
320        let keys = GatewayPeerKeys::ephemeral();
321        let prefixed = format!("ed25519:{}", keys.pubkey_b64());
322        let decoded = decode_pubkey_b64(&prefixed).expect("decode prefixed");
323        assert_eq!(decoded, keys.pubkey_bytes());
324    }
325
326    #[test]
327    fn decode_rejects_wrong_length() {
328        let err = decode_pubkey_b64("aGVsbG8=").expect_err("must reject 5-byte payload");
329        assert!(matches!(err, PubkeyDecodeError::WrongLength(_)));
330    }
331}