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 fs::write(path, bytes).map_err(|source| GatewayPeerKeyError::Io {
110 path: path.to_path_buf(),
111 source,
112 })?;
113 #[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 pub fn pubkey_bytes(&self) -> [u8; 32] {
127 self.inner.public.to_bytes()
128 }
129
130 pub fn verifying_key(&self) -> &VerifyingKey {
132 &self.inner.public
133 }
134
135 pub fn signing_key(&self) -> &SigningKey {
139 &self.inner.signing
140 }
141
142 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#[derive(Debug)]
159pub enum GatewayPeerKeyError {
160 InvalidLength { path: PathBuf, actual: usize },
163 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
198pub 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#[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 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}