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 PASSWORD_METHOD: &str = "PASSWORD+SK";
28const SSO_METHOD: &str = "OIDC";
29const AUTH_START_ENDPOINT: &str = "v3/auth/start";
30const AUTH_COMPLETE_ENDPOINT: &str = "v2/auth/complete";
31
32/// How an account can sign in. An account may offer both.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
35#[cfg_attr(
36    feature = "wasm",
37    derive(serde::Serialize, serde::Deserialize, tsify::Tsify),
38    tsify(into_wasm_abi)
39)]
40pub struct SignInMethods {
41    /// With the master password and Secret Key.
42    pub password: bool,
43    /// With single sign-on through the account's identity provider.
44    pub sso: bool,
45}
46
47impl SignInMethods {
48    pub(super) fn of(login_info: &LoginInfo) -> SignInMethods {
49        let offers = |kind| {
50            login_info
51                .auth_methods
52                .iter()
53                .any(|method| method.kind == kind)
54        };
55        SignInMethods {
56            password: offers(PASSWORD_METHOD),
57            sso: offers(SSO_METHOD),
58        }
59    }
60}
61
62/// The result of a single login attempt: a finished session, or a rejected OTP that asks for a full
63/// restart.
64pub(super) enum LoginOutcome {
65    /// Authentication succeeded.
66    Success(Box<Session>),
67    /// The submitted TOTP code was rejected; the caller should retry from the start.
68    BadOtp,
69}
70
71/// Confirms the account offers a given auth method. The request carries only the email, so a bare
72/// 400 means 1Password refused it as malformed.
73pub(super) async fn fetch_auth_methods(
74    username: &str,
75    rest: &RestClient,
76) -> Result<LoginInfo, OnePasswordError> {
77    rest.post_json(AUTH_METHODS_ENDPOINT, json!({ "email": username }))
78        .await
79        .map_err(|error| match error {
80            OnePasswordError::UnexpectedStatus { status: 400, .. } => {
81                OnePasswordError::InvalidEmail
82            }
83            error => error,
84        })
85}
86
87/// Runs one full login sequence: start a session, exchange SRP, verify the key, and drive 2FA if
88/// the server asks for it.
89pub(super) async fn login_attempt(
90    credentials: &Credentials,
91    account_key: &AccountKey,
92    client_info: &ClientInfo,
93    attempt: u32,
94    ui: &dyn TwoFactorUi,
95    rest: &RestClient,
96) -> Result<LoginOutcome, OnePasswordError> {
97    // Step 1: Request to initiate a new session
98    let (session_id, srp_info) =
99        start_new_session(credentials, account_key, client_info, rest).await?;
100
101    // After a new session has been initiated, all the subsequent requests must be signed with the
102    // session ID.
103    let session_rest = rest.with_session_id(&session_id)?;
104
105    // Step 2: Perform SRP exchange and verify key
106    let session_key = srp::perform_and_verify(
107        credentials,
108        account_key,
109        &srp_info,
110        &session_id,
111        &session_rest,
112    )
113    .await?;
114
115    // Assign a request signer now that we have a key. All the following requests are expected to be
116    // signed with the MAC.
117    let mac_rest = session_rest.with_signer(MacSigner::new(&session_key));
118
119    // Step 3: Verify the key with the server
120    let mfa = verify_session_key(client_info, &session_key, &mac_rest).await?;
121
122    // Step 4: Submit 2FA code if needed
123    if let Some(mfa) = mfa {
124        let outcome = perform_second_factor_authentication(
125            &mfa,
126            client_info,
127            &session_key,
128            attempt,
129            ui,
130            &mac_rest,
131        )
132        .await?;
133
134        match outcome {
135            MfaOutcome::Verified => {}
136            MfaOutcome::BadOtp => return Ok(LoginOutcome::BadOtp),
137        }
138    }
139
140    Ok(LoginOutcome::Success(Box::new(Session::new(
141        session_key,
142        mac_rest,
143    ))))
144}
145
146/// Starts a new session, looping through device registration/reauthorization until the server
147/// returns SRP parameters.
148async fn start_new_session(
149    credentials: &Credentials,
150    account_key: &AccountKey,
151    client_info: &ClientInfo,
152    rest: &RestClient,
153) -> Result<(String, SrpInfo), OnePasswordError> {
154    let mut device_attempts = 0;
155    loop {
156        // Step 1: Request to initiate a new session
157        let response: NewSession = rest
158            .post_json(
159                AUTH_START_ENDPOINT,
160                json!({
161                    "email": credentials.username,
162                    "skformat": account_key.format,
163                    "skid": account_key.uuid,
164                    "deviceUuid": client_info.device_uuid,
165                }),
166            )
167            .await?;
168
169        // Step 2: We could be either done at this point, or the server could ask us to register or
170        // reauthorize the device.
171        match response.status.as_str() {
172            // Done. For a previously unknown device ID this should never happen on a first try,
173            // though.
174            "ok" => {
175                if response.key_format.as_deref() != Some(account_key.format.as_str())
176                    || response.key_uuid.as_deref() != Some(account_key.uuid.as_str())
177                {
178                    return Err(OnePasswordError::BadCredentials);
179                }
180
181                let auth = response.auth.ok_or_else(|| {
182                    OnePasswordError::Internal(
183                        "missing SRP parameters in the start response".into(),
184                    )
185                })?;
186                let srp_info = SrpInfo::new(
187                    auth.method,
188                    auth.algorithm,
189                    auth.iterations,
190                    decode64_loose(&auth.salt)?,
191                )?;
192                return Ok((response.session_id, srp_info));
193            }
194            // "Device deleted" should never really happen, unless we managed to guess a device UUID
195            // that was previously registered and then deleted. Unlikely.
196            status @ ("device-not-registered" | "device-deleted") => {
197                device_attempts += 1;
198                if device_attempts > MAX_DEVICE_ATTEMPTS {
199                    return Err(OnePasswordError::Internal(format!(
200                        "the server still reports the device as '{status}' after \
201                         {MAX_DEVICE_ATTEMPTS} attempts"
202                    )));
203                }
204
205                let session_rest = rest.with_session_id(&response.session_id)?;
206                if status == "device-not-registered" {
207                    register_device(client_info, &session_rest).await?;
208                } else {
209                    reauthorize_device(client_info, &session_rest).await?;
210                }
211            }
212            other => {
213                return Err(OnePasswordError::Internal(format!(
214                    "failed to start a new session, unsupported status '{other}'"
215                )));
216            }
217        }
218    }
219}
220
221/// Completes authentication over the MAC-signed, encrypted channel, returning the enabled 2FA
222/// methods when the account needs a second factor.
223async fn verify_session_key(
224    client_info: &ClientInfo,
225    session_key: &AesKey,
226    rest: &RestClient,
227) -> Result<Option<MfaInfo>, OnePasswordError> {
228    let params = json!({
229        "client": client_info.client_id(),
230        "device": client_info.device_body(),
231    });
232    let response: AuthComplete = rest
233        .post_encrypted_json(AUTH_COMPLETE_ENDPOINT, params, session_key)
234        .await?;
235    Ok(response.mfa)
236}
237
238#[cfg(test)]
239mod tests {
240    use bitwarden_api_base::new_http_client;
241    use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
242
243    use super::{
244        super::sign_in::{SignInAddress, SignInDomain},
245        *,
246    };
247
248    fn client(server: &MockServer) -> RestClient {
249        RestClient::new(
250            new_http_client(),
251            format!("http://{}/api", server.address()),
252            "client-id",
253            "user-agent",
254            "op-user-agent",
255        )
256        .expect("valid headers")
257    }
258
259    fn credentials() -> Credentials {
260        Credentials {
261            username: "[email protected]".into(),
262            password: "password".into(),
263            account_key: "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9".into(),
264            sign_in_address: SignInAddress {
265                subdomain: "my".into(),
266                domain: SignInDomain::Global,
267            },
268        }
269    }
270
271    fn account_key() -> AccountKey {
272        AccountKey::parse(&credentials().account_key).expect("valid account key")
273    }
274
275    fn start_response(status: &str) -> serde_json::Value {
276        json!({"status": status, "sessionID": "SESSION"})
277    }
278
279    fn ok_start_response() -> serde_json::Value {
280        json!({
281            "status": "ok",
282            "sessionID": "SESSION",
283            "accountKeyFormat": "A3",
284            "accountKeyUuid": "RTN9SA",
285            "userAuth": {
286                "method": "SRPg-4096",
287                "alg": "PBES2g-HS256",
288                "iterations": 100000,
289                "salt": "c2FsdHNhbHRzYWx0",
290            },
291        })
292    }
293
294    #[tokio::test]
295    async fn fetches_the_auth_methods() {
296        let server = MockServer::start().await;
297        server
298            .register(
299                Mock::given(matchers::path("/api/v2/auth/methods"))
300                    .and(matchers::body_json(json!({"email": "[email protected]"})))
301                    .respond_with(
302                        ResponseTemplate::new(200)
303                            .set_body_json(json!({"authMethods": [{"type": "PASSWORD+SK"}]})),
304                    )
305                    .expect(1),
306            )
307            .await;
308
309        let info = fetch_auth_methods("[email protected]", &client(&server))
310            .await
311            .expect("methods are listed");
312
313        assert_eq!(info.auth_methods[0].kind, "PASSWORD+SK");
314        server.verify().await;
315    }
316
317    #[tokio::test]
318    async fn a_refused_email_is_invalid() {
319        let server = MockServer::start().await;
320        server
321            .register(
322                Mock::given(matchers::path("/api/v2/auth/methods"))
323                    .respond_with(ResponseTemplate::new(400).set_body_json(json!({})))
324                    .expect(1),
325            )
326            .await;
327
328        let error = fetch_auth_methods("user@example", &client(&server))
329            .await
330            .expect_err("the email is refused");
331
332        assert!(matches!(error, OnePasswordError::InvalidEmail));
333        server.verify().await;
334    }
335
336    #[tokio::test]
337    async fn other_auth_methods_failures_keep_their_status() {
338        let server = MockServer::start().await;
339        server
340            .register(
341                Mock::given(matchers::path("/api/v2/auth/methods"))
342                    .respond_with(ResponseTemplate::new(503))
343                    .expect(1),
344            )
345            .await;
346
347        let error = fetch_auth_methods("[email protected]", &client(&server))
348            .await
349            .expect_err("the server is down");
350
351        assert!(matches!(
352            error,
353            OnePasswordError::UnexpectedStatus { status: 503, .. }
354        ));
355        server.verify().await;
356    }
357
358    #[test]
359    fn sign_in_methods_read_the_offered_auth_methods() {
360        let methods = |kinds: &[&str]| {
361            let login_info: LoginInfo = serde_json::from_value(json!({
362                "authMethods": kinds.iter().map(|kind| json!({"type": kind})).collect::<Vec<_>>(),
363            }))
364            .expect("valid login info");
365            SignInMethods::of(&login_info)
366        };
367        let only = |password, sso| SignInMethods { password, sso };
368
369        assert_eq!(methods(&["PASSWORD+SK"]), only(true, false));
370        assert_eq!(methods(&["OIDC"]), only(false, true));
371        assert_eq!(methods(&["PASSWORD+SK", "OIDC"]), only(true, true));
372        assert_eq!(methods(&["PASSKEY"]), only(false, false));
373    }
374
375    #[tokio::test]
376    async fn start_registers_an_unknown_device_then_retries() {
377        let server = MockServer::start().await;
378        // The first start says the device is unknown, the second succeeds. wiremock matches the
379        // most recently registered mock first, so register the success last.
380        server
381            .register(
382                Mock::given(matchers::path("/api/v3/auth/start"))
383                    .respond_with(
384                        ResponseTemplate::new(200)
385                            .set_body_json(start_response("device-not-registered")),
386                    )
387                    .up_to_n_times(1)
388                    .expect(1),
389            )
390            .await;
391        server
392            .register(
393                Mock::given(matchers::path("/api/v1/device"))
394                    .and(matchers::method("POST"))
395                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1})))
396                    .expect(1),
397            )
398            .await;
399        server
400            .register(
401                Mock::given(matchers::path("/api/v3/auth/start"))
402                    .respond_with(ResponseTemplate::new(200).set_body_json(ok_start_response()))
403                    .expect(1),
404            )
405            .await;
406
407        let (session_id, srp_info) = start_new_session(
408            &credentials(),
409            &account_key(),
410            &ClientInfo::for_desktop("device-uuid"),
411            &client(&server),
412        )
413        .await
414        .expect("session starts after registering the device");
415
416        assert_eq!(session_id, "SESSION");
417        assert_eq!(
418            srp_info,
419            SrpInfo::new(
420                "SRPg-4096".into(),
421                "PBES2g-HS256".into(),
422                100000,
423                b"saltsaltsalt".to_vec(),
424            )
425            .expect("supported parameters")
426        );
427        server.verify().await;
428    }
429
430    #[tokio::test]
431    async fn start_gives_up_when_the_device_never_registers() {
432        let server = MockServer::start().await;
433        server
434            .register(
435                Mock::given(matchers::path("/api/v3/auth/start"))
436                    .respond_with(
437                        ResponseTemplate::new(200)
438                            .set_body_json(start_response("device-not-registered")),
439                    )
440                    .expect(u64::from(MAX_DEVICE_ATTEMPTS) + 1),
441            )
442            .await;
443        server
444            .register(
445                Mock::given(matchers::path("/api/v1/device"))
446                    .and(matchers::method("POST"))
447                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1})))
448                    .expect(u64::from(MAX_DEVICE_ATTEMPTS)),
449            )
450            .await;
451
452        let error = start_new_session(
453            &credentials(),
454            &account_key(),
455            &ClientInfo::for_desktop("device-uuid"),
456            &client(&server),
457        )
458        .await
459        .expect_err("gives up instead of registering the device forever");
460
461        assert!(
462            error.to_string().contains("device-not-registered"),
463            "unexpected error: {error}"
464        );
465        server.verify().await;
466    }
467
468    #[tokio::test]
469    async fn start_rejects_a_mismatching_account_key() {
470        let server = MockServer::start().await;
471        server
472            .register(
473                Mock::given(matchers::path("/api/v3/auth/start"))
474                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({
475                        "status": "ok",
476                        "sessionID": "SESSION",
477                        "accountKeyFormat": "A3",
478                        "accountKeyUuid": "OTHERS",
479                    })))
480                    .expect(1),
481            )
482            .await;
483
484        let error = start_new_session(
485            &credentials(),
486            &account_key(),
487            &ClientInfo::for_desktop("device-uuid"),
488            &client(&server),
489        )
490        .await
491        .expect_err("the server knows a different Secret Key");
492
493        assert!(matches!(error, OnePasswordError::BadCredentials));
494        server.verify().await;
495    }
496
497    #[tokio::test]
498    async fn start_reports_an_unknown_status() {
499        let server = MockServer::start().await;
500        server
501            .register(
502                Mock::given(matchers::path("/api/v3/auth/start"))
503                    .respond_with(
504                        ResponseTemplate::new(200).set_body_json(start_response("who-knows")),
505                    )
506                    .expect(1),
507            )
508            .await;
509
510        let error = start_new_session(
511            &credentials(),
512            &account_key(),
513            &ClientInfo::for_desktop("device-uuid"),
514            &client(&server),
515        )
516        .await
517        .expect_err("unknown status");
518
519        assert!(error.to_string().contains("who-knows"));
520        server.verify().await;
521    }
522}