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        fs::write(path, bytes).map_err(|source| GatewayPeerKeyError::Io {
110            path: path.to_path_buf(),
111            source,
112        })?;
113        // Restrict permissions on Unix. Best-effort — failure to chmod is
114        // not fatal because the secret is already on disk and an operator
115        // can chmod it themselves.
116        #[cfg(unix)]
117        {
118            use std::os::unix::fs::PermissionsExt;
119            let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
120        }
121        Ok(())
122    }
123
124    /// 32-byte Ed25519 verifying key, suitable for stamping onto a
125    /// `TrustedPeerDescriptor`.
126    pub fn pubkey_bytes(&self) -> [u8; 32] {
127        self.inner.public.to_bytes()
128    }
129
130    /// Borrow the verifying key.
131    pub fn verifying_key(&self) -> &VerifyingKey {
132        &self.inner.public
133    }
134
135    /// Borrow the signing key. Tests use this to build inbound envelopes
136    /// that need to be signed by the peer; production callers should not
137    /// reach for this directly.
138    pub fn signing_key(&self) -> &SigningKey {
139        &self.inner.signing
140    }
141
142    /// Standard-base64 encoding of the 32-byte verifying key. Used by the
143    /// `mobkit/peer_pubkey` RPC and bootstrap-by-fetch flows.
144    pub fn pubkey_b64(&self) -> String {
145        BASE64.encode(self.inner.public.to_bytes())
146    }
147}
148
149impl std::fmt::Debug for GatewayPeerKeys {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct("GatewayPeerKeys")
152            .field("pubkey_b64", &self.pubkey_b64())
153            .finish()
154    }
155}
156
157/// Errors loading or persisting the gateway keypair.
158#[derive(Debug)]
159pub enum GatewayPeerKeyError {
160    /// The on-disk key file was the wrong size — typically corruption or
161    /// a non-32-byte format that needs manual cleanup.
162    InvalidLength { path: PathBuf, actual: usize },
163    /// Filesystem failure (read / write / mkdir).
164    Io {
165        path: PathBuf,
166        source: std::io::Error,
167    },
168}
169
170impl std::fmt::Display for GatewayPeerKeyError {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        match self {
173            Self::InvalidLength { path, actual } => write!(
174                f,
175                "gateway peer key file {} is {actual} bytes (expected 32)",
176                path.display()
177            ),
178            Self::Io { path, source } => {
179                write!(
180                    f,
181                    "gateway peer key io error for {}: {source}",
182                    path.display()
183                )
184            }
185        }
186    }
187}
188
189impl std::error::Error for GatewayPeerKeyError {
190    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191        match self {
192            Self::Io { source, .. } => Some(source),
193            Self::InvalidLength { .. } => None,
194        }
195    }
196}
197
198/// Decode a `"<base64>"` (32-byte) Ed25519 pubkey string.
199///
200/// Used by both the contact-directory loader (where pubkey lives in TOML)
201/// and clients consuming the `mobkit/peer_pubkey` RPC response. Accepts
202/// the bare standard-base64 form; the `ed25519:` prefix used inside
203/// meerkat-comms trust files is stripped if present so callers can paste
204/// either shape.
205pub fn decode_pubkey_b64(text: &str) -> Result<[u8; 32], PubkeyDecodeError> {
206    let trimmed = text.trim();
207    let body = trimmed.strip_prefix("ed25519:").unwrap_or(trimmed);
208    let bytes = BASE64.decode(body).map_err(PubkeyDecodeError::Base64)?;
209    if bytes.len() != 32 {
210        return Err(PubkeyDecodeError::WrongLength(bytes.len()));
211    }
212    let mut out = [0u8; 32];
213    out.copy_from_slice(&bytes);
214    Ok(out)
215}
216
217/// Errors decoding a base64-encoded Ed25519 pubkey.
218#[derive(Debug)]
219pub enum PubkeyDecodeError {
220    Base64(base64::DecodeError),
221    WrongLength(usize),
222}
223
224impl std::fmt::Display for PubkeyDecodeError {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        match self {
227            Self::Base64(e) => write!(f, "invalid pubkey base64: {e}"),
228            Self::WrongLength(n) => write!(f, "pubkey must be 32 bytes, got {n}"),
229        }
230    }
231}
232
233impl std::error::Error for PubkeyDecodeError {}
234
235#[cfg(test)]
236#[allow(clippy::unwrap_used, clippy::expect_used)]
237mod tests {
238    use super::*;
239    use tempfile::tempdir;
240
241    #[test]
242    fn ephemeral_minted_keys_are_unique() {
243        let a = GatewayPeerKeys::ephemeral();
244        let b = GatewayPeerKeys::ephemeral();
245        assert_ne!(
246            a.pubkey_bytes(),
247            b.pubkey_bytes(),
248            "fresh ephemeral keys must be distinct"
249        );
250    }
251
252    #[test]
253    fn load_or_create_persists_and_round_trips() {
254        let dir = tempdir().expect("tempdir");
255        let first = GatewayPeerKeys::load_or_create(dir.path()).expect("create");
256        let pubkey = first.pubkey_bytes();
257        // Second call returns the persisted key, not a fresh one.
258        let second = GatewayPeerKeys::load_or_create(dir.path()).expect("load");
259        assert_eq!(second.pubkey_bytes(), pubkey, "key must persist");
260        assert!(dir.path().join(KEY_FILE_NAME).exists());
261    }
262
263    #[test]
264    fn load_or_create_rejects_short_file() {
265        let dir = tempdir().expect("tempdir");
266        std::fs::write(dir.path().join(KEY_FILE_NAME), b"too short").expect("write");
267        let err = GatewayPeerKeys::load_or_create(dir.path()).expect_err("short file must fail");
268        assert!(matches!(err, GatewayPeerKeyError::InvalidLength { .. }));
269    }
270
271    #[test]
272    fn pubkey_b64_round_trips() {
273        let keys = GatewayPeerKeys::ephemeral();
274        let encoded = keys.pubkey_b64();
275        let decoded = decode_pubkey_b64(&encoded).expect("decode");
276        assert_eq!(decoded, keys.pubkey_bytes());
277    }
278
279    #[test]
280    fn decode_strips_ed25519_prefix() {
281        let keys = GatewayPeerKeys::ephemeral();
282        let prefixed = format!("ed25519:{}", keys.pubkey_b64());
283        let decoded = decode_pubkey_b64(&prefixed).expect("decode prefixed");
284        assert_eq!(decoded, keys.pubkey_bytes());
285    }
286
287    #[test]
288    fn decode_rejects_wrong_length() {
289        let err = decode_pubkey_b64("aGVsbG8=").expect_err("must reject 5-byte payload");
290        assert!(matches!(err, PubkeyDecodeError::WrongLength(_)));
291    }
292}