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 .map_err(|error| match error {
320 OnePasswordError::UnexpectedStatus { status: 401, .. } => {
323 OnePasswordError::BadCredentials
324 }
325 error => error,
326 })?;
327
328 let expected = calculate_server_hash(shared_a, &client_hash, session_key);
331 if response.server_verify_hash != BASE64URL_NOPAD.encode(&expected) {
332 return Err(OnePasswordError::Internal(
333 "the server verification hash does not match".into(),
334 ));
335 }
336 Ok(())
337}
338
339fn calculate_server_hash(shared_a: &BoxedUint, client_hash: &[u8], session_key: &[u8]) -> [u8; 32] {
342 let mut buffer = to_compatible_byte_array(shared_a);
343 buffer.extend_from_slice(client_hash);
344 buffer.extend_from_slice(session_key);
345
346 sha256(&buffer)
347}
348
349fn calculate_client_hash(
352 session_key: &[u8],
353 username: &str,
354 key_uuid: &str,
355 salt: &[u8],
356 shared_a: &BoxedUint,
357 shared_b: &BoxedUint,
358) -> [u8; 32] {
359 let sirp_n = sha256(&to_compatible_byte_array(&N));
360 let sirp_g = sha256(&to_compatible_byte_array(&G));
361 let identity = sha256(calculate_identity(username, key_uuid).as_bytes());
362
363 let mut buffer = Vec::new();
365 for (a, b) in sirp_n.iter().zip(sirp_g.iter()) {
366 buffer.push(a ^ b);
367 }
368
369 buffer.extend_from_slice(&identity);
371 buffer.extend_from_slice(salt);
372 buffer.extend_from_slice(&to_compatible_byte_array(shared_a));
373 buffer.extend_from_slice(&to_compatible_byte_array(shared_b));
374 buffer.extend_from_slice(session_key);
375
376 sha256(&buffer)
377}
378
379fn compute_x(
381 credentials: &Credentials,
382 account_key: &AccountKey,
383 srp_info: &SrpInfo,
384) -> Result<[u8; 32], OnePasswordError> {
385 let k1 = kdf::hkdf_sha256(
386 SRP_METHOD,
387 &srp_info.salt,
388 kdf::normalize_username(&credentials.username).as_bytes(),
389 );
390 let k2 = kdf::pbes2(
391 &kdf::normalize_password(&credentials.password),
392 &k1,
393 srp_info.iterations,
394 );
395 account_key.combine_with(&k2)
396}
397
398fn sha256(data: &[u8]) -> [u8; 32] {
399 Sha256::digest(data).into()
400}
401
402#[cfg(test)]
403mod tests {
404 use bitwarden_api_base::new_http_client;
405 use data_encoding::HEXLOWER;
406 use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
407
408 use super::{
409 super::sign_in::{SignInAddress, SignInDomain},
410 *,
411 };
412
413 const SHARED_A: &str = "843c9c4977cf9c767452c90708c3dbdf3508c0016f8a56abc20c2e654dbd74c2c04b9412528a0927f499b245f9ad6742052662de2f725bf2a6c84913062842b4b2aaa8d41598c0d11424745bbae928d8e00e3c2c831c5ae90e128b719adb8be3845561186826462f0dbbdba272666c039f075b3da18c866c61a208cb9aed5ade03e6570818b7146c789f2e2928958ec7bebffbf2cc06cbb83b77ed80eae95e194502dead2e945e885d145d4521b74b8669211ffe718b20f04253d19550e0f9e8f1f0381caa2200223904a94d1e70f7db7cfa7d10d415bf7571f656a2e7bac3d142a2fa60b5a4e2fec4a82348fb46e03b65938f960373eefb95e50b1dd38134593b2f3ed0a19ae8684b4b54a04e0e022e01abc03072aa2e0096b209eaadb8dae57acd607a46e27bc5bfa66c3887e03441b4628135f830d1d78c7a60366d88cb42ed7ddd2dc32049f9dd3a1f459b610d41d25e8615f3271fcadcd37bf1b13c84c049d57d14ded500290b430c33d1d1dc3b04af66862ca3b4d501e2827355f68eaaf063a131c2436aa0a75519b7ac4d79845b6235898dcd9bef1093618b7c5bc5d73a7fc2a5ef8bca638e922152e459e89652b4a7d7d19cfd24de93f72f20e3a6f4325abf5ca1aec3ef3f392cc356c80b72e43a577775d2bf613b60d9f46d130e9881534e7548241e612901f61d5c5acb62100b8371c8dc42747437cd9ddcf9debf";
414 const SHARED_B: &str = "7112742e3035eca37656e1ad2171516e3e154bcbabbcb5f52787aa53ad882cffd8e952bd67dbc8059025be23a0b86914bf8ec4c08cac0b3448a99d8c097e4b0c6942870b2cd2c56a58499c81c294bf2f64de408535f0a36ba416177519dcb5a54b7a403459abb1bfe8aecb92048e84a55ba48f1672f6ee3f30abff81868e88c8bb25c7c17292e535f91debda167af8f12d1e1073a48a9257b443dacd8ba47270051b03940117d2cec29f6521a3e78e575634db5bc87d479a4327db1b30578c90553edd3de58af08e9157a11b352b0bd7fca70d469809b3d516fed4edc989b78c6f330e553947111c563cb8c8ff184179cf7b8733494e16f3e38ed7cd42651c5bb4d81548c4b320996445b6f1a4c34a6211b5f65e561c04009c7422e289d7035085e21258513040b16bea0d3e91304879fa61f48af4daefce65d0917e4af106d868c6189dfd9031c8a3b2d97fa2a50445d6a818341fed7ad2a986f5aa691626426dc2b1047e1db8a1984f8fda526f21e825df6b4cc60cd31300181a3782e53d039f85164e417b419cde581826b08887f25277f9f7c0933aa596f5a4bb27af7bffb095027e326d1c02544357eaa553ac93b564bb5953b8fc498044d65b8003ad93f95c319ce6af0a0327151935e860c3e5dad17cd65ae4318e76905ce2a3ae239c12ab207313af3c0c7744e7aee2584043ae71dfc3e376bf747f92fa5a94bd36cb";
415
416 fn big(hex: &str) -> BoxedUint {
417 from_server_hex(hex).expect("valid hex")
418 }
419
420 #[test]
421 fn to_server_hex_returns_hex_string() {
422 let cases: [(u32, &str); 8] = [
423 (0, "0"),
424 (1, "1"),
425 (0xD, "d"),
426 (0xDE, "de"),
427 (0xDEA, "dea"),
428 (0xDEAD, "dead"),
429 (0x80, "80"),
430 (0xFF, "ff"),
431 ];
432 for (number, expected) in cases {
433 assert_eq!(to_server_hex(&BoxedUint::from(number)), expected);
434 }
435 }
436
437 #[test]
438 fn from_server_hex_rejects_malformed_values() {
439 for input in ["", "not hex", "é", &"f".repeat(N_HEX.len() + 1)] {
440 from_server_hex(input).expect_err("malformed values are rejected");
441 }
442 }
443
444 #[test]
446 fn validate_b_rejects_zero_and_one_mod_n() {
447 let n = big(N_HEX);
448 for value in [big("0"), big("1"), n.clone(), n.wrapping_add(big("1"))] {
449 validate_b(&value).expect_err("B must not be 0 or 1 mod N");
450 }
451
452 validate_b(&big(SHARED_B)).expect("a normal B is accepted");
453 }
454
455 #[test]
456 fn compute_key_returns_key() {
457 let secret_a = BoxedUint::from_be_hex(
458 "37bbf7bf6a51f902673556ea6a2db91dd9987554ab74c3bc089b213693d9c06e",
459 SCALAR_BITS,
460 )
461 .expect("valid hex");
462 let srp_x = HEXLOWER
463 .decode(b"9559afc0581390b1190a57dd281729baa237760982c7369c4c14d42157703a0f")
464 .expect("valid hex");
465
466 let key = compute_key(&secret_a, &big(SHARED_A), &big(SHARED_B), &srp_x);
467
468 assert_eq!(
469 HEXLOWER.encode(&key),
470 "9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc"
471 );
472 }
473
474 #[test]
475 fn calculate_client_hash_returns_hash() {
476 let session_key = HEXLOWER
477 .decode(b"9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc")
478 .expect("valid hex");
479 let salt = HEXLOWER
480 .decode(b"c813e48eb6e88c7557c9a70fcbda0fbc")
481 .expect("valid hex");
482
483 let hash = calculate_client_hash(
484 &session_key,
485 "[email protected]",
486 "P9JQCW",
487 &salt,
488 &big(SHARED_A),
489 &big(SHARED_B),
490 );
491
492 assert_eq!(
493 HEXLOWER.encode(&hash),
494 "e74d30467ccdfdf7d61973b9a94f88bd2b7155ba304138f5d02e2078c3a124fa"
495 );
496 }
497
498 #[test]
499 fn compute_x_returns_x() {
500 let salt =
501 super::super::opdata::decode64_loose("-JLqTVQLjQg08LWZ0gyuUA").expect("valid salt");
502 let account_key =
503 AccountKey::parse("A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9").expect("valid account key");
504
505 let srp_info = SrpInfo::new("SRPg-4096".into(), "PBES2g-HS256".into(), 100000, salt)
506 .expect("supported parameters");
507
508 let credentials = Credentials {
509 username: "username".into(),
510 password: "password".into(),
511 account_key: "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9".into(),
512 sign_in_address: SignInAddress {
513 subdomain: "my".into(),
514 domain: SignInDomain::Global,
515 },
516 };
517
518 let x = compute_x(&credentials, &account_key, &srp_info).expect("derivation succeeds");
519
520 assert_eq!(
521 HEXLOWER.encode(&x),
522 "e7e14f282b01332cc193dc42f8501e3ffe8afdbf4b431ed4bfd885ff0bdfecf3"
523 );
524 }
525
526 #[test]
529 fn calculate_server_hash_returns_hash() {
530 let client_hash = HEXLOWER
531 .decode(b"e74d30467ccdfdf7d61973b9a94f88bd2b7155ba304138f5d02e2078c3a124fa")
532 .expect("valid hex");
533 let session_key = HEXLOWER
534 .decode(b"9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc")
535 .expect("valid hex");
536
537 let hash = calculate_server_hash(&big(SHARED_A), &client_hash, &session_key);
538
539 assert_eq!(
540 HEXLOWER.encode(&hash),
541 "e3ebd2e43efdc593960267f26b8a149e4368a5d5e963995ef9ceb6820307d6f9"
542 );
543 }
544
545 const VERIFY_SESSION_KEY: [u8; 32] = [0u8; 32];
546 const VERIFY_USERNAME: &str = "[email protected]";
547 const VERIFY_KEY_UUID: &str = "RTN9SA";
548 const VERIFY_SALT: &[u8] = b"salt";
549
550 fn verify_ephemerals() -> (BoxedUint, BoxedUint) {
551 (BoxedUint::from(2u32), BoxedUint::from(3u32))
552 }
553
554 async fn run_verify_key(server_hash: &str) -> Result<(), OnePasswordError> {
556 run_verify_key_against(
557 ResponseTemplate::new(200).set_body_json(json!({ "serverVerifyHash": server_hash })),
558 )
559 .await
560 }
561
562 async fn run_verify_key_against(response: ResponseTemplate) -> Result<(), OnePasswordError> {
564 let server = MockServer::start().await;
565 server
566 .register(
567 Mock::given(matchers::path("/api/v2/auth/confirm-key"))
568 .respond_with(response)
569 .expect(1),
570 )
571 .await;
572 let rest = RestClient::new(
573 new_http_client(),
574 format!("http://{}/api", server.address()),
575 "client-id",
576 "user-agent",
577 "op-user-agent",
578 )
579 .expect("valid headers");
580
581 let (shared_a, shared_b) = verify_ephemerals();
582 let result = verify_key(
583 &VERIFY_SESSION_KEY,
584 VERIFY_USERNAME,
585 VERIFY_KEY_UUID,
586 VERIFY_SALT,
587 &shared_a,
588 &shared_b,
589 &rest,
590 )
591 .await;
592
593 server.verify().await;
594 result
595 }
596
597 #[tokio::test]
598 async fn verify_key_reports_a_rejected_proof_as_bad_credentials() {
599 let error = run_verify_key_against(ResponseTemplate::new(401))
600 .await
601 .expect_err("the server rejects the proof");
602
603 assert!(matches!(error, OnePasswordError::BadCredentials));
604 }
605
606 #[tokio::test]
607 async fn verify_key_keeps_other_failures() {
608 let error = run_verify_key_against(ResponseTemplate::new(503))
609 .await
610 .expect_err("the server is unavailable");
611
612 assert!(matches!(
613 error,
614 OnePasswordError::UnexpectedStatus { status: 503, .. }
615 ));
616 }
617
618 #[tokio::test]
619 async fn verify_key_accepts_a_matching_server_hash() {
620 let (shared_a, shared_b) = verify_ephemerals();
621 let client_hash = calculate_client_hash(
622 &VERIFY_SESSION_KEY,
623 VERIFY_USERNAME,
624 VERIFY_KEY_UUID,
625 VERIFY_SALT,
626 &shared_a,
627 &shared_b,
628 );
629 let server_hash = BASE64URL_NOPAD.encode(&calculate_server_hash(
630 &shared_a,
631 &client_hash,
632 &VERIFY_SESSION_KEY,
633 ));
634
635 run_verify_key(&server_hash)
636 .await
637 .expect("the server proved it holds the session key");
638 }
639
640 #[tokio::test]
641 async fn verify_key_rejects_a_server_hash_it_did_not_expect() {
642 for server_hash in ["", "not-the-hash"] {
643 let error = run_verify_key(server_hash)
644 .await
645 .expect_err("the server did not prove it holds the session key");
646
647 assert!(
648 error.to_string().contains("does not match"),
649 "unexpected error: {error}"
650 );
651 }
652 }
653
654 #[test]
655 fn srp_info_rejects_unsupported_parameters() {
656 let bad_method = SrpInfo::new("SRPg-2048".into(), "PBES2g-HS256".into(), 100000, vec![])
657 .expect_err("only SRPg-4096 is supported");
658 assert!(bad_method.to_string().contains("SRPg-2048"));
659
660 let too_few = SrpInfo::new("SRPg-4096".into(), "PBES2g-HS256".into(), 9999, vec![])
661 .expect_err("a count below the floor is rejected");
662 assert!(too_few.to_string().contains("9999 iterations"));
663
664 let bad_key_method =
665 SrpInfo::new("SRPg-4096".into(), "PBES2g-HS512".into(), 100000, vec![])
666 .expect_err("only the SHA-256 variants are supported");
667 assert!(bad_key_method.to_string().contains("PBES2g-HS512"));
668 }
669}