coven/storage/cloud/
http.rs

1//! HTTP-backed `CloudHome` implementation.
2//!
3//! Talks to a bae-proxy's `/cloud/*` write proxy endpoints.
4//! Requests are authenticated with Ed25519 signatures.
5
6use async_trait::async_trait;
7use reqwest::Client;
8
9use crate::clock::ClockRef;
10use crate::keys::UserKeypair;
11
12use super::{CloudHome, CloudHomeError, CloudHomeJoinInfo};
13
14/// HTTP-backed cloud home that proxies through a bae-proxy.
15pub struct HttpCloudHome {
16    base_url: String,
17    keypair: UserKeypair,
18    client: Client,
19    clock: ClockRef,
20}
21
22impl HttpCloudHome {
23    pub fn new(base_url: String, keypair: UserKeypair, clock: ClockRef) -> Self {
24        Self {
25            base_url: base_url.trim_end_matches('/').to_string(),
26            keypair,
27            client: Client::new(),
28            clock,
29        }
30    }
31
32    /// Build auth headers for a request.
33    fn sign_request(&self, method: &str, path: &str) -> [(&'static str, String); 3] {
34        let timestamp = self.clock.now().timestamp() as u64;
35
36        let message = format!("{}\n{}\n{}", method, path, timestamp);
37        let signature = self.keypair.sign(message.as_bytes());
38
39        [
40            ("X-Bae-Pubkey", hex::encode(self.keypair.public_key)),
41            ("X-Bae-Timestamp", timestamp.to_string()),
42            ("X-Bae-Signature", hex::encode(signature)),
43        ]
44    }
45
46    /// Map an HTTP response to a CloudHomeError for non-success status codes.
47    async fn map_error(key: &str, resp: reqwest::Response) -> CloudHomeError {
48        let status = resp.status();
49        let body = resp
50            .text()
51            .await
52            .unwrap_or_else(|e| format!("<body read failed: {e}>"));
53
54        if status == reqwest::StatusCode::NOT_FOUND {
55            CloudHomeError::NotFound(key.to_string())
56        } else if status == reqwest::StatusCode::UNAUTHORIZED
57            || status == reqwest::StatusCode::FORBIDDEN
58        {
59            CloudHomeError::Storage(format!("unauthorized: {body}"))
60        } else {
61            CloudHomeError::Storage(format!("{status}: {body}"))
62        }
63    }
64}
65
66#[async_trait]
67impl CloudHome for HttpCloudHome {
68    async fn write(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError> {
69        let path = format!("/cloud/{key}");
70        let url = format!("{}{}", self.base_url, path);
71        let headers = self.sign_request("PUT", &path);
72
73        let resp = self
74            .client
75            .put(&url)
76            .header(headers[0].0, &headers[0].1)
77            .header(headers[1].0, &headers[1].1)
78            .header(headers[2].0, &headers[2].1)
79            .body(data)
80            .send()
81            .await
82            .map_err(|e| CloudHomeError::Storage(format!("write {key}: {e}")))?;
83
84        if resp.status().is_success() {
85            Ok(())
86        } else {
87            Err(Self::map_error(key, resp).await)
88        }
89    }
90
91    async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError> {
92        let path = format!("/cloud/{key}");
93        let url = format!("{}{}", self.base_url, path);
94        let headers = self.sign_request("GET", &path);
95
96        let resp = self
97            .client
98            .get(&url)
99            .header(headers[0].0, &headers[0].1)
100            .header(headers[1].0, &headers[1].1)
101            .header(headers[2].0, &headers[2].1)
102            .send()
103            .await
104            .map_err(|e| CloudHomeError::Storage(format!("read {key}: {e}")))?;
105
106        if resp.status().is_success() {
107            let bytes = resp
108                .bytes()
109                .await
110                .map_err(|e| CloudHomeError::Storage(format!("read body {key}: {e}")))?;
111            Ok(bytes.to_vec())
112        } else {
113            Err(Self::map_error(key, resp).await)
114        }
115    }
116
117    async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError> {
118        let path = format!("/cloud/{key}");
119        let url = format!("{}{}", self.base_url, path);
120        let headers = self.sign_request("GET", &path);
121        let range_value = format!("bytes={}-{}", start, end.saturating_sub(1));
122
123        let resp = self
124            .client
125            .get(&url)
126            .header(headers[0].0, &headers[0].1)
127            .header(headers[1].0, &headers[1].1)
128            .header(headers[2].0, &headers[2].1)
129            .header("Range", &range_value)
130            .send()
131            .await
132            .map_err(|e| CloudHomeError::Storage(format!("read_range {key}: {e}")))?;
133
134        if resp.status().is_success() {
135            let bytes = resp
136                .bytes()
137                .await
138                .map_err(|e| CloudHomeError::Storage(format!("read_range body {key}: {e}")))?;
139            Ok(bytes.to_vec())
140        } else {
141            Err(Self::map_error(key, resp).await)
142        }
143    }
144
145    async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError> {
146        let url = format!(
147            "{}/cloud?prefix={}",
148            self.base_url,
149            urlencoding::encode(prefix)
150        );
151        let headers = self.sign_request("GET", "/cloud");
152
153        let resp = self
154            .client
155            .get(&url)
156            .header(headers[0].0, &headers[0].1)
157            .header(headers[1].0, &headers[1].1)
158            .header(headers[2].0, &headers[2].1)
159            .send()
160            .await
161            .map_err(|e| CloudHomeError::Storage(format!("list {prefix}: {e}")))?;
162
163        if resp.status().is_success() {
164            let keys: Vec<String> = resp
165                .json()
166                .await
167                .map_err(|e| CloudHomeError::Storage(format!("list parse {prefix}: {e}")))?;
168            Ok(keys)
169        } else {
170            Err(Self::map_error(prefix, resp).await)
171        }
172    }
173
174    async fn delete(&self, key: &str) -> Result<(), CloudHomeError> {
175        let path = format!("/cloud/{key}");
176        let url = format!("{}{}", self.base_url, path);
177        let headers = self.sign_request("DELETE", &path);
178
179        let resp = self
180            .client
181            .delete(&url)
182            .header(headers[0].0, &headers[0].1)
183            .header(headers[1].0, &headers[1].1)
184            .header(headers[2].0, &headers[2].1)
185            .send()
186            .await
187            .map_err(|e| CloudHomeError::Storage(format!("delete {key}: {e}")))?;
188
189        if resp.status().is_success() {
190            Ok(())
191        } else {
192            Err(Self::map_error(key, resp).await)
193        }
194    }
195
196    async fn exists(&self, key: &str) -> Result<bool, CloudHomeError> {
197        let path = format!("/cloud/{key}");
198        let url = format!("{}{}", self.base_url, path);
199        let headers = self.sign_request("HEAD", &path);
200
201        let resp = self
202            .client
203            .head(&url)
204            .header(headers[0].0, &headers[0].1)
205            .header(headers[1].0, &headers[1].1)
206            .header(headers[2].0, &headers[2].1)
207            .send()
208            .await
209            .map_err(|e| CloudHomeError::Storage(format!("exists {key}: {e}")))?;
210
211        if resp.status() == reqwest::StatusCode::NOT_FOUND {
212            Ok(false)
213        } else if resp.status().is_success() {
214            Ok(true)
215        } else {
216            Err(Self::map_error(key, resp).await)
217        }
218    }
219
220    async fn grant_access(&self, _member_id: &str) -> Result<CloudHomeJoinInfo, CloudHomeError> {
221        Ok(CloudHomeJoinInfo::HttpProxy {
222            url: self.base_url.clone(),
223        })
224    }
225
226    async fn revoke_access(&self, _member_id: &str) -> Result<(), CloudHomeError> {
227        Ok(())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::clock::FixedClock;
235    use crate::keys::{verify_signature, UserKeypair};
236    use chrono::{DateTime, Utc};
237    use std::sync::Arc;
238
239    fn test_keypair() -> UserKeypair {
240        UserKeypair::generate()
241    }
242
243    /// Fixed instant the test clock returns, so signed timestamps are
244    /// deterministic.
245    fn fixed_instant() -> DateTime<Utc> {
246        DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
247            .unwrap()
248            .with_timezone(&Utc)
249    }
250
251    fn test_cloud_home(base_url: &str, keypair: UserKeypair) -> HttpCloudHome {
252        HttpCloudHome::new(
253            base_url.to_string(),
254            keypair,
255            Arc::new(FixedClock(fixed_instant())),
256        )
257    }
258
259    #[test]
260    fn sign_request_produces_three_headers() {
261        let kp = test_keypair();
262        let cloud_home = test_cloud_home("https://example.com", kp);
263        let headers = cloud_home.sign_request("PUT", "/cloud/changes/dev1/42.enc");
264
265        assert_eq!(headers[0].0, "X-Bae-Pubkey");
266        assert_eq!(headers[1].0, "X-Bae-Timestamp");
267        assert_eq!(headers[2].0, "X-Bae-Signature");
268
269        // Pubkey is hex-encoded 32-byte key = 64 hex chars
270        assert_eq!(headers[0].1.len(), 64);
271
272        // Timestamp is the injected clock's unix seconds.
273        let ts: u64 = headers[1].1.parse().unwrap();
274        assert_eq!(ts, fixed_instant().timestamp() as u64);
275
276        // Signature is hex-encoded 64-byte signature = 128 hex chars
277        assert_eq!(headers[2].1.len(), 128);
278    }
279
280    #[test]
281    fn sign_request_signature_verifies() {
282        let kp = test_keypair();
283        let cloud_home = test_cloud_home("https://example.com", kp.clone());
284        let headers = cloud_home.sign_request("GET", "/cloud/some/key");
285
286        let message = format!("GET\n/cloud/some/key\n{}", headers[1].1);
287        let sig_bytes: [u8; crate::keys::SIGN_BYTES] =
288            hex::decode(&headers[2].1).unwrap().try_into().unwrap();
289
290        assert!(verify_signature(
291            &sig_bytes,
292            message.as_bytes(),
293            &kp.public_key
294        ));
295    }
296
297    #[test]
298    fn sign_request_different_methods_produce_different_signatures() {
299        let kp = test_keypair();
300        let cloud_home = test_cloud_home("https://example.com", kp);
301
302        let h1 = cloud_home.sign_request("GET", "/cloud/key");
303        let h2 = cloud_home.sign_request("PUT", "/cloud/key");
304
305        // The fixed clock gives both requests the same timestamp, so the
306        // signatures differ purely because the method is part of the signed
307        // message.
308        assert_eq!(h1[1].1, h2[1].1);
309        assert_ne!(h1[2].1, h2[2].1);
310    }
311
312    #[test]
313    fn base_url_trailing_slash_stripped() {
314        let kp = test_keypair();
315        let cloud_home = test_cloud_home("https://example.com/", kp);
316        assert_eq!(cloud_home.base_url, "https://example.com");
317    }
318
319    #[test]
320    fn grant_access_returns_join_info() {
321        let kp = test_keypair();
322        let cloud_home = test_cloud_home("https://example.com", kp);
323
324        let result = tokio::runtime::Runtime::new()
325            .unwrap()
326            .block_on(cloud_home.grant_access("some-member"));
327
328        assert!(result.is_ok());
329        let join_info = result.unwrap();
330        match join_info {
331            CloudHomeJoinInfo::HttpProxy { url } => {
332                assert_eq!(url, "https://example.com");
333            }
334            _ => panic!("expected HttpProxy join info"),
335        }
336    }
337
338    #[test]
339    fn revoke_access_succeeds() {
340        let kp = test_keypair();
341        let cloud_home = test_cloud_home("https://example.com", kp);
342
343        let result = tokio::runtime::Runtime::new()
344            .unwrap()
345            .block_on(cloud_home.revoke_access("some-member"));
346
347        assert!(result.is_ok());
348    }
349}