Skip to main content

bitwarden_importers/importers/onepassword/access/
login.rs

1//! The password + Secret Key login state machine.
2//!
3//! One attempt runs: start a session (registering the device if the server asks), exchange SRP,
4//! confirm the key, then complete authentication over the MAC-signed encrypted channel, driving 2FA
5//! when the account requires it.
6
7use serde_json::json;
8
9use super::{
10    account_key::AccountKey,
11    credentials::Credentials,
12    device::{ClientInfo, reauthorize_device, register_device},
13    error::OnePasswordError,
14    mac::MacSigner,
15    opdata::{AesKey, decode64_loose},
16    rest::RestClient,
17    session::Session,
18    srp::{self, SrpInfo},
19    two_factor::{MfaOutcome, TwoFactorUi, perform_second_factor_authentication},
20    wire::{AuthComplete, LoginInfo, MfaInfo, NewSession},
21};
22
23/// How many times the server may send us back to register or reauthorize the device before we give
24/// up. One round is the normal case.
25const MAX_DEVICE_ATTEMPTS: u32 = 2;
26const AUTH_METHODS_ENDPOINT: &str = "v2/auth/methods";
27const AUTH_START_ENDPOINT: &str = "v3/auth/start";
28const AUTH_COMPLETE_ENDPOINT: &str = "v2/auth/complete";
29
30/// The result of a single login attempt: a finished session, or a rejected OTP that asks for a full
31/// restart.
32pub(super) enum LoginOutcome {
33    /// Authentication succeeded.
34    Success(Box<Session>),
35    /// The submitted TOTP code was rejected; the caller should retry from the start.
36    BadOtp,
37}
38
39/// Confirms the account offers a given auth method.
40pub(super) async fn fetch_auth_methods(
41    username: &str,
42    rest: &RestClient,
43) -> Result<LoginInfo, OnePasswordError> {
44    rest.post_json(AUTH_METHODS_ENDPOINT, json!({ "email": username }))
45        .await
46}
47
48/// Runs one full login sequence: start a session, exchange SRP, verify the key, and drive 2FA if
49/// the server asks for it.
50pub(super) async fn login_attempt(
51    credentials: &Credentials,
52    account_key: &AccountKey,
53    client_info: &ClientInfo,
54    attempt: u32,
55    ui: &dyn TwoFactorUi,
56    rest: &RestClient,
57) -> Result<LoginOutcome, OnePasswordError> {
58    // Step 1: Request to initiate a new session
59    let (session_id, srp_info) =
60        start_new_session(credentials, account_key, client_info, rest).await?;
61
62    // After a new session has been initiated, all the subsequent requests must be signed with the
63    // session ID.
64    let session_rest = rest.with_session_id(&session_id)?;
65
66    // Step 2: Perform SRP exchange and verify key
67    let session_key = srp::perform_and_verify(
68        credentials,
69        account_key,
70        &srp_info,
71        &session_id,
72        &session_rest,
73    )
74    .await?;
75
76    // Assign a request signer now that we have a key. All the following requests are expected to be
77    // signed with the MAC.
78    let mac_rest = session_rest.with_signer(MacSigner::new(&session_key));
79
80    // Step 3: Verify the key with the server
81    let mfa = verify_session_key(client_info, &session_key, &mac_rest).await?;
82
83    // Step 4: Submit 2FA code if needed
84    if let Some(mfa) = mfa {
85        let outcome = perform_second_factor_authentication(
86            &mfa,
87            client_info,
88            &session_key,
89            attempt,
90            ui,
91            &mac_rest,
92        )
93        .await?;
94
95        match outcome {
96            MfaOutcome::Verified => {}
97            MfaOutcome::BadOtp => return Ok(LoginOutcome::BadOtp),
98        }
99    }
100
101    Ok(LoginOutcome::Success(Box::new(Session::new(
102        session_key,
103        mac_rest,
104    ))))
105}
106
107/// Starts a new session, looping through device registration/reauthorization until the server
108/// returns SRP parameters.
109async fn start_new_session(
110    credentials: &Credentials,
111    account_key: &AccountKey,
112    client_info: &ClientInfo,
113    rest: &RestClient,
114) -> Result<(String, SrpInfo), OnePasswordError> {
115    let mut device_attempts = 0;
116    loop {
117        // Step 1: Request to initiate a new session
118        let response: NewSession = rest
119            .post_json(
120                AUTH_START_ENDPOINT,
121                json!({
122                    "email": credentials.username,
123                    "skformat": account_key.format,
124                    "skid": account_key.uuid,
125                    "deviceUuid": client_info.device_uuid,
126                }),
127            )
128            .await?;
129
130        // Step 2: We could be either done at this point, or the server could ask us to register or
131        // reauthorize the device.
132        match response.status.as_str() {
133            // Done. For a previously unknown device ID this should never happen on a first try,
134            // though.
135            "ok" => {
136                if response.key_format.as_deref() != Some(account_key.format.as_str())
137                    || response.key_uuid.as_deref() != Some(account_key.uuid.as_str())
138                {
139                    return Err(OnePasswordError::BadCredentials);
140                }
141
142                let auth = response.auth.ok_or_else(|| {
143                    OnePasswordError::Internal(
144                        "missing SRP parameters in the start response".into(),
145                    )
146                })?;
147                let srp_info = SrpInfo::new(
148                    auth.method,
149                    auth.algorithm,
150                    auth.iterations,
151                    decode64_loose(&auth.salt)?,
152                )?;
153                return Ok((response.session_id, srp_info));
154            }
155            // "Device deleted" should never really happen, unless we managed to guess a device UUID
156            // that was previously registered and then deleted. Unlikely.
157            status @ ("device-not-registered" | "device-deleted") => {
158                device_attempts += 1;
159                if device_attempts > MAX_DEVICE_ATTEMPTS {
160                    return Err(OnePasswordError::Internal(format!(
161                        "the server still reports the device as '{status}' after \
162                         {MAX_DEVICE_ATTEMPTS} attempts"
163                    )));
164                }
165
166                let session_rest = rest.with_session_id(&response.session_id)?;
167                if status == "device-not-registered" {
168                    register_device(client_info, &session_rest).await?;
169                } else {
170                    reauthorize_device(client_info, &session_rest).await?;
171                }
172            }
173            other => {
174                return Err(OnePasswordError::Internal(format!(
175                    "failed to start a new session, unsupported status '{other}'"
176                )));
177            }
178        }
179    }
180}
181
182/// Completes authentication over the MAC-signed, encrypted channel, returning the enabled 2FA
183/// methods when the account needs a second factor.
184async fn verify_session_key(
185    client_info: &ClientInfo,
186    session_key: &AesKey,
187    rest: &RestClient,
188) -> Result<Option<MfaInfo>, OnePasswordError> {
189    let params = json!({
190        "client": client_info.client_id(),
191        "device": client_info.device_body(),
192    });
193    let response: AuthComplete = rest
194        .post_encrypted_json(AUTH_COMPLETE_ENDPOINT, params, session_key)
195        .await?;
196    Ok(response.mfa)
197}
198
199#[cfg(test)]
200mod tests {
201    use bitwarden_api_base::new_http_client;
202    use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
203
204    use super::{
205        super::sign_in::{SignInAddress, SignInDomain},
206        *,
207    };
208
209    fn client(server: &MockServer) -> RestClient {
210        RestClient::new(
211            new_http_client(),
212            format!("http://{}/api", server.address()),
213            "client-id",
214            "user-agent",
215            "op-user-agent",
216        )
217        .expect("valid headers")
218    }
219
220    fn credentials() -> Credentials {
221        Credentials {
222            username: "[email protected]".into(),
223            password: "password".into(),
224            account_key: "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9".into(),
225            sign_in_address: SignInAddress {
226                subdomain: "my".into(),
227                domain: SignInDomain::Global,
228            },
229        }
230    }
231
232    fn account_key() -> AccountKey {
233        AccountKey::parse(&credentials().account_key).expect("valid account key")
234    }
235
236    fn start_response(status: &str) -> serde_json::Value {
237        json!({"status": status, "sessionID": "SESSION"})
238    }
239
240    fn ok_start_response() -> serde_json::Value {
241        json!({
242            "status": "ok",
243            "sessionID": "SESSION",
244            "accountKeyFormat": "A3",
245            "accountKeyUuid": "RTN9SA",
246            "userAuth": {
247                "method": "SRPg-4096",
248                "alg": "PBES2g-HS256",
249                "iterations": 100000,
250                "salt": "c2FsdHNhbHRzYWx0",
251            },
252        })
253    }
254
255    #[tokio::test]
256    async fn fetches_the_auth_methods() {
257        let server = MockServer::start().await;
258        server
259            .register(
260                Mock::given(matchers::path("/api/v2/auth/methods"))
261                    .and(matchers::body_json(json!({"email": "[email protected]"})))
262                    .respond_with(
263                        ResponseTemplate::new(200)
264                            .set_body_json(json!({"authMethods": [{"type": "PASSWORD+SK"}]})),
265                    )
266                    .expect(1),
267            )
268            .await;
269
270        let info = fetch_auth_methods("[email protected]", &client(&server))
271            .await
272            .expect("methods are listed");
273
274        assert_eq!(info.auth_methods[0].kind, "PASSWORD+SK");
275        server.verify().await;
276    }
277
278    #[tokio::test]
279    async fn start_registers_an_unknown_device_then_retries() {
280        let server = MockServer::start().await;
281        // The first start says the device is unknown, the second succeeds. wiremock matches the
282        // most recently registered mock first, so register the success last.
283        server
284            .register(
285                Mock::given(matchers::path("/api/v3/auth/start"))
286                    .respond_with(
287                        ResponseTemplate::new(200)
288                            .set_body_json(start_response("device-not-registered")),
289                    )
290                    .up_to_n_times(1)
291                    .expect(1),
292            )
293            .await;
294        server
295            .register(
296                Mock::given(matchers::path("/api/v1/device"))
297                    .and(matchers::method("POST"))
298                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1})))
299                    .expect(1),
300            )
301            .await;
302        server
303            .register(
304                Mock::given(matchers::path("/api/v3/auth/start"))
305                    .respond_with(ResponseTemplate::new(200).set_body_json(ok_start_response()))
306                    .expect(1),
307            )
308            .await;
309
310        let (session_id, srp_info) = start_new_session(
311            &credentials(),
312            &account_key(),
313            &ClientInfo::for_desktop("device-uuid"),
314            &client(&server),
315        )
316        .await
317        .expect("session starts after registering the device");
318
319        assert_eq!(session_id, "SESSION");
320        assert_eq!(
321            srp_info,
322            SrpInfo::new(
323                "SRPg-4096".into(),
324                "PBES2g-HS256".into(),
325                100000,
326                b"saltsaltsalt".to_vec(),
327            )
328            .expect("supported parameters")
329        );
330        server.verify().await;
331    }
332
333    #[tokio::test]
334    async fn start_gives_up_when_the_device_never_registers() {
335        let server = MockServer::start().await;
336        server
337            .register(
338                Mock::given(matchers::path("/api/v3/auth/start"))
339                    .respond_with(
340                        ResponseTemplate::new(200)
341                            .set_body_json(start_response("device-not-registered")),
342                    )
343                    .expect(u64::from(MAX_DEVICE_ATTEMPTS) + 1),
344            )
345            .await;
346        server
347            .register(
348                Mock::given(matchers::path("/api/v1/device"))
349                    .and(matchers::method("POST"))
350                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1})))
351                    .expect(u64::from(MAX_DEVICE_ATTEMPTS)),
352            )
353            .await;
354
355        let error = start_new_session(
356            &credentials(),
357            &account_key(),
358            &ClientInfo::for_desktop("device-uuid"),
359            &client(&server),
360        )
361        .await
362        .expect_err("gives up instead of registering the device forever");
363
364        assert!(
365            error.to_string().contains("device-not-registered"),
366            "unexpected error: {error}"
367        );
368        server.verify().await;
369    }
370
371    #[tokio::test]
372    async fn start_rejects_a_mismatching_account_key() {
373        let server = MockServer::start().await;
374        server
375            .register(
376                Mock::given(matchers::path("/api/v3/auth/start"))
377                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({
378                        "status": "ok",
379                        "sessionID": "SESSION",
380                        "accountKeyFormat": "A3",
381                        "accountKeyUuid": "OTHERS",
382                    })))
383                    .expect(1),
384            )
385            .await;
386
387        let error = start_new_session(
388            &credentials(),
389            &account_key(),
390            &ClientInfo::for_desktop("device-uuid"),
391            &client(&server),
392        )
393        .await
394        .expect_err("the server knows a different Secret Key");
395
396        assert!(matches!(error, OnePasswordError::BadCredentials));
397        server.verify().await;
398    }
399
400    #[tokio::test]
401    async fn start_reports_an_unknown_status() {
402        let server = MockServer::start().await;
403        server
404            .register(
405                Mock::given(matchers::path("/api/v3/auth/start"))
406                    .respond_with(
407                        ResponseTemplate::new(200).set_body_json(start_response("who-knows")),
408                    )
409                    .expect(1),
410            )
411            .await;
412
413        let error = start_new_session(
414            &credentials(),
415            &account_key(),
416            &ClientInfo::for_desktop("device-uuid"),
417            &client(&server),
418        )
419        .await
420        .expect_err("unknown status");
421
422        assert!(error.to_string().contains("who-knows"));
423        server.verify().await;
424    }
425}