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