Skip to main content

bitwarden_importers/importers/onepassword/access/
kdf.rs

1//! HKDF-SHA256, PBES2 (PBKDF2-HS256), and master-key derivation.
2
3use std::borrow::Cow;
4
5use data_encoding::BASE64URL_NOPAD;
6use hkdf::Hkdf;
7use icu_normalizer::DecomposingNormalizer;
8use sha2::{Digest, Sha256};
9
10use super::{account_key::AccountKey, error::OnePasswordError};
11
12/// The floor the web client enforces inside its PBKDF2 primitive, so a server cannot talk us into
13/// deriving a weaker key than the client would.
14pub(super) const MIN_PBKDF2_ITERATIONS: u32 = 10_000;
15
16/// HKDF-SHA256 producing 32 bytes, with `method` as the `info` parameter.
17pub(super) fn hkdf_sha256(method: &str, ikm: &[u8], salt: &[u8]) -> [u8; 32] {
18    let hk = Hkdf::<Sha256>::new(Some(salt), ikm);
19    let mut okm = [0u8; 32];
20    hk.expand(method.as_bytes(), &mut okm)
21        .expect("okm is a fixed 32 bytes, under HKDF's 255-block limit");
22    okm
23}
24
25/// Rejects a PBES2 method this module cannot honour.
26///
27/// The web client only implements the SHA-256 variants and throws `Invalid PBKDF2 alg` on anything
28/// else, so an `*-HS512` method is rejected rather than guessed at. Both the master keyset and the
29/// SRP parameters carry one of these, from different responses, so both have to check.
30pub(super) fn validate_pbes2(method: &str) -> Result<(), OnePasswordError> {
31    match matches!(method, "PBES2-HS256" | "PBES2g-HS256") {
32        true => Ok(()),
33        false => Err(OnePasswordError::Unsupported(format!(
34            "Method '{method}' is not supported"
35        ))),
36    }
37}
38
39/// PBKDF2-HMAC-SHA256 producing 32 bytes. Callers check the method first.
40pub(super) fn pbes2(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] {
41    bitwarden_crypto::pbkdf2(password.as_bytes(), salt, iterations)
42}
43
44pub(super) fn normalize_password(password: &str) -> Cow<'_, str> {
45    DecomposingNormalizer::new_nfkd().normalize(password.trim())
46}
47
48pub(super) fn normalize_username(username: &str) -> String {
49    username.trim().to_lowercase()
50}
51
52/// The only place the email is decomposed, not just trimmed and lowercased.
53pub(super) fn normalize_identity_username(username: &str) -> String {
54    DecomposingNormalizer::new_nfkd()
55        .normalize(username.trim())
56        .to_lowercase()
57}
58
59/// The legacy `PBES2-` stand-in for the password, hashed raw and never normalized.
60fn legacy_password(username: &str, password: &str) -> String {
61    let digest = match password.is_empty() {
62        true => String::new(),
63        false => BASE64URL_NOPAD.encode(&Sha256::digest(password.as_bytes())),
64    };
65    format!("{username}:{digest}")
66}
67
68/// Derives the 32-byte master unlock key (kid `"mp"`), dispatching on the algorithm prefix the way
69/// the web client's `Auk.deriveKdfBytes` does.
70pub(super) fn derive_master_key(
71    algorithm: &str,
72    iterations: u32,
73    salt: &[u8],
74    username: &str,
75    password: &str,
76    account_key: &AccountKey,
77) -> Result<[u8; 32], OnePasswordError> {
78    validate_pbes2(algorithm)?;
79
80    let username = normalize_username(username);
81
82    // The legacy algorithm has never been observed in the wild, but the web client still implements
83    // it.
84    let is_legacy = algorithm.starts_with("PBES2-");
85
86    let k1;
87    let salt = if is_legacy {
88        salt
89    } else {
90        k1 = hkdf_sha256(algorithm, salt, username.as_bytes());
91        &k1
92    };
93
94    let password = if is_legacy {
95        legacy_password(&username, password)
96    } else {
97        normalize_password(password).into_owned()
98    };
99
100    let k2 = pbes2(&password, salt, iterations);
101
102    account_key.combine_with(&k2)
103}
104
105#[cfg(test)]
106mod tests {
107    use std::collections::BTreeMap;
108
109    use data_encoding::{BASE64URL_NOPAD, HEXLOWER};
110    use serde::Deserialize;
111
112    use super::*;
113
114    /// Vectors generated from the 1P web client itself, see the fixture's `note`.
115    #[derive(Deserialize)]
116    struct Vectors {
117        params: VectorParams,
118        cases: Vec<Vector>,
119    }
120
121    #[derive(Deserialize)]
122    struct VectorParams {
123        username: String,
124        account_key: String,
125        salt_b64url: String,
126        iterations: u32,
127    }
128
129    #[derive(Deserialize)]
130    struct Vector {
131        name: String,
132        password: String,
133        password_hex: String,
134        normalized_hex: String,
135        keys: BTreeMap<String, String>,
136    }
137
138    fn vectors() -> Vectors {
139        serde_json::from_str(include_str!("fixtures/master-key-vectors.json"))
140            .expect("the vectors parse")
141    }
142
143    #[test]
144    fn vector_passwords_survived_the_trip_through_the_json_file() {
145        for case in vectors().cases {
146            assert_eq!(
147                HEXLOWER.encode(case.password.as_bytes()),
148                case.password_hex,
149                "{} was rewritten in the fixture",
150                case.name
151            );
152        }
153    }
154
155    #[test]
156    fn normalize_password_matches_the_web_client() {
157        for case in vectors().cases {
158            assert_eq!(
159                HEXLOWER.encode(normalize_password(&case.password).as_bytes()),
160                case.normalized_hex,
161                "{}",
162                case.name
163            );
164        }
165    }
166
167    fn derive_vector(params: &VectorParams, case: &Vector, algorithm: &str) -> [u8; 32] {
168        let salt = BASE64URL_NOPAD
169            .decode(params.salt_b64url.as_bytes())
170            .expect("valid salt");
171        let account_key = AccountKey::parse(&params.account_key).expect("valid account key");
172
173        derive_master_key(
174            algorithm,
175            params.iterations,
176            &salt,
177            &params.username,
178            &case.password,
179            &account_key,
180        )
181        .expect("derivation succeeds")
182    }
183
184    #[test]
185    fn derive_master_key_matches_the_web_client() {
186        let Vectors { params, cases } = vectors();
187        for case in &cases {
188            assert!(!case.keys.is_empty(), "{} has no keys", case.name);
189            for (algorithm, expected) in &case.keys {
190                assert_eq!(
191                    &HEXLOWER.encode(&derive_vector(&params, case, algorithm)),
192                    expected,
193                    "{} with {algorithm}",
194                    case.name
195                );
196            }
197        }
198    }
199
200    /// `PBES2g-` normalizes, so the same password typed two ways unlocks the same account. The
201    /// legacy prefix hashes the raw bytes instead, and stays sensitive to the spelling. Catches
202    /// skipping normalization entirely, which is what `compute_x` used to do.
203    #[test]
204    fn only_the_modern_prefix_is_blind_to_the_unicode_spelling() {
205        let Vectors { params, cases } = vectors();
206        let find = |name: &str| {
207            cases
208                .iter()
209                .find(|case| case.name == name)
210                .unwrap_or_else(|| panic!("{name} is in the fixture"))
211        };
212        let precomposed = find("precomposed_e_acute");
213        let decomposed = find("decomposed_e_acute");
214
215        assert_ne!(precomposed.password_hex, decomposed.password_hex);
216        for algorithm in precomposed.keys.keys() {
217            let one = derive_vector(&params, precomposed, algorithm);
218            let other = derive_vector(&params, decomposed, algorithm);
219            match algorithm.starts_with("PBES2g-") {
220                true => assert_eq!(one, other, "{algorithm}"),
221                false => assert_ne!(one, other, "{algorithm}"),
222            }
223        }
224    }
225
226    #[test]
227    fn normalize_username_trims_and_lowercases() {
228        assert_eq!(
229            normalize_username("  [email protected] \n"),
230            "[email protected]"
231        );
232    }
233
234    /// Unlike the HKDF salt, the identity also decomposes, and only then lowercases.
235    #[test]
236    fn normalize_identity_username_also_decomposes() {
237        assert_eq!(
238            HEXLOWER.encode(normalize_identity_username(" CAF\u{e9}@x.com ").as_bytes()),
239            HEXLOWER.encode("cafe\u{301}@x.com".as_bytes())
240        );
241    }
242
243    #[test]
244    fn hkdf_returns_derived_key() {
245        let derived = hkdf_sha256("PBES2g-HS256", b"ikm", b"salt");
246        assert_eq!(
247            BASE64URL_NOPAD.encode(&derived),
248            "UybCHXHHQRaFxUUR3G2ZO9CJ0H2eWJ1Ik_MpNQHrHdE"
249        );
250    }
251
252    #[test]
253    fn pbes2_returns_derived_key() {
254        assert_eq!(
255            BASE64URL_NOPAD.encode(&pbes2("password", b"salt", 100)),
256            "B-aZcYDPfxKQTwQQDUBdNIiP32KvbVBqDswjsZb-mdg"
257        );
258    }
259
260    /// The web client throws `Invalid PBKDF2 alg` on these, so we do not guess at them either.
261    #[test]
262    fn derive_master_key_throws_on_unsupported_method() {
263        let account_key =
264            AccountKey::parse("A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9").expect("valid account key");
265        for algorithm in ["PBES2-HS512", "PBES2g-HS512", "SRPg-4096", "Unknown", ""] {
266            let err = derive_master_key(algorithm, 100, b"salt", "user", "pw", &account_key)
267                .expect_err("unsupported");
268            assert!(matches!(err, OnePasswordError::Unsupported(_)));
269            assert!(err.to_string().contains("is not supported"));
270        }
271    }
272
273    #[test]
274    fn derive_master_key_returns_master_key() {
275        let salt = BASE64URL_NOPAD
276            .decode(b"i2enf0xq-XPKCFFf5UZqNQ")
277            .expect("valid salt");
278        let account_key =
279            AccountKey::parse("A3-RTN9SA-DY9445Y5FF96X6E7B5GPFA95R9").expect("valid account key");
280
281        let key = derive_master_key(
282            "PBES2g-HS256",
283            100000,
284            &salt,
285            "username",
286            "password",
287            &account_key,
288        )
289        .expect("derivation succeeds");
290
291        assert_eq!(
292            HEXLOWER.encode(&key),
293            "09f6cf6acc4f64f2ac6af5d912427253c4dd5e1a48dfc6bfea21df8f6d3a701e"
294        );
295    }
296}