Skip to main content

bitwarden_importers/importers/onepassword/access/
mac.rs

1//! `X-AgileBits-MAC` request signer.
2
3use std::sync::atomic::{AtomicU32, Ordering};
4
5use data_encoding::BASE64URL_NOPAD;
6use hmac::{Hmac, KeyInit, Mac};
7use rand::Rng;
8use sha2::Sha256;
9use url::Url;
10
11use super::{error::OnePasswordError, opdata::AesKey};
12
13const SESSION_HMAC_SECRET: &str = "He never wears a Mac, in the pouring rain. Very strange.";
14
15/// Signs requests with the per-session MAC header, bumping the request id on each signature.
16pub(super) struct MacSigner {
17    session_id: String,
18    salt: [u8; 32],
19    request_id: AtomicU32,
20}
21
22impl MacSigner {
23    /// Creates a signer from the session key, starting from a random request id.
24    pub(super) fn new(session_key: &AesKey) -> MacSigner {
25        Self::with_request_id(session_key, bitwarden_random::rng().next_u32())
26    }
27
28    fn with_request_id(session_key: &AesKey, request_id: u32) -> MacSigner {
29        MacSigner {
30            session_id: session_key.id.clone(),
31            salt: calculate_session_hmac_salt(&session_key.key),
32            request_id: AtomicU32::new(request_id),
33        }
34    }
35
36    /// Returns the `X-AgileBits-MAC` header value for a request and takes the next request id.
37    ///
38    /// The id only has to differ between requests, so nothing needs ordering against other threads.
39    pub(super) fn sign(&self, url: &str, method: &str) -> Result<String, OnePasswordError> {
40        let id = self.request_id.fetch_add(1, Ordering::Relaxed);
41
42        let message = self.calculate_auth_message(url, method, id)?;
43        Ok(calculate_auth_signature(&self.salt, &message, id))
44    }
45
46    /// `sessionId|METHOD|host/path?query|v1|requestId`.
47    fn calculate_auth_message(
48        &self,
49        url: &str,
50        method: &str,
51        request_id: u32,
52    ) -> Result<String, OnePasswordError> {
53        let parsed = Url::parse(url)
54            .map_err(|_| OnePasswordError::Internal(format!("invalid url '{url}'")))?;
55        let host = parsed.host_str().unwrap_or("");
56        let path = parsed.path().trim_start_matches('/');
57        let query = parsed.query().unwrap_or("");
58
59        Ok(format!(
60            "{}|{}|{}/{}?{}|v1|{}",
61            self.session_id,
62            method.to_uppercase(),
63            host,
64            path,
65            query,
66            request_id
67        ))
68    }
69}
70
71/// `v1|requestId|b64url(HMAC-SHA256(salt, message)[0..12])`.
72fn calculate_auth_signature(salt: &[u8], auth_message: &str, request_id: u32) -> String {
73    let hash = hmac_sha256(salt, auth_message.as_bytes());
74    let hash12 = BASE64URL_NOPAD.encode(&hash[..12]);
75    format!("v1|{request_id}|{hash12}")
76}
77
78/// `HMAC-SHA256(sessionKey, secret)`.
79fn calculate_session_hmac_salt(session_key: &[u8]) -> [u8; 32] {
80    hmac_sha256(session_key, SESSION_HMAC_SECRET.as_bytes())
81}
82
83fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
84    let mut mac =
85        <Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
86    mac.update(message);
87    mac.finalize().into_bytes().into()
88}
89
90#[cfg(test)]
91mod tests {
92    use data_encoding::HEXLOWER;
93
94    use super::{super::opdata::decode64_loose, *};
95
96    const SESSION_KEY: &str = "WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM";
97
98    fn signer() -> MacSigner {
99        let key = AesKey::new(
100            "PBXONDZUWVCJFAV25C7XR7IYDQ",
101            decode64_loose(SESSION_KEY).expect("valid key"),
102        );
103        MacSigner::with_request_id(&key, 842346063)
104    }
105
106    #[test]
107    fn sign_returns_headers_with_signature() {
108        let signature = signer()
109            .sign("https://my.1password.com/api/v1/auth/verify", "POST")
110            .expect("signs");
111        assert_eq!(signature, "v1|842346063|xv-fEAYowunpH4V-");
112    }
113
114    #[test]
115    fn sign_returns_signature_for_url_with_query() {
116        let signature = signer()
117            .sign(
118                "https://my.1password.com/api/v1/account?attrs=billing,counts,groups,invite,me,settings,tier,user-flags,users,vaults",
119                "GET",
120            )
121            .expect("signs");
122        assert_eq!(signature, "v1|842346063|UyjKq0HAmjB5j7kF");
123    }
124
125    #[test]
126    fn sign_increments_the_counter() {
127        let signer = signer();
128        let first = signer
129            .sign("https://my.1password.com/api/v1/auth/verify", "POST")
130            .expect("signs");
131        let second = signer
132            .sign("https://my.1password.com/api/v1/auth/verify", "POST")
133            .expect("signs");
134
135        assert_ne!(first, second);
136
137        let seed = |s: &str| -> u32 {
138            s.split('|')
139                .nth(1)
140                .expect("seed field")
141                .parse()
142                .expect("u32")
143        };
144        assert_eq!(seed(&first) + 1, seed(&second));
145    }
146
147    #[test]
148    fn calculates_session_hmac_salt() {
149        let key = decode64_loose(SESSION_KEY).expect("valid key");
150        assert_eq!(
151            HEXLOWER.encode(&calculate_session_hmac_salt(&key)),
152            "cce080cc9b3eaeaa9b6e621e1b4c4d2048babe16e40b0576fc2520c26473b9ac"
153        );
154    }
155
156    #[test]
157    fn new_starts_from_a_random_request_id() {
158        let key = AesKey::new(
159            "PBXONDZUWVCJFAV25C7XR7IYDQ",
160            decode64_loose(SESSION_KEY).expect("valid key"),
161        );
162        let ids: Vec<u32> = (0..4)
163            .map(|_| MacSigner::new(&key).request_id.into_inner())
164            .collect();
165        assert!(ids.iter().any(|id| *id != ids[0]));
166    }
167}