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::new("my", SignInDomain::Global)
226                .expect("a valid subdomain"),
227            device_uuid: "device-uuid".into(),
228        }
229    }
230
231    fn account_key() -> AccountKey {
232        AccountKey::parse(&credentials().account_key).expect("valid account key")
233    }
234
235    fn start_response(status: &str) -> serde_json::Value {
236        json!({"status": status, "sessionID": "SESSION"})
237    }
238
239    fn ok_start_response() -> serde_json::Value {
240        json!({
241            "status": "ok",
242            "sessionID": "SESSION",
243            "accountKeyFormat": "A3",
244            "accountKeyUuid": "RTN9SA",
245            "userAuth": {
246                "method": "SRPg-4096",
247                "alg": "PBES2g-HS256",
248                "iterations": 100000,
249                "salt": "c2FsdHNhbHRzYWx0",
250            },
251        })
252    }
253
254    #[tokio::test]
255    async fn fetches_the_auth_methods() {
256        let server = MockServer::start().await;
257        server
258            .register(
259                Mock::given(matchers::path("/api/v2/auth/methods"))
260                    .and(matchers::body_json(json!({"email": "[email protected]"})))
261                    .respond_with(
262                        ResponseTemplate::new(200)
263                            .set_body_json(json!({"authMethods": [{"type": "PASSWORD+SK"}]})),
264                    )
265                    .expect(1),
266            )
267            .await;
268
269        let info = fetch_auth_methods("[email protected]", &client(&server))
270            .await
271            .expect("methods are listed");
272
273        assert_eq!(info.auth_methods[0].kind, "PASSWORD+SK");
274        server.verify().await;
275    }
276
277    #[tokio::test]
278    async fn start_registers_an_unknown_device_then_retries() {
279        let server = MockServer::start().await;
280        // The first start says the device is unknown, the second succeeds. wiremock matches the
281        // most recently registered mock first, so register the success last.
282        server
283            .register(
284                Mock::given(matchers::path("/api/v3/auth/start"))
285                    .respond_with(
286                        ResponseTemplate::new(200)
287                            .set_body_json(start_response("device-not-registered")),
288                    )
289                    .up_to_n_times(1)
290                    .expect(1),
291            )
292            .await;
293        server
294            .register(
295                Mock::given(matchers::path("/api/v1/device"))
296                    .and(matchers::method("POST"))
297                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1})))
298                    .expect(1),
299            )
300            .await;
301        server
302            .register(
303                Mock::given(matchers::path("/api/v3/auth/start"))
304                    .respond_with(ResponseTemplate::new(200).set_body_json(ok_start_response()))
305                    .expect(1),
306            )
307            .await;
308
309        let (session_id, srp_info) = start_new_session(
310            &credentials(),
311            &account_key(),
312            &ClientInfo::for_desktop("device-uuid"),
313            &client(&server),
314        )
315        .await
316        .expect("session starts after registering the device");
317
318        assert_eq!(session_id, "SESSION");
319        assert_eq!(
320            srp_info,
321            SrpInfo::new(
322                "SRPg-4096".into(),
323                "PBES2g-HS256".into(),
324                100000,
325                b"saltsaltsalt".to_vec(),
326            )
327            .expect("supported parameters")
328        );
329        server.verify().await;
330    }
331
332    #[tokio::test]
333    async fn start_gives_up_when_the_device_never_registers() {
334        let server = MockServer::start().await;
335        server
336            .register(
337                Mock::given(matchers::path("/api/v3/auth/start"))
338                    .respond_with(
339                        ResponseTemplate::new(200)
340                            .set_body_json(start_response("device-not-registered")),
341                    )
342                    .expect(u64::from(MAX_DEVICE_ATTEMPTS) + 1),
343            )
344            .await;
345        server
346            .register(
347                Mock::given(matchers::path("/api/v1/device"))
348                    .and(matchers::method("POST"))
349                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": 1})))
350                    .expect(u64::from(MAX_DEVICE_ATTEMPTS)),
351            )
352            .await;
353
354        let error = start_new_session(
355            &credentials(),
356            &account_key(),
357            &ClientInfo::for_desktop("device-uuid"),
358            &client(&server),
359        )
360        .await
361        .expect_err("gives up instead of registering the device forever");
362
363        assert!(
364            error.to_string().contains("device-not-registered"),
365            "unexpected error: {error}"
366        );
367        server.verify().await;
368    }
369
370    #[tokio::test]
371    async fn start_rejects_a_mismatching_account_key() {
372        let server = MockServer::start().await;
373        server
374            .register(
375                Mock::given(matchers::path("/api/v3/auth/start"))
376                    .respond_with(ResponseTemplate::new(200).set_body_json(json!({
377                        "status": "ok",
378                        "sessionID": "SESSION",
379                        "accountKeyFormat": "A3",
380                        "accountKeyUuid": "OTHERS",
381                    })))
382                    .expect(1),
383            )
384            .await;
385
386        let error = start_new_session(
387            &credentials(),
388            &account_key(),
389            &ClientInfo::for_desktop("device-uuid"),
390            &client(&server),
391        )
392        .await
393        .expect_err("the server knows a different Secret Key");
394
395        assert!(matches!(error, OnePasswordError::BadCredentials));
396        server.verify().await;
397    }
398
399    #[tokio::test]
400    async fn start_reports_an_unknown_status() {
401        let server = MockServer::start().await;
402        server
403            .register(
404                Mock::given(matchers::path("/api/v3/auth/start"))
405                    .respond_with(
406                        ResponseTemplate::new(200).set_body_json(start_response("who-knows")),
407                    )
408                    .expect(1),
409            )
410            .await;
411
412        let error = start_new_session(
413            &credentials(),
414            &account_key(),
415            &ClientInfo::for_desktop("device-uuid"),
416            &client(&server),
417        )
418        .await
419        .expect_err("unknown status");
420
421        assert!(error.to_string().contains("who-knows"));
422        server.verify().await;
423    }
424}