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 from a precomputed x and labels the key with the session id. The salt only
107/// feeds the verification hash.
108pub(super) async fn perform_and_verify_with_x(
109    srp_x: &[u8],
110    username: &str,
111    key_uuid: &str,
112    salt: &[u8],
113    session_id: &str,
114    rest: &RestClient,
115) -> Result<AesKey, OnePasswordError> {
116    let srp_x = scalar_bytes(srp_x)?;
117
118    let key = perform(&generate_secret_a(), srp_x, username, key_uuid, salt, rest).await?;
119    Ok(AesKey::new(session_id, key.to_vec()))
120}
121
122/// The bytes of x without leading zeros, which a big integer serializer may add. x is a SHA-256
123/// scalar, so a value that still does not fit comes from a malformed credential bundle.
124fn scalar_bytes(srp_x: &[u8]) -> Result<&[u8], OnePasswordError> {
125    let leading_zeros = srp_x.iter().take_while(|byte| **byte == 0).count();
126    let significant = &srp_x[leading_zeros..];
127    if significant.len() > SCALAR_BITS as usize / 8 {
128        return Err(OnePasswordError::Internal(
129            "the SRP x is longer than 32 bytes".into(),
130        ));
131    }
132
133    Ok(significant)
134}
135
136/// The exchange itself, with `secret_a` taken as an argument so tests can pin it.
137async fn perform(
138    secret_a: &BoxedUint,
139    srp_x: &[u8],
140    username: &str,
141    key_uuid: &str,
142    salt: &[u8],
143    rest: &RestClient,
144) -> Result<[u8; 32], OnePasswordError> {
145    // Trade our public ephemeral for the server's.
146    let shared_a = compute_shared_a(secret_a);
147    let shared_b = exchange_a_for_b(&shared_a, rest).await?;
148
149    // A B divisible by N would collapse the session key to a value the server picked.
150    validate_b(&shared_b)?;
151
152    // Both sides reach the same key without the password ever crossing the wire.
153    let session_key = compute_key(secret_a, &shared_a, &shared_b, srp_x);
154
155    // Prove to the server that we hold it.
156    verify_key(
157        &session_key,
158        username,
159        key_uuid,
160        salt,
161        &shared_a,
162        &shared_b,
163        rest,
164    )
165    .await?;
166
167    Ok(session_key)
168}
169
170/// Generates the ephemeral secret `a` as a random 256-bit value.
171fn generate_secret_a() -> BoxedUint {
172    let mut bytes = [0u8; 32];
173    bitwarden_random::rng().fill_bytes(&mut bytes);
174    scalar(&bytes)
175}
176
177/// `A = g^a mod N`.
178fn compute_shared_a(secret_a: &BoxedUint) -> BoxedUint {
179    mod_pow(&G, secret_a)
180}
181
182/// Rejects a server `B` that is 0 or 1 modulo `N`, the two values the web client refuses.
183fn validate_b(shared_b: &BoxedUint) -> Result<(), OnePasswordError> {
184    let reduced = shared_b.rem(N.as_nz_ref());
185    if bool::from(reduced.is_zero() | reduced.is_one()) {
186        return Err(OnePasswordError::Internal(
187            "Shared B validation failed".into(),
188        ));
189    }
190    Ok(())
191}
192
193/// Sends `userA` and returns the server's `userB`.
194async fn exchange_a_for_b(
195    shared_a: &BoxedUint,
196    rest: &RestClient,
197) -> Result<BoxedUint, OnePasswordError> {
198    let response: AForB = rest
199        .post_json(AUTH_ENDPOINT, json!({ "userA": to_server_hex(shared_a) }))
200        .await?;
201    from_server_hex(&response.b)
202}
203
204/// `base ^ exponent mod N`, constant time in `exponent`.
205fn mod_pow(base: &BoxedUint, exponent: &BoxedUint) -> BoxedUint {
206    BoxedMontyForm::new(base.resize(N_BITS), &N_PARAMS)
207        .pow(exponent)
208        .retrieve()
209}
210
211/// A SHA-256 output as an SRP scalar.
212fn scalar(hash: &[u8]) -> BoxedUint {
213    BoxedUint::from_be_slice(hash, SCALAR_BITS).expect("an SRP scalar is a 32-byte hash")
214}
215
216/// Computes the SRP session key `K`.
217fn compute_key(
218    secret_a: &BoxedUint,
219    shared_a: &BoxedUint,
220    shared_b: &BoxedUint,
221    srp_x: &[u8],
222) -> [u8; 32] {
223    // a + u*x, the half only we can build.
224    let x = scalar(srp_x);
225    let exponent = scalar(&compute_u(shared_a, shared_b))
226        .concatenating_mul(&x)
227        .wrapping_add(secret_a);
228
229    // shared_b - k*g^x strips the verifier term out of the server's ephemeral, leaving g^b. The
230    // difference is almost always negative, so each operand is reduced mod N first and `sub_mod`
231    // adds N back when the subtraction underflows.
232    let k_g_pow_x = mod_pow(&G, &x)
233        .concatenating_mul(&scalar(&compute_k()))
234        .rem(N.as_nz_ref());
235    let base = shared_b
236        .rem(N.as_nz_ref())
237        .sub_mod(&k_g_pow_x, N.as_nz_ref());
238
239    // K is the premaster secret hashed in the server's hex encoding.
240    sha256(to_server_hex(&mod_pow(&base, &exponent)).as_bytes())
241}
242
243/// The multiplier `k = H(N, g)`, always
244/// 3509477ea9fca66eadb7cf7b1bd0eb508f54d3989a9c988006a7d0b338374dd2 for this group.
245fn compute_k() -> [u8; 32] {
246    let mut input = to_compatible_byte_array(&N);
247    input.extend_from_slice(&mod_n_bytes(&G));
248    sha256(&input)
249}
250
251/// The scrambling parameter `u = H(A, B)`, which ties the key to both ephemerals.
252fn compute_u(shared_a: &BoxedUint, shared_b: &BoxedUint) -> [u8; 32] {
253    let mut ab = mod_n_bytes(shared_a);
254    ab.extend_from_slice(&mod_n_bytes(shared_b));
255    sha256(&ab)
256}
257
258/// Hex in the exact format 1Password's server expects: lowercase, with all leading zero nibbles
259/// stripped. The output may be odd-length; that is intentional. Both `userA` (sent over the wire)
260/// and `u` (the SRP shared secret hashed into the session key) use this encoding, and changing it
261/// would break wire compatibility or session-key agreement with the server.
262///
263/// Mirrors the official 1Password JS client (webapi bundle):
264///   `q = e => e.toString(16).replace(/^(0x)?0*/, "")`
265fn to_server_hex(value: &BoxedUint) -> String {
266    let hex = HEXLOWER.encode(&value.to_be_bytes());
267    match hex.trim_start_matches('0') {
268        "" => "0".to_string(),
269        trimmed => trimmed.to_string(),
270    }
271}
272
273/// Parses a value the server sent in the encoding above.
274fn from_server_hex(hex: &str) -> Result<BoxedUint, OnePasswordError> {
275    let invalid = || OnePasswordError::Internal("invalid shared value from server".into());
276
277    // The ASCII check is load bearing: padding below counts characters while `from_be_hex` asserts
278    // on byte length, so a multi-byte character would overshoot the width and panic.
279    if hex.is_empty() || hex.len() > N_HEX.len() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
280        return Err(invalid());
281    }
282
283    // The server strips leading zeros, `from_be_hex` wants the full width of the group.
284    let padded = format!("{hex:0>width$}", width = N_HEX.len());
285    BoxedUint::from_be_hex(&padded, N_BITS)
286        .into_option()
287        .ok_or_else(invalid)
288}
289
290/// `value mod N` as big-endian bytes, always the full width of `N`.
291fn mod_n_bytes(value: &BoxedUint) -> Vec<u8> {
292    value.rem(N.as_nz_ref()).to_be_bytes().into_vec()
293}
294
295/// Big-endian bytes with leading zeros stripped, the encoding the server hashes over.
296fn to_compatible_byte_array(value: &BoxedUint) -> Vec<u8> {
297    let bytes = value.to_be_bytes();
298    match bytes.iter().position(|byte| *byte != 0) {
299        Some(first_significant) => bytes[first_significant..].to_vec(),
300        None => vec![0],
301    }
302}
303
304/// `SHA256(SHA256(uuid) || SHA256(lower(nfkd(username))))`, url-safe base64.
305fn calculate_identity(username: &str, key_uuid: &str) -> String {
306    let username = kdf::normalize_identity_username(username);
307
308    let mut buffer = Vec::with_capacity(64);
309    buffer.extend_from_slice(&sha256(key_uuid.as_bytes()));
310    buffer.extend_from_slice(&sha256(username.as_bytes()));
311    BASE64URL_NOPAD.encode(&sha256(&buffer))
312}
313
314/// Sends the client verification hash to confirm the session key, then checks the server's answer.
315async fn verify_key(
316    session_key: &[u8],
317    username: &str,
318    key_uuid: &str,
319    salt: &[u8],
320    shared_a: &BoxedUint,
321    shared_b: &BoxedUint,
322    rest: &RestClient,
323) -> Result<(), OnePasswordError> {
324    let client_hash =
325        calculate_client_hash(session_key, username, key_uuid, salt, shared_a, shared_b);
326    let response: ServerHash = rest
327        .post_json(
328            CONFIRM_KEY_ENDPOINT,
329            json!({ "clientVerifyHash": BASE64URL_NOPAD.encode(&client_hash) }),
330        )
331        .await
332        .map_err(|error| match error {
333            // A wrong password or Secret Key derives a different verifier, and this is the first
334            // step that can tell. The server says so with a bare 401 and no error body.
335            OnePasswordError::UnexpectedStatus { status: 401, .. } => {
336                OnePasswordError::BadCredentials
337            }
338            error => error,
339        })?;
340
341    // The only step that authenticates the server: nothing but a party holding the same session key
342    // can produce this hash.
343    let expected = calculate_server_hash(shared_a, &client_hash, session_key);
344    if response.server_verify_hash != BASE64URL_NOPAD.encode(&expected) {
345        return Err(OnePasswordError::Internal(
346            "the server verification hash does not match".into(),
347        ));
348    }
349    Ok(())
350}
351
352/// The server's answer to [`calculate_client_hash`]: `H(A || M1 || K)`, where `M1` is the client
353/// hash we just sent. `A` uses the same leading-zero-stripped encoding as the client hash.
354fn calculate_server_hash(shared_a: &BoxedUint, client_hash: &[u8], session_key: &[u8]) -> [u8; 32] {
355    let mut buffer = to_compatible_byte_array(shared_a);
356    buffer.extend_from_slice(client_hash);
357    buffer.extend_from_slice(session_key);
358
359    sha256(&buffer)
360}
361
362/// The client verification hash sent to `v2/auth/confirm-key`:
363/// `H(H(N) xor H(g) || H(I) || s || A || B || K)`.
364fn calculate_client_hash(
365    session_key: &[u8],
366    username: &str,
367    key_uuid: &str,
368    salt: &[u8],
369    shared_a: &BoxedUint,
370    shared_b: &BoxedUint,
371) -> [u8; 32] {
372    let sirp_n = sha256(&to_compatible_byte_array(&N));
373    let sirp_g = sha256(&to_compatible_byte_array(&G));
374    let identity = sha256(calculate_identity(username, key_uuid).as_bytes());
375
376    // Opens with the group both sides agreed on.
377    let mut buffer = Vec::new();
378    for (a, b) in sirp_n.iter().zip(sirp_g.iter()) {
379        buffer.push(a ^ b);
380    }
381
382    // Then who we are, the salt, both ephemerals, and the key only we and the server derived.
383    buffer.extend_from_slice(&identity);
384    buffer.extend_from_slice(salt);
385    buffer.extend_from_slice(&to_compatible_byte_array(shared_a));
386    buffer.extend_from_slice(&to_compatible_byte_array(shared_b));
387    buffer.extend_from_slice(session_key);
388
389    sha256(&buffer)
390}
391
392/// Derives SRP `x`, which proves we know the password without sending it.
393pub(super) fn compute_x(
394    credentials: &Credentials,
395    account_key: &AccountKey,
396    srp_info: &SrpInfo,
397) -> Result<[u8; 32], OnePasswordError> {
398    let k1 = kdf::hkdf_sha256(
399        SRP_METHOD,
400        &srp_info.salt,
401        kdf::normalize_username(&credentials.username).as_bytes(),
402    );
403    let k2 = kdf::pbes2(
404        &kdf::normalize_password(&credentials.password),
405        &k1,
406        srp_info.iterations,
407    );
408    account_key.combine_with(&k2)
409}
410
411fn sha256(data: &[u8]) -> [u8; 32] {
412    Sha256::digest(data).into()
413}
414
415/// The server's half of the exchange, for the tests of flows that run a login end to end. It holds
416/// the verifier of an account, the way 1Password does, and reuses the math of the client.
417#[cfg(test)]
418pub(super) struct TestServer {
419    username: String,
420    key_uuid: String,
421    salt: Vec<u8>,
422    verifier: BoxedUint,
423    secret_b: BoxedUint,
424    shared_b: BoxedUint,
425}
426
427#[cfg(test)]
428impl TestServer {
429    /// A server that knows the account with the given `srp_x`, and answers with the SRP public
430    /// value `B = k*v + g^b`, where `b` is `secret_b`.
431    pub(super) fn new(
432        srp_x: &[u8],
433        secret_b: &[u8; 32],
434        username: &str,
435        key_uuid: &str,
436        salt: &[u8],
437    ) -> TestServer {
438        let verifier = mod_pow(&G, &scalar(srp_x));
439        let secret_b = scalar(secret_b);
440
441        // B = k*v + g^b
442        let k_verifier = verifier
443            .concatenating_mul(&scalar(&compute_k()))
444            .rem(N.as_nz_ref());
445        let shared_b = k_verifier.add_mod(&mod_pow(&G, &secret_b), N.as_nz_ref());
446
447        TestServer {
448            username: username.to_string(),
449            key_uuid: key_uuid.to_string(),
450            salt: salt.to_vec(),
451            verifier,
452            secret_b,
453            shared_b,
454        }
455    }
456
457    /// `B` in the encoding of `userB`.
458    pub(super) fn shared_b(&self) -> String {
459        to_server_hex(&self.shared_b)
460    }
461
462    /// The session key for the client's `A`, or `None` when `A` is not a value.
463    pub(super) fn session_key(&self, shared_a: &str) -> Option<[u8; 32]> {
464        let shared_a = from_server_hex(shared_a).ok()?;
465
466        // S = (A * v^u)^b
467        let u = scalar(&compute_u(&shared_a, &self.shared_b));
468        let base = mod_pow(&self.verifier, &u)
469            .concatenating_mul(&shared_a)
470            .rem(N.as_nz_ref());
471        Some(sha256(
472            to_server_hex(&mod_pow(&base, &self.secret_b)).as_bytes(),
473        ))
474    }
475
476    /// The server's proof, if `client_verify_hash` proves the client holds the session key of
477    /// `shared_a` and knows the account.
478    pub(super) fn confirm_key(&self, shared_a: &str, client_verify_hash: &str) -> Option<String> {
479        let session_key = self.session_key(shared_a)?;
480        let shared_a = from_server_hex(shared_a).ok()?;
481
482        let client_hash = calculate_client_hash(
483            &session_key,
484            &self.username,
485            &self.key_uuid,
486            &self.salt,
487            &shared_a,
488            &self.shared_b,
489        );
490        (BASE64URL_NOPAD.encode(&client_hash) == client_verify_hash).then(|| {
491            BASE64URL_NOPAD.encode(&calculate_server_hash(
492                &shared_a,
493                &client_hash,
494                &session_key,
495            ))
496        })
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use bitwarden_api_base::new_http_client;
503    use data_encoding::HEXLOWER;
504    use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
505
506    use super::{
507        super::sign_in::{SignInAddress, SignInDomain},
508        *,
509    };
510
511    const SHARED_A: &str = "843c9c4977cf9c767452c90708c3dbdf3508c0016f8a56abc20c2e654dbd74c2c04b9412528a0927f499b245f9ad6742052662de2f725bf2a6c84913062842b4b2aaa8d41598c0d11424745bbae928d8e00e3c2c831c5ae90e128b719adb8be3845561186826462f0dbbdba272666c039f075b3da18c866c61a208cb9aed5ade03e6570818b7146c789f2e2928958ec7bebffbf2cc06cbb83b77ed80eae95e194502dead2e945e885d145d4521b74b8669211ffe718b20f04253d19550e0f9e8f1f0381caa2200223904a94d1e70f7db7cfa7d10d415bf7571f656a2e7bac3d142a2fa60b5a4e2fec4a82348fb46e03b65938f960373eefb95e50b1dd38134593b2f3ed0a19ae8684b4b54a04e0e022e01abc03072aa2e0096b209eaadb8dae57acd607a46e27bc5bfa66c3887e03441b4628135f830d1d78c7a60366d88cb42ed7ddd2dc32049f9dd3a1f459b610d41d25e8615f3271fcadcd37bf1b13c84c049d57d14ded500290b430c33d1d1dc3b04af66862ca3b4d501e2827355f68eaaf063a131c2436aa0a75519b7ac4d79845b6235898dcd9bef1093618b7c5bc5d73a7fc2a5ef8bca638e922152e459e89652b4a7d7d19cfd24de93f72f20e3a6f4325abf5ca1aec3ef3f392cc356c80b72e43a577775d2bf613b60d9f46d130e9881534e7548241e612901f61d5c5acb62100b8371c8dc42747437cd9ddcf9debf";
512    const SHARED_B: &str = "7112742e3035eca37656e1ad2171516e3e154bcbabbcb5f52787aa53ad882cffd8e952bd67dbc8059025be23a0b86914bf8ec4c08cac0b3448a99d8c097e4b0c6942870b2cd2c56a58499c81c294bf2f64de408535f0a36ba416177519dcb5a54b7a403459abb1bfe8aecb92048e84a55ba48f1672f6ee3f30abff81868e88c8bb25c7c17292e535f91debda167af8f12d1e1073a48a9257b443dacd8ba47270051b03940117d2cec29f6521a3e78e575634db5bc87d479a4327db1b30578c90553edd3de58af08e9157a11b352b0bd7fca70d469809b3d516fed4edc989b78c6f330e553947111c563cb8c8ff184179cf7b8733494e16f3e38ed7cd42651c5bb4d81548c4b320996445b6f1a4c34a6211b5f65e561c04009c7422e289d7035085e21258513040b16bea0d3e91304879fa61f48af4daefce65d0917e4af106d868c6189dfd9031c8a3b2d97fa2a50445d6a818341fed7ad2a986f5aa691626426dc2b1047e1db8a1984f8fda526f21e825df6b4cc60cd31300181a3782e53d039f85164e417b419cde581826b08887f25277f9f7c0933aa596f5a4bb27af7bffb095027e326d1c02544357eaa553ac93b564bb5953b8fc498044d65b8003ad93f95c319ce6af0a0327151935e860c3e5dad17cd65ae4318e76905ce2a3ae239c12ab207313af3c0c7744e7aee2584043ae71dfc3e376bf747f92fa5a94bd36cb";
513
514    fn big(hex: &str) -> BoxedUint {
515        from_server_hex(hex).expect("valid hex")
516    }
517
518    #[test]
519    fn to_server_hex_returns_hex_string() {
520        let cases: [(u32, &str); 8] = [
521            (0, "0"),
522            (1, "1"),
523            (0xD, "d"),
524            (0xDE, "de"),
525            (0xDEA, "dea"),
526            (0xDEAD, "dead"),
527            (0x80, "80"),
528            (0xFF, "ff"),
529        ];
530        for (number, expected) in cases {
531            assert_eq!(to_server_hex(&BoxedUint::from(number)), expected);
532        }
533    }
534
535    #[test]
536    fn from_server_hex_rejects_malformed_values() {
537        for input in ["", "not hex", "é", &"f".repeat(N_HEX.len() + 1)] {
538            from_server_hex(input).expect_err("malformed values are rejected");
539        }
540    }
541
542    /// A `B` congruent to 0 or 1 collapses the session key to a value the server picked.
543    #[test]
544    fn validate_b_rejects_zero_and_one_mod_n() {
545        let n = big(N_HEX);
546        for value in [big("0"), big("1"), n.clone(), n.wrapping_add(big("1"))] {
547            validate_b(&value).expect_err("B must not be 0 or 1 mod N");
548        }
549
550        validate_b(&big(SHARED_B)).expect("a normal B is accepted");
551    }
552
553    #[test]
554    fn compute_key_returns_key() {
555        let secret_a = BoxedUint::from_be_hex(
556            "37bbf7bf6a51f902673556ea6a2db91dd9987554ab74c3bc089b213693d9c06e",
557            SCALAR_BITS,
558        )
559        .expect("valid hex");
560        let srp_x = HEXLOWER
561            .decode(b"9559afc0581390b1190a57dd281729baa237760982c7369c4c14d42157703a0f")
562            .expect("valid hex");
563
564        let key = compute_key(&secret_a, &big(SHARED_A), &big(SHARED_B), &srp_x);
565
566        assert_eq!(
567            HEXLOWER.encode(&key),
568            "9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc"
569        );
570    }
571
572    #[test]
573    fn calculate_client_hash_returns_hash() {
574        let session_key = HEXLOWER
575            .decode(b"9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc")
576            .expect("valid hex");
577        let salt = HEXLOWER
578            .decode(b"c813e48eb6e88c7557c9a70fcbda0fbc")
579            .expect("valid hex");
580
581        let hash = calculate_client_hash(
582            &session_key,
583            "[email protected]",
584            "P9JQCW",
585            &salt,
586            &big(SHARED_A),
587            &big(SHARED_B),
588        );
589
590        assert_eq!(
591            HEXLOWER.encode(&hash),
592            "e74d30467ccdfdf7d61973b9a94f88bd2b7155ba304138f5d02e2078c3a124fa"
593        );
594    }
595
596    #[test]
597    fn compute_x_returns_x() {
598        let salt =
599            super::super::opdata::decode64_loose("-JLqTVQLjQg08LWZ0gyuUA").expect("valid salt");
600        let account_key =
601            AccountKey::parse("A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9").expect("valid account key");
602
603        let srp_info = SrpInfo::new("SRPg-4096".into(), "PBES2g-HS256".into(), 100000, salt)
604            .expect("supported parameters");
605
606        let credentials = Credentials {
607            username: "username".into(),
608            password: "password".into(),
609            account_key: "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9".into(),
610            sign_in_address: SignInAddress {
611                subdomain: "my".into(),
612                domain: SignInDomain::Global,
613            },
614        };
615
616        let x = compute_x(&credentials, &account_key, &srp_info).expect("derivation succeeds");
617
618        assert_eq!(
619            HEXLOWER.encode(&x),
620            "e7e14f282b01332cc193dc42f8501e3ffe8afdbf4b431ed4bfd885ff0bdfecf3"
621        );
622    }
623
624    #[tokio::test]
625    async fn rejects_a_precomputed_x_longer_than_a_scalar() {
626        let server = MockServer::start().await;
627        let rest = RestClient::new(
628            new_http_client(),
629            format!("http://{}/api", server.address()),
630            "client-id",
631            "user-agent",
632            "op-user-agent",
633        )
634        .expect("valid headers");
635
636        let error =
637            perform_and_verify_with_x(&[1; 33], "username", "key-uuid", b"salt", "SESSION", &rest)
638                .await
639                .map(|_| ())
640                .expect_err("33 bytes do not fit the scalar");
641
642        assert!(matches!(error, OnePasswordError::Internal(_)));
643        assert!(
644            server
645                .received_requests()
646                .await
647                .unwrap_or_default()
648                .is_empty()
649        );
650    }
651
652    #[test]
653    fn leading_zeros_do_not_count_towards_the_width_of_a_precomputed_x() {
654        let x = [0x5a; 32];
655        let mut padded = vec![0];
656        padded.extend_from_slice(&x);
657
658        assert_eq!(scalar_bytes(&x).expect("fits"), x);
659        assert_eq!(scalar_bytes(&padded).expect("fits"), x);
660    }
661
662    #[test]
663    fn a_precomputed_x_of_only_zeros_is_a_zero_scalar() {
664        let significant = scalar_bytes(&[0; 33]).expect("fits");
665
666        assert!(bool::from(scalar(significant).is_zero()));
667    }
668
669    /// SSO feeds its precomputed x into the same exchange.
670    #[tokio::test]
671    async fn a_precomputed_x_reaches_the_key_the_password_path_derives() {
672        let salt =
673            super::super::opdata::decode64_loose("-JLqTVQLjQg08LWZ0gyuUA").expect("valid salt");
674        let account_key =
675            AccountKey::parse("A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9").expect("valid account key");
676        let credentials = Credentials {
677            username: "username".into(),
678            password: "password".into(),
679            account_key: "A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9".into(),
680            sign_in_address: SignInAddress {
681                subdomain: "my".into(),
682                domain: SignInDomain::Global,
683            },
684        };
685        let srp_info = SrpInfo::new("SRPg-4096".into(), "PBES2g-HS256".into(), 100000, salt)
686            .expect("supported parameters");
687
688        // The x the password path computes, fed straight back in the way the SSO flow does.
689        let srp_x = compute_x(&credentials, &account_key, &srp_info).expect("derivation succeeds");
690
691        let secret_a = BoxedUint::from_be_hex(
692            "37bbf7bf6a51f902673556ea6a2db91dd9987554ab74c3bc089b213693d9c06e",
693            SCALAR_BITS,
694        )
695        .expect("valid hex");
696        let shared_a = compute_shared_a(&secret_a);
697        let shared_b = big(SHARED_B);
698        let expected = compute_key(&secret_a, &shared_a, &shared_b, &srp_x);
699
700        // The server plays back the pinned B and a proof over these exact inputs.
701        let client_hash = calculate_client_hash(
702            &expected,
703            &credentials.username,
704            &account_key.uuid,
705            srp_info.salt(),
706            &shared_a,
707            &shared_b,
708        );
709        let server_hash =
710            BASE64URL_NOPAD.encode(&calculate_server_hash(&shared_a, &client_hash, &expected));
711
712        let server = MockServer::start().await;
713        server
714            .register(
715                Mock::given(matchers::path("/api/v2/auth"))
716                    .respond_with(
717                        ResponseTemplate::new(200).set_body_json(json!({ "userB": SHARED_B })),
718                    )
719                    .expect(1),
720            )
721            .await;
722        server
723            .register(
724                Mock::given(matchers::path("/api/v2/auth/confirm-key"))
725                    .respond_with(
726                        ResponseTemplate::new(200)
727                            .set_body_json(json!({ "serverVerifyHash": server_hash })),
728                    )
729                    .expect(1),
730            )
731            .await;
732        let rest = RestClient::new(
733            new_http_client(),
734            format!("http://{}/api", server.address()),
735            "client-id",
736            "user-agent",
737            "op-user-agent",
738        )
739        .expect("valid headers");
740
741        let key = perform(
742            &secret_a,
743            &srp_x,
744            &credentials.username,
745            &account_key.uuid,
746            srp_info.salt(),
747            &rest,
748        )
749        .await
750        .expect("the exchange verifies against the mock server");
751
752        assert_eq!(HEXLOWER.encode(&key), HEXLOWER.encode(&expected));
753        server.verify().await;
754    }
755
756    /// Pinned against the web client's `recieveServerHash`, which hashes
757    /// `z(bigA) || m || rawKey`.
758    #[test]
759    fn calculate_server_hash_returns_hash() {
760        let client_hash = HEXLOWER
761            .decode(b"e74d30467ccdfdf7d61973b9a94f88bd2b7155ba304138f5d02e2078c3a124fa")
762            .expect("valid hex");
763        let session_key = HEXLOWER
764            .decode(b"9d17458228928fc1107668113026390d502a40954e3e6a83513acbb2e1f8fedc")
765            .expect("valid hex");
766
767        let hash = calculate_server_hash(&big(SHARED_A), &client_hash, &session_key);
768
769        assert_eq!(
770            HEXLOWER.encode(&hash),
771            "e3ebd2e43efdc593960267f26b8a149e4368a5d5e963995ef9ceb6820307d6f9"
772        );
773    }
774
775    const VERIFY_SESSION_KEY: [u8; 32] = [0u8; 32];
776    const VERIFY_USERNAME: &str = "[email protected]";
777    const VERIFY_KEY_UUID: &str = "RTN9SA";
778    const VERIFY_SALT: &[u8] = b"salt";
779
780    fn verify_ephemerals() -> (BoxedUint, BoxedUint) {
781        (BoxedUint::from(2u32), BoxedUint::from(3u32))
782    }
783
784    /// Runs `verify_key` against a server that answers with `server_hash`.
785    async fn run_verify_key(server_hash: &str) -> Result<(), OnePasswordError> {
786        run_verify_key_against(
787            ResponseTemplate::new(200).set_body_json(json!({ "serverVerifyHash": server_hash })),
788        )
789        .await
790    }
791
792    /// Runs `verify_key` against a server that answers `confirm-key` with `response`.
793    async fn run_verify_key_against(response: ResponseTemplate) -> Result<(), OnePasswordError> {
794        let server = MockServer::start().await;
795        server
796            .register(
797                Mock::given(matchers::path("/api/v2/auth/confirm-key"))
798                    .respond_with(response)
799                    .expect(1),
800            )
801            .await;
802        let rest = RestClient::new(
803            new_http_client(),
804            format!("http://{}/api", server.address()),
805            "client-id",
806            "user-agent",
807            "op-user-agent",
808        )
809        .expect("valid headers");
810
811        let (shared_a, shared_b) = verify_ephemerals();
812        let result = verify_key(
813            &VERIFY_SESSION_KEY,
814            VERIFY_USERNAME,
815            VERIFY_KEY_UUID,
816            VERIFY_SALT,
817            &shared_a,
818            &shared_b,
819            &rest,
820        )
821        .await;
822
823        server.verify().await;
824        result
825    }
826
827    #[tokio::test]
828    async fn verify_key_reports_a_rejected_proof_as_bad_credentials() {
829        let error = run_verify_key_against(ResponseTemplate::new(401))
830            .await
831            .expect_err("the server rejects the proof");
832
833        assert!(matches!(error, OnePasswordError::BadCredentials));
834    }
835
836    #[tokio::test]
837    async fn verify_key_keeps_other_failures() {
838        let error = run_verify_key_against(ResponseTemplate::new(503))
839            .await
840            .expect_err("the server is unavailable");
841
842        assert!(matches!(
843            error,
844            OnePasswordError::UnexpectedStatus { status: 503, .. }
845        ));
846    }
847
848    #[tokio::test]
849    async fn verify_key_accepts_a_matching_server_hash() {
850        let (shared_a, shared_b) = verify_ephemerals();
851        let client_hash = calculate_client_hash(
852            &VERIFY_SESSION_KEY,
853            VERIFY_USERNAME,
854            VERIFY_KEY_UUID,
855            VERIFY_SALT,
856            &shared_a,
857            &shared_b,
858        );
859        let server_hash = BASE64URL_NOPAD.encode(&calculate_server_hash(
860            &shared_a,
861            &client_hash,
862            &VERIFY_SESSION_KEY,
863        ));
864
865        run_verify_key(&server_hash)
866            .await
867            .expect("the server proved it holds the session key");
868    }
869
870    #[tokio::test]
871    async fn verify_key_rejects_a_server_hash_it_did_not_expect() {
872        for server_hash in ["", "not-the-hash"] {
873            let error = run_verify_key(server_hash)
874                .await
875                .expect_err("the server did not prove it holds the session key");
876
877            assert!(
878                error.to_string().contains("does not match"),
879                "unexpected error: {error}"
880            );
881        }
882    }
883
884    fn test_server() -> TestServer {
885        TestServer::new(
886            &[0x5a; 32],
887            &[0x33; 32],
888            VERIFY_USERNAME,
889            VERIFY_KEY_UUID,
890            VERIFY_SALT,
891        )
892    }
893
894    /// The client's proof for `srp_x` against the ephemeral of `server`.
895    fn client_proof(server: &TestServer, srp_x: &[u8]) -> (String, [u8; 32], [u8; 32]) {
896        let secret_a = scalar(&[0x77; 32]);
897        let shared_a = compute_shared_a(&secret_a);
898        let shared_b = from_server_hex(&server.shared_b()).expect("valid B");
899
900        let key = compute_key(&secret_a, &shared_a, &shared_b, srp_x);
901        let proof = calculate_client_hash(
902            &key,
903            VERIFY_USERNAME,
904            VERIFY_KEY_UUID,
905            VERIFY_SALT,
906            &shared_a,
907            &shared_b,
908        );
909        (to_server_hex(&shared_a), key, proof)
910    }
911
912    /// The test server and the client reach the same key, and each accepts the other's proof.
913    #[test]
914    fn the_test_server_agrees_with_the_client() {
915        let server = test_server();
916        let (shared_a, key, proof) = client_proof(&server, &[0x5a; 32]);
917
918        assert_eq!(server.session_key(&shared_a), Some(key));
919        assert_eq!(
920            server.confirm_key(&shared_a, &BASE64URL_NOPAD.encode(&proof)),
921            Some(BASE64URL_NOPAD.encode(&calculate_server_hash(&big(&shared_a), &proof, &key)))
922        );
923    }
924
925    #[test]
926    fn the_test_server_rejects_a_client_that_does_not_know_the_account() {
927        let server = test_server();
928        let (shared_a, _, proof) = client_proof(&server, &[0x5b; 32]);
929
930        assert!(
931            server
932                .confirm_key(&shared_a, &BASE64URL_NOPAD.encode(&proof))
933                .is_none()
934        );
935    }
936
937    #[test]
938    fn srp_info_rejects_unsupported_parameters() {
939        let bad_method = SrpInfo::new("SRPg-2048".into(), "PBES2g-HS256".into(), 100000, vec![])
940            .expect_err("only SRPg-4096 is supported");
941        assert!(bad_method.to_string().contains("SRPg-2048"));
942
943        let too_few = SrpInfo::new("SRPg-4096".into(), "PBES2g-HS256".into(), 9999, vec![])
944            .expect_err("a count below the floor is rejected");
945        assert!(too_few.to_string().contains("9999 iterations"));
946
947        let bad_key_method =
948            SrpInfo::new("SRPg-4096".into(), "PBES2g-HS512".into(), 100000, vec![])
949                .expect_err("only the SHA-256 variants are supported");
950        assert!(bad_key_method.to_string().contains("PBES2g-HS512"));
951    }
952}