Skip to main content

bitwarden_importers/importers/onepassword/access/
srp.rs

1//! SRP-4096: the A/B exchange with the server and the crypto behind it.
2
3use 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
28/// The width of the SRP group, and so of every value reduced modulo it.
29const N_BITS: u32 = 4096;
30
31/// The width of the SHA-256 values SRP uses as scalars: `u`, `k` and `x`.
32const SCALAR_BITS: u32 = 256;
33
34/// The 4096-bit SRP group prime (RFC 3526).
35static 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
40/// The Montgomery form of [`N`], built once.
41static N_PARAMS: LazyLock<BoxedMontyParams> =
42    LazyLock::new(|| BoxedMontyParams::new_vartime(N.clone()));
43
44/// The SRP group generator.
45static 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/// The account's public SRP parameters, as returned by `v3/auth/start`.
64///
65/// Validated on construction, so [`compute_x`] cannot fail on an unsupported method or too few
66/// iterations. These are public KDF parameters and carry no secret.
67#[derive(Debug, PartialEq)]
68pub(super) struct SrpInfo {
69    iterations: u32,
70    salt: Vec<u8>,
71}
72
73impl SrpInfo {
74    /// Rejects parameters this module cannot honour.
75    pub(super) fn new(
76        method: String,
77        key_method: String,
78        iterations: u32,
79        salt: Vec<u8>,
80    ) -> Result<SrpInfo, OnePasswordError> {
81        // TODO: Support the legacy `SRP-4096`. The client derives its x differently and ends in
82        // SHA-1.
83        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    /// The salt, also mixed into the client verification hash.
101    pub(super) fn salt(&self) -> &[u8] {
102        &self.salt
103    }
104}
105
106/// Runs the SRP exchange and labels the resulting key with the session id.
107pub(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
125/// The exchange itself, with `secret_a` taken as an argument so tests can pin it.
126async 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    // The password and the Secret Key stretched into the SRP private value.
134    let srp_x = compute_x(credentials, account_key, srp_info)?;
135
136    // Trade our public ephemeral for the server's.
137    let shared_a = compute_shared_a(secret_a);
138    let shared_b = exchange_a_for_b(&shared_a, rest).await?;
139
140    // A B divisible by N would collapse the session key to a value the server picked.
141    validate_b(&shared_b)?;
142
143    // Both sides reach the same key without the password ever crossing the wire.
144    let session_key = compute_key(secret_a, &shared_a, &shared_b, &srp_x);
145
146    // Prove to the server that we hold it.
147    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
161/// Generates the ephemeral secret `a` as a random 256-bit value.
162fn generate_secret_a() -> BoxedUint {
163    let mut bytes = [0u8; 32];
164    bitwarden_random::rng().fill_bytes(&mut bytes);
165    scalar(&bytes)
166}
167
168/// `A = g^a mod N`.
169fn compute_shared_a(secret_a: &BoxedUint) -> BoxedUint {
170    mod_pow(&G, secret_a)
171}
172
173/// Rejects a server `B` that is 0 or 1 modulo `N`, the two values the web client refuses.
174fn 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
184/// Sends `userA` and returns the server's `userB`.
185async 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
195/// `base ^ exponent mod N`, constant time in `exponent`.
196fn mod_pow(base: &BoxedUint, exponent: &BoxedUint) -> BoxedUint {
197    BoxedMontyForm::new(base.resize(N_BITS), &N_PARAMS)
198        .pow(exponent)
199        .retrieve()
200}
201
202/// A SHA-256 output as an SRP scalar.
203fn scalar(hash: &[u8]) -> BoxedUint {
204    BoxedUint::from_be_slice(hash, SCALAR_BITS).expect("an SRP scalar is a 32-byte hash")
205}
206
207/// Computes the SRP session key `K`.
208fn compute_key(
209    secret_a: &BoxedUint,
210    shared_a: &BoxedUint,
211    shared_b: &BoxedUint,
212    srp_x: &[u8],
213) -> [u8; 32] {
214    // The multiplier k = H(N, g), always
215    // 3509477ea9fca66eadb7cf7b1bd0eb508f54d3989a9c988006a7d0b338374dd2 for this group.
216    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    // The scrambling parameter u = H(A, B), which ties the key to both ephemerals.
221    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    // a + u*x, the half only we can build.
226    let x = scalar(srp_x);
227    let exponent = scalar(&ab_sha256)
228        .concatenating_mul(&x)
229        .wrapping_add(secret_a);
230
231    // shared_b - k*g^x strips the verifier term out of the server's ephemeral, leaving g^b. The
232    // difference is almost always negative, so each operand is reduced mod N first and `sub_mod`
233    // adds N back when the subtraction underflows.
234    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    // K is the premaster secret hashed in the server's hex encoding.
242    sha256(to_server_hex(&mod_pow(&base, &exponent)).as_bytes())
243}
244
245/// Hex in the exact format 1Password's server expects: lowercase, with all leading zero nibbles
246/// stripped. The output may be odd-length; that is intentional. Both `userA` (sent over the wire)
247/// and `u` (the SRP shared secret hashed into the session key) use this encoding, and changing it
248/// would break wire compatibility or session-key agreement with the server.
249///
250/// Mirrors the official 1Password JS client (webapi bundle):
251///   `q = e => e.toString(16).replace(/^(0x)?0*/, "")`
252fn 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
260/// Parses a value the server sent in the encoding above.
261fn from_server_hex(hex: &str) -> Result<BoxedUint, OnePasswordError> {
262    let invalid = || OnePasswordError::Internal("invalid shared value from server".into());
263
264    // The ASCII check is load bearing: padding below counts characters while `from_be_hex` asserts
265    // on byte length, so a multi-byte character would overshoot the width and panic.
266    if hex.is_empty() || hex.len() > N_HEX.len() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
267        return Err(invalid());
268    }
269
270    // The server strips leading zeros, `from_be_hex` wants the full width of the group.
271    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
277/// `value mod N` as big-endian bytes, always the full width of `N`.
278fn mod_n_bytes(value: &BoxedUint) -> Vec<u8> {
279    value.rem(N.as_nz_ref()).to_be_bytes().into_vec()
280}
281
282/// Big-endian bytes with leading zeros stripped, the encoding the server hashes over.
283fn 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
291/// `SHA256(SHA256(uuid) || SHA256(lower(nfkd(username))))`, url-safe base64.
292fn 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
301/// Sends the client verification hash to confirm the session key, then checks the server's answer.
302async 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    // The only step that authenticates the server: nothing but a party holding the same session key
321    // can produce this hash.
322    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
331/// The server's answer to [`calculate_client_hash`]: `H(A || M1 || K)`, where `M1` is the client
332/// hash we just sent. `A` uses the same leading-zero-stripped encoding as the client hash.
333fn 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
341/// The client verification hash sent to `v2/auth/confirm-key`:
342/// `H(H(N) xor H(g) || H(I) || s || A || B || K)`.
343fn 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    // Opens with the group both sides agreed on.
356    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    // Then who we are, the salt, both ephemerals, and the key only we and the server derived.
362    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
371/// Derives SRP `x`, which proves we know the password without sending it.
372fn 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    /// A `B` congruent to 0 or 1 collapses the session key to a value the server picked.
437    #[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    /// Pinned against the web client's `recieveServerHash`, which hashes
518    /// `z(bigA) || m || rawKey`.
519    #[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    /// Runs `verify_key` against a server that answers with `server_hash`.
546    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}