meerkat_mobkit/auth/
peer_keys.rs1use 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
32pub const KEY_FILE_NAME: &str = "peer_key.ed25519";
34
35#[derive(Clone)]
42pub struct GatewayPeerKeys {
43 inner: Arc<GatewayPeerKeysInner>,
44}
45
46struct GatewayPeerKeysInner {
47 signing: SigningKey,
48 public: VerifyingKey,
49}
50
51impl GatewayPeerKeys {
52 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 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 #[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 pub fn pubkey_bytes(&self) -> [u8; 32] {
147 self.inner.public.to_bytes()
148 }
149
150 pub fn verifying_key(&self) -> &VerifyingKey {
152 &self.inner.public
153 }
154
155 pub fn signing_key(&self) -> &SigningKey {
159 &self.inner.signing
160 }
161
162 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#[derive(Debug)]
179pub enum GatewayPeerKeyError {
180 InvalidLength { path: PathBuf, actual: usize },
183 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
218pub 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#[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 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 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}