1use std::sync::LazyLock;
4
5use crypto_bigint::{
6 BoxedUint, ConcatenatingMul, Odd, Resize,
7 modular::{BoxedMontyForm, BoxedMontyParams},
8};
9use data_encoding::{BASE64URL_NOPAD, HEXLOWER};
10use rand::Rng;
11use serde_json::json;
12use sha2::{Digest, Sha256};
13
14use super::{
15 account_key::AccountKey,
16 credentials::Credentials,
17 error::OnePasswordError,
18 kdf,
19 opdata::AesKey,
20 rest::RestClient,
21 wire::{AForB, ServerHash},
22};
23
24const SRP_METHOD: &str = "SRPg-4096";
25const AUTH_ENDPOINT: &str = "v2/auth";
26const CONFIRM_KEY_ENDPOINT: &str = "v2/auth/confirm-key";
27
28const N_BITS: u32 = 4096;
30
31const SCALAR_BITS: u32 = 256;
33
34static N: LazyLock<Odd<BoxedUint>> = LazyLock::new(|| {
36 Odd::new(BoxedUint::from_be_hex(N_HEX, N_BITS).expect("N_HEX is a compile-time hex constant"))
37 .expect("the SRP group prime is odd")
38});
39
40static N_PARAMS: LazyLock<BoxedMontyParams> =
42 LazyLock::new(|| BoxedMontyParams::new_vartime(N.clone()));
43
44static G: LazyLock<BoxedUint> = LazyLock::new(|| BoxedUint::from(5u32));
46
47const N_HEX: &str = concat!(
48 "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22",
49 "514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6",
50 "F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D",
51 "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB",
52 "9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E8603",
53 "9B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA0510",
54 "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7D",
55 "B3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864",
56 "D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E208E24FA074E5AB31",
57 "43DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D788719A10BDBA5B2699C327186AF4E23C",
58 "1A946834B6150BDA2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6287C59474E6BC05D",
59 "99B2964FA090C3A2233BA186515BE7ED1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA9",
60 "93B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934063199FFFFFFFFFFFFFFFF",
61);
62
63#[derive(Debug, PartialEq)]
68pub(super) struct SrpInfo {
69 iterations: u32,
70 salt: Vec<u8>,
71}
72
73impl SrpInfo {
74 pub(super) fn new(
76 method: String,
77 key_method: String,
78 iterations: u32,
79 salt: Vec<u8>,
80 ) -> Result<SrpInfo, OnePasswordError> {
81 if method != SRP_METHOD {
84 return Err(OnePasswordError::Unsupported(format!(
85 "Method '{method}' is not supported"
86 )));
87 }
88 if iterations < kdf::MIN_PBKDF2_ITERATIONS {
89 return Err(OnePasswordError::Unsupported(format!(
90 "{iterations} iterations is below the minimum of {}",
91 kdf::MIN_PBKDF2_ITERATIONS
92 )));
93 }
94
95 kdf::validate_pbes2(&key_method)?;
96
97 Ok(SrpInfo { iterations, salt })
98 }
99
100 pub(super) fn salt(&self) -> &[u8] {
102 &self.salt
103 }
104}
105
106pub(super) async fn perform_and_verify(
108 credentials: &Credentials,
109 account_key: &AccountKey,
110 srp_info: &SrpInfo,
111 session_id: &str,
112 rest: &RestClient,
113) -> Result<AesKey, OnePasswordError> {
114 let key = perform(
115 &generate_secret_a(),
116 credentials,
117 account_key,
118 srp_info,
119 rest,
120 )
121 .await?;
122 Ok(AesKey::new(session_id, key.to_vec()))
123}
124
125async fn perform(
127 secret_a: &BoxedUint,
128 credentials: &Credentials,
129 account_key: &AccountKey,
130 srp_info: &SrpInfo,
131 rest: &RestClient,
132) -> Result<[u8; 32], OnePasswordError> {
133 let srp_x = compute_x(credentials, account_key, srp_info)?;
135
136 let shared_a = compute_shared_a(secret_a);
138 let shared_b = exchange_a_for_b(&shared_a, rest).await?;
139
140 validate_b(&shared_b)?;
142
143 let session_key = compute_key(secret_a, &shared_a, &shared_b, &srp_x);
145
146 verify_key(
148 &session_key,
149 &credentials.username,
150 &account_key.uuid,
151 srp_info.salt(),
152 &shared_a,
153 &shared_b,
154 rest,
155 )
156 .await?;
157
158 Ok(session_key)
159}
160
161fn generate_secret_a() -> BoxedUint {
163 let mut bytes = [0u8; 32];
164 bitwarden_random::rng().fill_bytes(&mut bytes);
165 scalar(&bytes)
166}
167
168fn compute_shared_a(secret_a: &BoxedUint) -> BoxedUint {
170 mod_pow(&G, secret_a)
171}
172
173fn validate_b(shared_b: &BoxedUint) -> Result<(), OnePasswordError> {
175 let reduced = shared_b.rem(N.as_nz_ref());
176 if bool::from(reduced.is_zero() | reduced.is_one()) {
177 return Err(OnePasswordError::Internal(
178 "Shared B validation failed".into(),
179 ));
180 }
181 Ok(())
182}
183
184async fn exchange_a_for_b(
186 shared_a: &BoxedUint,
187 rest: &RestClient,
188) -> Result<BoxedUint, OnePasswordError> {
189 let response: AForB = rest
190 .post_json(AUTH_ENDPOINT, json!({ "userA": to_server_hex(shared_a) }))
191 .await?;
192 from_server_hex(&response.b)
193}
194
195fn mod_pow(base: &BoxedUint, exponent: &BoxedUint) -> BoxedUint {
197 BoxedMontyForm::new(base.resize(N_BITS), &N_PARAMS)
198 .pow(exponent)
199 .retrieve()
200}
201
202fn scalar(hash: &[u8]) -> BoxedUint {
204 BoxedUint::from_be_slice(hash, SCALAR_BITS).expect("an SRP scalar is a 32-byte hash")
205}
206
207fn compute_key(
209 secret_a: &BoxedUint,
210 shared_a: &BoxedUint,
211 shared_b: &BoxedUint,
212 srp_x: &[u8],
213) -> [u8; 32] {
214 let mut g_mod_n_input = to_compatible_byte_array(&N);
217 g_mod_n_input.extend_from_slice(&mod_n_bytes(&G));
218 let g_mod_n = sha256(&g_mod_n_input);
219
220 let mut ab = mod_n_bytes(shared_a);
222 ab.extend_from_slice(&mod_n_bytes(shared_b));
223 let ab_sha256 = sha256(&ab);
224
225 let x = scalar(srp_x);
227 let exponent = scalar(&ab_sha256)
228 .concatenating_mul(&x)
229 .wrapping_add(secret_a);
230
231 let k_g_pow_x = mod_pow(&G, &x)
235 .concatenating_mul(&scalar(&g_mod_n))
236 .rem(N.as_nz_ref());
237 let base = shared_b
238 .rem(N.as_nz_ref())
239 .sub_mod(&k_g_pow_x, N.as_nz_ref());
240
241 sha256(to_server_hex(&mod_pow(&base, &exponent)).as_bytes())
243}
244
245fn to_server_hex(value: &BoxedUint) -> String {
253 let hex = HEXLOWER.encode(&value.to_be_bytes());
254 match hex.trim_start_matches('0') {
255 "" => "0".to_string(),
256 trimmed => trimmed.to_string(),
257 }
258}
259
260fn from_server_hex(hex: &str) -> Result<BoxedUint, OnePasswordError> {
262 let invalid = || OnePasswordError::Internal("invalid shared value from server".into());
263
264 if hex.is_empty() || hex.len() > N_HEX.len() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
267 return Err(invalid());
268 }
269
270 let padded = format!("{hex:0>width$}", width = N_HEX.len());
272 BoxedUint::from_be_hex(&padded, N_BITS)
273 .into_option()
274 .ok_or_else(invalid)
275}
276
277fn mod_n_bytes(value: &BoxedUint) -> Vec<u8> {
279 value.rem(N.as_nz_ref()).to_be_bytes().into_vec()
280}
281
282fn to_compatible_byte_array(value: &BoxedUint) -> Vec<u8> {
284 let bytes = value.to_be_bytes();
285 match bytes.iter().position(|byte| *byte != 0) {
286 Some(first_significant) => bytes[first_significant..].to_vec(),
287 None => vec![0],
288 }
289}
290
291fn calculate_identity(username: &str, key_uuid: &str) -> String {
293 let username = kdf::normalize_identity_username(username);
294
295 let mut buffer = Vec::with_capacity(64);
296 buffer.extend_from_slice(&sha256(key_uuid.as_bytes()));
297 buffer.extend_from_slice(&sha256(username.as_bytes()));
298 BASE64URL_NOPAD.encode(&sha256(&buffer))
299}
300
301async fn verify_key(
303 session_key: &[u8],
304 username: &str,
305 key_uuid: &str,
306 salt: &[u8],
307 shared_a: &BoxedUint,
308 shared_b: &BoxedUint,
309 rest: &RestClient,
310) -> Result<(), OnePasswordError> {
311 let client_hash =
312 calculate_client_hash(session_key, username, key_uuid, salt, shared_a, shared_b);
313 let response: ServerHash = rest
314 .post_json(
315 CONFIRM_KEY_ENDPOINT,
316 json!({ "clientVerifyHash": BASE64URL_NOPAD.encode(&client_hash) }),
317 )
318 .await?;
319
320 let expected = calculate_server_hash(shared_a, &client_hash, session_key);
323 if response.server_verify_hash != BASE64URL_NOPAD.encode(&expected) {
324 return Err(OnePasswordError::Internal(
325 "the server verification hash does not match".into(),
326 ));
327 }
328 Ok(())
329}
330
331fn calculate_server_hash(shared_a: &BoxedUint, client_hash: &[u8], session_key: &[u8]) -> [u8; 32] {
334 let mut buffer = to_compatible_byte_array(shared_a);
335 buffer.extend_from_slice(client_hash);
336 buffer.extend_from_slice(session_key);
337
338 sha256(&buffer)
339}
340
341fn calculate_client_hash(
344 session_key: &[u8],
345 username: &str,
346 key_uuid: &str,
347 salt: &[u8],
348 shared_a: &BoxedUint,
349 shared_b: &BoxedUint,
350) -> [u8; 32] {
351 let sirp_n = sha256(&to_compatible_byte_array(&N));
352 let sirp_g = sha256(&to_compatible_byte_array(&G));
353 let identity = sha256(calculate_identity(username, key_uuid).as_bytes());
354
355 let mut buffer = Vec::new();
357 for (a, b) in sirp_n.iter().zip(sirp_g.iter()) {
358 buffer.push(a ^ b);
359 }
360
361 buffer.extend_from_slice(&identity);
363 buffer.extend_from_slice(salt);
364 buffer.extend_from_slice(&to_compatible_byte_array(shared_a));
365 buffer.extend_from_slice(&to_compatible_byte_array(shared_b));
366 buffer.extend_from_slice(session_key);
367
368 sha256(&buffer)
369}
370
371fn compute_x(
373 credentials: &Credentials,
374 account_key: &AccountKey,
375 srp_info: &SrpInfo,
376) -> Result<[u8; 32], OnePasswordError> {
377 let k1 = kdf::hkdf_sha256(
378 SRP_METHOD,
379 &srp_info.salt,
380 kdf::normalize_username(&credentials.username).as_bytes(),
381 );
382 let k2 = kdf::pbes2(
383 &kdf::normalize_password(&credentials.password),
384 &k1,
385 srp_info.iterations,
386 );
387 account_key.combine_with(&k2)
388}
389
390fn sha256(data: &[u8]) -> [u8; 32] {
391 Sha256::digest(data).into()
392}
393
394#[cfg(test)]
395mod tests {
396 use bitwarden_api_base::new_http_client;
397 use data_encoding::HEXLOWER;
398 use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
399
400 use super::{
401 super::sign_in::{SignInAddress, SignInDomain},
402 *,
403 };
404
405 const SHARED_A: &str = "843c9c4977cf9c767452c90708c3dbdf3508c0016f8a56abc20c2e654dbd74c2c04b9412528a0927f499b245f9ad6742052662de2f725bf2a6c84913062842b4b2aaa8d41598c0d11424745bbae928d8e00e3c2c831c5ae90e128b719adb8be3845561186826462f0dbbdba272666c039f075b3da18c866c61a208cb9aed5ade03e6570818b7146c789f2e2928958ec7bebffbf2cc06cbb83b77ed80eae95e194502dead2e945e885d145d4521b74b8669211ffe718b20f04253d19550e0f9e8f1f0381caa2200223904a94d1e70f7db7cfa7d10d415bf7571f656a2e7bac3d142a2fa60b5a4e2fec4a82348fb46e03b65938f960373eefb95e50b1dd38134593b2f3ed0a19ae8684b4b54a04e0e022e01abc03072aa2e0096b209eaadb8dae57acd607a46e27bc5bfa66c3887e03441b4628135f830d1d78c7a60366d88cb42ed7ddd2dc32049f9dd3a1f459b610d41d25e8615f3271fcadcd37bf1b13c84c049d57d14ded500290b430c33d1d1dc3b04af66862ca3b4d501e2827355f68eaaf063a131c2436aa0a75519b7ac4d79845b6235898dcd9bef1093618b7c5bc5d73a7fc2a5ef8bca638e922152e459e89652b4a7d7d19cfd24de93f72f20e3a6f4325abf5ca1aec3ef3f392cc356c80b72e43a577775d2bf613b60d9f46d130e9881534e7548241e612901f61d5c5acb62100b8371c8dc42747437cd9ddcf9debf";
406 const SHARED_B: &str = "7112742e3035eca37656e1ad2171516e3e154bcbabbcb5f52787aa53ad882cffd8e952bd67dbc8059025be23a0b86914bf8ec4c08cac0b3448a99d8c097e4b0c6942870b2cd2c56a58499c81c294bf2f64de408535f0a36ba416177519dcb5a54b7a403459abb1bfe8aecb92048e84a55ba48f1672f6ee3f30abff81868e88c8bb25c7c17292e535f91debda167af8f12d1e1073a48a9257b443dacd8ba47270051b03940117d2cec29f6521a3e78e575634db5bc87d479a4327db1b30578c90553edd3de58af08e9157a11b352b0bd7fca70d469809b3d516fed4edc989b78c6f330e553947111c563cb8c8ff184179cf7b8733494e16f3e38ed7cd42651c5bb4d81548c4b320996445b6f1a4c34a6211b5f65e561c04009c7422e289d7035085e21258513040b16bea0d3e91304879fa61f48af4daefce65d0917e4af106d868c6189dfd9031c8a3b2d97fa2a50445d6a818341fed7ad2a986f5aa691626426dc2b1047e1db8a1984f8fda526f21e825df6b4cc60cd31300181a3782e53d039f85164e417b419cde581826b08887f25277f9f7c0933aa596f5a4bb27af7bffb095027e326d1c02544357eaa553ac93b564bb5953b8fc498044d65b8003ad93f95c319ce6af0a0327151935e860c3e5dad17cd65ae4318e76905ce2a3ae239c12ab207313af3c0c7744e7aee2584043ae71dfc3e376bf747f92fa5a94bd36cb";
407
408 fn big(hex: &str) -> BoxedUint {
409 from_server_hex(hex).expect("valid hex")
410 }
411
412 #[test]
413 fn to_server_hex_returns_hex_string() {
414 let cases: [(u32, &str); 8] = [
415 (0, "0"),
416 (1, "1"),
417 (0xD, "d"),
418 (0xDE, "de"),
419 (0xDEA, "dea"),
420 (0xDEAD, "dead"),
421 (0x80, "80"),
422 (0xFF, "ff"),
423 ];
424 for (number, expected) in cases {
425 assert_eq!(to_server_hex(&BoxedUint::from(number)), expected);
426 }
427 }
428
429 #[test]
430 fn from_server_hex_rejects_malformed_values() {
431 for input in ["", "not hex", "é", &"f".repeat(N_HEX.len() + 1)] {
432 from_server_hex(input).expect_err("malformed values are rejected");
433 }
434 }
435
436 #[test]
438 fn validate_b_rejects_zero_and_one_mod_n() {
439 let n = big(N_HEX);
440 for value in [big("0"), big("1"), n.clone(), n.wrapping_add(big("1"))] {
441 validate_b(&value).expect_err("B must not be 0 or 1 mod N");
442 }
443
444 validate_b(&big(SHARED_B)).expect("a normal B is accepted");
445 }
446
447 #[test]
448 fn compute_key_returns_key() {
449 let secret_a = BoxedUint::from_be_hex(
450 "37bbf7bf6a51f902673556ea6a2db91dd9987554ab74c3bc089b213693d9c06e",
451 SCALAR_BITS,
452 )
453 .expect("valid hex");
454 let srp_x = HEXLOWER
455 .decode(b"9559afc0581390b1190a57dd281729baa237760982c7369c4c14d42157703a0f")
456 .expect("valid hex");
457
458 let key = compute_key(&secret_a, &big(SHARED_A), &big(SHARED_B), &srp_x);
459
460 assert_eq!(
461 HEXLOWER.encode(&key),
462 "9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc"
463 );
464 }
465
466 #[test]
467 fn calculate_client_hash_returns_hash() {
468 let session_key = HEXLOWER
469 .decode(b"9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc")
470 .expect("valid hex");
471 let salt = HEXLOWER
472 .decode(b"c813e48eb6e88c7557c9a70fcbda0fbc")
473 .expect("valid hex");
474
475 let hash = calculate_client_hash(
476 &session_key,
477 "[email protected]",
478 "P9JQCW",
479 &salt,
480 &big(SHARED_A),
481 &big(SHARED_B),
482 );
483
484 assert_eq!(
485 HEXLOWER.encode(&hash),
486 "e74d30467ccdfdf7d61973b9a94f88bd2b7155ba304138f5d02e2078c3a124fa"
487 );
488 }
489
490 #[test]
491 fn compute_x_returns_x() {
492 let salt =
493 super::super::opdata::decode64_loose("-JLqTVQLjQg08LWZ0gyuUA").expect("valid salt");
494 let account_key =
495 AccountKey::parse("A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9").expect("valid account key");
496
497 let srp_info = SrpInfo::new("SRPg-4096".into(), "PBES2g-HS256".into(), 100000, salt)
498 .expect("supported parameters");
499
500 let credentials = Credentials {
501 username: "username".into(),
502 password: "password".into(),
503 account_key: "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9".into(),
504 sign_in_address: SignInAddress::new("my", SignInDomain::Global)
505 .expect("a valid subdomain"),
506 device_uuid: "device-uuid".into(),
507 };
508
509 let x = compute_x(&credentials, &account_key, &srp_info).expect("derivation succeeds");
510
511 assert_eq!(
512 HEXLOWER.encode(&x),
513 "e7e14f282b01332cc193dc42f8501e3ffe8afdbf4b431ed4bfd885ff0bdfecf3"
514 );
515 }
516
517 #[test]
520 fn calculate_server_hash_returns_hash() {
521 let client_hash = HEXLOWER
522 .decode(b"e74d30467ccdfdf7d61973b9a94f88bd2b7155ba304138f5d02e2078c3a124fa")
523 .expect("valid hex");
524 let session_key = HEXLOWER
525 .decode(b"9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc")
526 .expect("valid hex");
527
528 let hash = calculate_server_hash(&big(SHARED_A), &client_hash, &session_key);
529
530 assert_eq!(
531 HEXLOWER.encode(&hash),
532 "e3ebd2e43efdc593960267f26b8a149e4368a5d5e963995ef9ceb6820307d6f9"
533 );
534 }
535
536 const VERIFY_SESSION_KEY: [u8; 32] = [0u8; 32];
537 const VERIFY_USERNAME: &str = "[email protected]";
538 const VERIFY_KEY_UUID: &str = "RTN9SA";
539 const VERIFY_SALT: &[u8] = b"salt";
540
541 fn verify_ephemerals() -> (BoxedUint, BoxedUint) {
542 (BoxedUint::from(2u32), BoxedUint::from(3u32))
543 }
544
545 async fn run_verify_key(server_hash: &str) -> Result<(), OnePasswordError> {
547 let server = MockServer::start().await;
548 server
549 .register(
550 Mock::given(matchers::path("/api/v2/auth/confirm-key"))
551 .respond_with(
552 ResponseTemplate::new(200)
553 .set_body_json(json!({ "serverVerifyHash": server_hash })),
554 )
555 .expect(1),
556 )
557 .await;
558 let rest = RestClient::new(
559 new_http_client(),
560 format!("http://{}/api", server.address()),
561 "client-id",
562 "user-agent",
563 "op-user-agent",
564 )
565 .expect("valid headers");
566
567 let (shared_a, shared_b) = verify_ephemerals();
568 let result = verify_key(
569 &VERIFY_SESSION_KEY,
570 VERIFY_USERNAME,
571 VERIFY_KEY_UUID,
572 VERIFY_SALT,
573 &shared_a,
574 &shared_b,
575 &rest,
576 )
577 .await;
578
579 server.verify().await;
580 result
581 }
582
583 #[tokio::test]
584 async fn verify_key_accepts_a_matching_server_hash() {
585 let (shared_a, shared_b) = verify_ephemerals();
586 let client_hash = calculate_client_hash(
587 &VERIFY_SESSION_KEY,
588 VERIFY_USERNAME,
589 VERIFY_KEY_UUID,
590 VERIFY_SALT,
591 &shared_a,
592 &shared_b,
593 );
594 let server_hash = BASE64URL_NOPAD.encode(&calculate_server_hash(
595 &shared_a,
596 &client_hash,
597 &VERIFY_SESSION_KEY,
598 ));
599
600 run_verify_key(&server_hash)
601 .await
602 .expect("the server proved it holds the session key");
603 }
604
605 #[tokio::test]
606 async fn verify_key_rejects_a_server_hash_it_did_not_expect() {
607 for server_hash in ["", "not-the-hash"] {
608 let error = run_verify_key(server_hash)
609 .await
610 .expect_err("the server did not prove it holds the session key");
611
612 assert!(
613 error.to_string().contains("does not match"),
614 "unexpected error: {error}"
615 );
616 }
617 }
618
619 #[test]
620 fn srp_info_rejects_unsupported_parameters() {
621 let bad_method = SrpInfo::new("SRPg-2048".into(), "PBES2g-HS256".into(), 100000, vec![])
622 .expect_err("only SRPg-4096 is supported");
623 assert!(bad_method.to_string().contains("SRPg-2048"));
624
625 let too_few = SrpInfo::new("SRPg-4096".into(), "PBES2g-HS256".into(), 9999, vec![])
626 .expect_err("a count below the floor is rejected");
627 assert!(too_few.to_string().contains("9999 iterations"));
628
629 let bad_key_method =
630 SrpInfo::new("SRPg-4096".into(), "PBES2g-HS512".into(), 100000, vec![])
631 .expect_err("only the SHA-256 variants are supported");
632 assert!(bad_key_method.to_string().contains("PBES2g-HS512"));
633 }
634}