Skip to main content

bitwarden_importers/importers/onepassword/access/
rest.rs

1//! reqwest wrapper: identity headers, the MAC signing hook, encrypted GET/POST, and error parsing.
2
3use rand::Rng;
4use reqwest::{
5    Method,
6    header::{HeaderMap, HeaderName, HeaderValue},
7};
8use serde::de::DeserializeOwned;
9use serde_json::Value;
10
11use super::{
12    error::OnePasswordError,
13    mac::MacSigner,
14    opdata::{AesKey, Encrypted},
15    wire::{EncryptedEnvelope, ErrorResponse, FailureReason},
16};
17
18const CLIENT_HEADER: &str = "x-agilebits-client";
19const USER_AGENT_HEADER: &str = "user-agent";
20const OP_USER_AGENT_HEADER: &str = "op-user-agent";
21const SESSION_ID_HEADER: &str = "x-agilebits-session-id";
22const MAC_HEADER: &str = "x-agilebits-mac";
23const IV_SIZE: usize = 12;
24
25/// An HTTP client bound to a base URL, carrying the 1Password identity headers and an optional MAC
26/// signer. Requests are signed automatically once a signer is attached.
27pub(super) struct RestClient {
28    http: reqwest::Client,
29    base_url: String,
30    headers: HeaderMap,
31    signer: Option<MacSigner>,
32}
33
34impl RestClient {
35    /// Builds the base client with the identity headers derived from `ClientInfo`.
36    pub(super) fn new(
37        http: reqwest::Client,
38        base_url: impl Into<String>,
39        client_id: &str,
40        user_agent: &str,
41        op_user_agent: &str,
42    ) -> Result<RestClient, OnePasswordError> {
43        let mut headers = HeaderMap::new();
44        insert_header(&mut headers, CLIENT_HEADER, client_id)?;
45        insert_header(&mut headers, USER_AGENT_HEADER, user_agent)?;
46        insert_header(&mut headers, OP_USER_AGENT_HEADER, op_user_agent)?;
47
48        Ok(RestClient {
49            http,
50            base_url: base_url.into(),
51            headers,
52            signer: None,
53        })
54    }
55
56    /// Derives a client that adds the session id header.
57    pub(super) fn with_session_id(&self, session_id: &str) -> Result<RestClient, OnePasswordError> {
58        let mut headers = self.headers.clone();
59        insert_header(&mut headers, SESSION_ID_HEADER, session_id)?;
60
61        Ok(RestClient {
62            http: self.http.clone(),
63            base_url: self.base_url.clone(),
64            headers,
65            signer: None,
66        })
67    }
68
69    /// Derives a client that signs every request. The signer carries the session id already, so
70    /// this keeps whatever headers the receiver has.
71    pub(super) fn with_signer(&self, signer: MacSigner) -> RestClient {
72        RestClient {
73            http: self.http.clone(),
74            base_url: self.base_url.clone(),
75            headers: self.headers.clone(),
76            signer: Some(signer),
77        }
78    }
79
80    /// POSTs a JSON body and parses the JSON response.
81    pub(super) async fn post_json<T: DeserializeOwned>(
82        &self,
83        endpoint: &str,
84        body: Value,
85    ) -> Result<T, OnePasswordError> {
86        self.request_json(Method::POST, endpoint, Some(&body)).await
87    }
88
89    /// PUTs with no body and parses the JSON response.
90    pub(super) async fn put<T: DeserializeOwned>(
91        &self,
92        endpoint: &str,
93    ) -> Result<T, OnePasswordError> {
94        self.request_json(Method::PUT, endpoint, None).await
95    }
96
97    /// GETs an opdata envelope, decrypts it, and parses the JSON plaintext.
98    pub(super) async fn get_encrypted_json<T: DeserializeOwned>(
99        &self,
100        endpoint: &str,
101        session_key: &AesKey,
102    ) -> Result<T, OnePasswordError> {
103        let envelope = self.request_json(Method::GET, endpoint, None).await?;
104        decrypt_response(envelope, session_key)
105    }
106
107    /// Encrypts `params`, POSTs the opdata envelope, then decrypts and parses the response.
108    pub(super) async fn post_encrypted_json<T: DeserializeOwned>(
109        &self,
110        endpoint: &str,
111        params: Value,
112        session_key: &AesKey,
113    ) -> Result<T, OnePasswordError> {
114        let payload = serde_json::to_vec(&params)
115            .map_err(|_| OnePasswordError::Internal("failed to serialize request".into()))?;
116
117        let mut iv = [0u8; IV_SIZE];
118        bitwarden_random::rng().fill_bytes(&mut iv);
119        let envelope = session_key.encrypt(&payload, &iv)?;
120        let body = serde_json::to_value(&envelope)
121            .map_err(|_| OnePasswordError::Internal("failed to serialize envelope".into()))?;
122
123        let response = self
124            .request_json(Method::POST, endpoint, Some(&body))
125            .await?;
126        decrypt_response(response, session_key)
127    }
128
129    /// Sends a request and parses the JSON response.
130    async fn request_json<T: DeserializeOwned>(
131        &self,
132        method: Method,
133        endpoint: &str,
134        body: Option<&Value>,
135    ) -> Result<T, OnePasswordError> {
136        let text = self.request(method, endpoint, body).await?;
137        deserialize(text.as_bytes())
138    }
139
140    async fn request(
141        &self,
142        method: Method,
143        endpoint: &str,
144        body: Option<&Value>,
145    ) -> Result<String, OnePasswordError> {
146        let url = format!("{}/{}", self.base_url, endpoint);
147
148        let mut builder = self
149            .http
150            .request(method.clone(), &url)
151            .headers(self.headers.clone());
152        if let Some(body) = body {
153            builder = builder.json(body);
154        }
155        if let Some(signer) = &self.signer {
156            builder = builder.header(MAC_HEADER, signer.sign(&url, method.as_str())?);
157        }
158
159        let response = builder
160            .send()
161            .await
162            .map_err(|e| OnePasswordError::Network(e.to_string()))?;
163        let status = response.status();
164        let text = response
165            .text()
166            .await
167            .map_err(|e| OnePasswordError::Network(e.to_string()))?;
168
169        if !status.is_success() {
170            return Err(parse_server_error(text.as_bytes()).unwrap_or_else(|| {
171                OnePasswordError::Internal(format!(
172                    "unexpected response from '{endpoint}' (HTTP {})",
173                    status.as_u16()
174                ))
175            }));
176        }
177
178        Ok(text)
179    }
180}
181
182/// Decrypts an opdata envelope and parses its JSON plaintext.
183fn decrypt_response<T: DeserializeOwned>(
184    envelope: EncryptedEnvelope,
185    session_key: &AesKey,
186) -> Result<T, OnePasswordError> {
187    let plaintext = session_key.decrypt(&Encrypted::parse(&envelope)?)?;
188
189    // A decrypted payload can still be a server error: some errors come back HTTP 200 with the
190    // error object encrypted.
191    if let Some(error) = parse_server_error(&plaintext) {
192        return Err(error);
193    }
194    deserialize(&plaintext)
195}
196
197/// Maps a 1Password error body to an `OnePasswordError`, or `None` when the body is not an error.
198fn parse_server_error(body: &[u8]) -> Option<OnePasswordError> {
199    if let Ok(error) = serde_json::from_slice::<ErrorResponse>(body) {
200        return Some(match error.code {
201            102 => OnePasswordError::BadCredentials,
202            117 => OnePasswordError::NotFound,
203            code => OnePasswordError::Internal(format!(
204                "the server responded with error code {code}: '{}'",
205                error.message
206            )),
207        });
208    }
209
210    if let Ok(failure) = serde_json::from_slice::<FailureReason>(body)
211        && !failure.reason.is_empty()
212    {
213        return Some(OnePasswordError::Internal(format!(
214            "the server responded with failure reason: '{}'",
215            failure.reason
216        )));
217    }
218
219    None
220}
221
222fn deserialize<T: DeserializeOwned>(body: &[u8]) -> Result<T, OnePasswordError> {
223    serde_json::from_slice(body).map_err(|_| OnePasswordError::Parse)
224}
225
226fn insert_header(
227    headers: &mut HeaderMap,
228    name: &'static str,
229    value: &str,
230) -> Result<(), OnePasswordError> {
231    let value = HeaderValue::from_str(value)
232        .map_err(|_| OnePasswordError::Internal(format!("invalid header value for '{name}'")))?;
233    headers.insert(HeaderName::from_static(name), value);
234    Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239    use bitwarden_api_base::new_http_client;
240    use serde::Deserialize;
241    use serde_json::json;
242    use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
243
244    use super::{super::opdata::decode64_loose, *};
245
246    #[derive(Debug, Deserialize)]
247    struct Greeting {
248        hello: String,
249    }
250
251    fn client(server: &MockServer) -> RestClient {
252        RestClient::new(
253            new_http_client(),
254            format!("http://{}/api", server.address()),
255            "1Password for Mac/81210036",
256            "1Password for Mac/81210036",
257            "op-user-agent",
258        )
259        .expect("valid headers")
260    }
261
262    fn session_key() -> AesKey {
263        AesKey::new(
264            "SESSION",
265            decode64_loose("WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM").expect("valid key"),
266        )
267    }
268
269    #[test]
270    fn parses_error_bodies() {
271        let error = parse_server_error(br#"{"errorCode":102,"errorMessage":"nope"}"#)
272            .expect("recognized error");
273        assert!(matches!(error, OnePasswordError::BadCredentials));
274
275        let error = parse_server_error(br#"{"errorCode":117,"errorMessage":"gone"}"#)
276            .expect("recognized error");
277        assert!(matches!(error, OnePasswordError::NotFound));
278
279        let error = parse_server_error(br#"{"errorCode":401,"errorMessage":"no auth"}"#)
280            .expect("recognized error");
281        assert!(matches!(error, OnePasswordError::Internal(_)));
282        assert!(error.to_string().contains("401"));
283
284        let error = parse_server_error(br#"{"reason":"rate limited"}"#).expect("recognized error");
285        assert!(error.to_string().contains("rate limited"));
286    }
287
288    #[test]
289    fn ignores_non_error_bodies() {
290        assert!(parse_server_error(br#"{"status":"ok","sessionID":"S"}"#).is_none());
291        assert!(parse_server_error(br#"{"mfa":null}"#).is_none());
292    }
293
294    #[tokio::test]
295    async fn post_json_sends_identity_headers() {
296        let server = MockServer::start().await;
297        server
298            .register(
299                Mock::given(matchers::path("/api/v2/auth/methods"))
300                    .and(matchers::method("POST"))
301                    .and(matchers::header(
302                        "x-agilebits-client",
303                        "1Password for Mac/81210036",
304                    ))
305                    .and(matchers::body_json(json!({"email": "[email protected]"})))
306                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"hello": "you"})))
307                    .expect(1),
308            )
309            .await;
310
311        let response: Greeting = client(&server)
312            .post_json("v2/auth/methods", json!({"email": "[email protected]"}))
313            .await
314            .expect("request succeeds");
315
316        assert_eq!(response.hello, "you");
317        server.verify().await;
318    }
319
320    #[tokio::test]
321    async fn maps_error_responses_to_errors() {
322        let server = MockServer::start().await;
323        server
324            .register(
325                Mock::given(matchers::path("/api/v2/auth"))
326                    .respond_with(ResponseTemplate::new(401).set_body_json(
327                        json!({"errorCode": 102, "errorMessage": "bad credentials"}),
328                    ))
329                    .expect(1),
330            )
331            .await;
332
333        let error = client(&server)
334            .post_json::<Greeting>("v2/auth", json!({}))
335            .await
336            .expect_err("server rejects");
337
338        assert!(matches!(error, OnePasswordError::BadCredentials));
339        server.verify().await;
340    }
341
342    #[tokio::test]
343    async fn signs_requests_once_a_signer_is_attached() {
344        let server = MockServer::start().await;
345        server
346            .register(
347                Mock::given(matchers::path("/api/v1/auth/verify"))
348                    .and(matchers::header("x-agilebits-session-id", "SESSION"))
349                    .and(matchers::header_regex("x-agilebits-mac", r"^v1\|\d+\|.+$"))
350                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"hello": "you"})))
351                    .expect(1),
352            )
353            .await;
354
355        let key = session_key();
356        let rest = client(&server)
357            .with_session_id(&key.id)
358            .expect("valid session id")
359            .with_signer(MacSigner::new(&key));
360        let _: Greeting = rest
361            .post_json("v1/auth/verify", json!({}))
362            .await
363            .expect("request succeeds");
364
365        server.verify().await;
366    }
367
368    #[tokio::test]
369    async fn round_trips_an_encrypted_request() {
370        let key = session_key();
371        let response_body = {
372            let mut iv = [0u8; IV_SIZE];
373            bitwarden_random::rng().fill_bytes(&mut iv);
374            let envelope = key
375                .encrypt(br#"{"hello":"encrypted"}"#, &iv)
376                .expect("encrypts");
377            serde_json::to_value(&envelope).expect("serializes")
378        };
379
380        let server = MockServer::start().await;
381        server
382            .register(
383                Mock::given(matchers::path("/api/v1/auth/mfa"))
384                    .and(matchers::method("POST"))
385                    // The request body is an opdata envelope, not the plaintext params.
386                    .and(matchers::body_partial_json(
387                        json!({"kid": "SESSION", "enc": "A256GCM"}),
388                    ))
389                    .respond_with(ResponseTemplate::new(200).set_body_json(response_body))
390                    .expect(1),
391            )
392            .await;
393
394        let response: Greeting = client(&server)
395            .post_encrypted_json("v1/auth/mfa", json!({"totp": {"code": "123456"}}), &key)
396            .await
397            .expect("request succeeds");
398
399        assert_eq!(response.hello, "encrypted");
400        server.verify().await;
401    }
402
403    #[tokio::test]
404    async fn surfaces_errors_hidden_inside_an_encrypted_response() {
405        let key = session_key();
406        let response_body = {
407            let mut iv = [0u8; IV_SIZE];
408            bitwarden_random::rng().fill_bytes(&mut iv);
409            let envelope = key
410                .encrypt(br#"{"errorCode":102,"errorMessage":"nope"}"#, &iv)
411                .expect("encrypts");
412            serde_json::to_value(&envelope).expect("serializes")
413        };
414
415        let server = MockServer::start().await;
416        server
417            .register(
418                Mock::given(matchers::path("/api/v1/auth/mfa"))
419                    .respond_with(ResponseTemplate::new(200).set_body_json(response_body))
420                    .expect(1),
421            )
422            .await;
423
424        let error = client(&server)
425            .post_encrypted_json::<Greeting>("v1/auth/mfa", json!({}), &key)
426            .await
427            .expect_err("encrypted error is surfaced");
428
429        assert!(matches!(error, OnePasswordError::BadCredentials));
430        server.verify().await;
431    }
432}