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