Skip to main content

sioul_sync/
bitwarden.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Bitwarden, read here with no other program: your vault's logins and
5//! their one-time codes, to fill a site's form when you ask. Read only:
6//! nothing is ever written to the vault.
7//!
8//! As Bitwarden's own clients do (its security white paper; its SDK under
9//! GPL-3.0, `bitwarden-crypto`): the master key is derived from your master
10//! password and e-mail (PBKDF2-SHA256 or Argon2id, as your account says),
11//! hashed once more to log in, stretched with HKDF to open your user key,
12//! which opens each item (AES-256-CBC with HMAC-SHA256, or COSE for newer
13//! accounts); an organisation's key comes through your RSA key. Or your
14//! security key alone, as Bitwarden's "log in with passkey": the key's secret
15//! (WebAuthn PRF) opens an RSA key Bitwarden keeps for that passkey, which
16//! opens your user key. The master password and the keys live in memory
17//! only, wiped when dropped; the keyring keeps the "remember this device"
18//! token, nothing else.
19
20use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7};
21use base64::Engine;
22use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
23use hmac::{Hmac, Mac};
24use rsa::pkcs8::DecodePrivateKey;
25use rsa::{Oaep, RsaPrivateKey};
26use serde_json::Value;
27use sha2::{Digest, Sha256};
28use std::collections::HashMap;
29use std::time::Duration;
30use zeroize::Zeroizing;
31
32type HmacSha256 = Hmac<Sha256>;
33
34/// What went wrong, for the window to say.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Problem {
37    /// The master password does not open the vault.
38    WrongPassword,
39    /// A second step is asked: the providers offered (0 authenticator app,
40    /// 1 e-mail, 3 YubiKey OTP, 7 security key (FIDO2), 8 recovery code; 2, 6
41    /// Duo cannot be done here), and for the security key, its options, as
42    /// the key is asked with them (`key_script`).
43    SecondFactor(Vec<u8>, Option<String>),
44    /// A new device: Bitwarden sent a code by e-mail.
45    NewDevice,
46    /// A passkey login whose key gave no secret to open the vault (no WebAuthn PRF).
47    NoKeySecret,
48    /// Bitwarden logs in with this passkey but keeps no key of the vault for it (its encryption is off).
49    PasskeyNotForVault,
50    /// The server could not be reached.
51    Network(String),
52    /// Anything else, in Bitwarden's words.
53    Refused(String),
54}
55
56/// Bitwarden's addresses: its US or EU cloud, or your own server (Vaultwarden too).
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Server {
59    pub identity: String,
60    pub api: String,
61    /// The web vault: its security-key page asks a FIDO2 key for the second step.
62    pub vault: String,
63}
64
65impl Server {
66    /// "" or "bitwarden.com": the US cloud; "bitwarden.eu": the EU one (their
67    /// vaults' addresses too, as a browser shows them); else a self-hosted base address.
68    pub fn of(base: &str) -> Server {
69        let base = base.trim().trim_end_matches('/');
70        match base.to_ascii_lowercase().as_str() {
71            "" | "bitwarden.com" | "vault.bitwarden.com" | "https://bitwarden.com" | "https://vault.bitwarden.com" => Server { identity: "https://identity.bitwarden.com".into(), api: "https://api.bitwarden.com".into(), vault: "https://vault.bitwarden.com".into() },
72            "bitwarden.eu" | "vault.bitwarden.eu" | "https://bitwarden.eu" | "https://vault.bitwarden.eu" => Server { identity: "https://identity.bitwarden.eu".into(), api: "https://api.bitwarden.eu".into(), vault: "https://vault.bitwarden.eu".into() },
73            _ => {
74                let own = if base.contains("://") { base.to_string() } else { format!("https://{base}") };
75                Server { identity: format!("{own}/identity"), api: format!("{own}/api"), vault: own }
76            }
77        }
78    }
79}
80
81/// How the master key is derived, as the account says.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Kdf {
84    Pbkdf2 { iterations: u32 },
85    Argon2id { iterations: u32, memory_mib: u32, parallelism: u32 },
86}
87
88/// PBKDF2-HMAC-SHA256, 32 bytes (RFC 8018).
89fn pbkdf2(secret: &[u8], salt: &[u8], iterations: u32) -> Zeroizing<[u8; 32]> {
90    let prf = HmacSha256::new_from_slice(secret).expect("HMAC takes any key");
91    let mut block = prf.clone();
92    block.update(salt);
93    block.update(&1u32.to_be_bytes());
94    let mut u: [u8; 32] = block.finalize().into_bytes().into();
95    let mut out = Zeroizing::new(u);
96    for _ in 1..iterations {
97        let mut next = prf.clone();
98        next.update(&u);
99        u = next.finalize().into_bytes().into();
100        for (o, x) in out.iter_mut().zip(u) {
101            *o ^= x;
102        }
103    }
104    out
105}
106
107/// The master key, from the master password and the e-mail (trimmed, in lower case).
108/// The settings come from the server, and are held to Bitwarden's own bounds:
109/// below its clients' minimums, a server would make your password cheaper to
110/// guess from what it is sent (its clients refuse them too); above its own
111/// maximums, it would only exhaust this computer.
112pub fn master_key(password: &str, email: &str, kdf: Kdf) -> Result<Zeroizing<[u8; 32]>, Problem> {
113    let salt = email.trim().to_lowercase();
114    match kdf {
115        Kdf::Pbkdf2 { iterations } => {
116            if !(5_000..=2_000_000).contains(&iterations) {
117                return Err(Problem::Refused(format!("PBKDF2: {iterations} iterations")));
118            }
119            Ok(pbkdf2(password.as_bytes(), salt.as_bytes(), iterations))
120        }
121        Kdf::Argon2id { iterations, memory_mib, parallelism } => {
122            if iterations < 2 || !(16..=1024).contains(&memory_mib) || !(1..=16).contains(&parallelism) {
123                return Err(Problem::Refused(format!("Argon2id: {iterations} iterations, {memory_mib} MiB, {parallelism} lanes")));
124            }
125            let params = argon2::Params::new(memory_mib.saturating_mul(1024), iterations, parallelism, Some(32)).map_err(|e| Problem::Refused(e.to_string()))?;
126            let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
127            let mut key = Zeroizing::new([0u8; 32]);
128            argon.hash_password_into(password.as_bytes(), &Sha256::digest(salt.as_bytes()), key.as_mut()).map_err(|e| Problem::Refused(e.to_string()))?;
129            Ok(key)
130        }
131    }
132}
133
134/// The hash that logs in: the master key hashed once with the password.
135pub fn password_hash(master: &[u8; 32], password: &str) -> String {
136    let hash = pbkdf2(master, password.as_bytes(), 1);
137    STANDARD.encode(&hash[..])
138}
139
140/// A symmetric key: AES-256 with its HMAC key, or a COSE key (newer accounts).
141pub struct Key {
142    enc: Zeroizing<Vec<u8>>,
143    mac: Zeroizing<Vec<u8>>,
144    /// The COSE algorithm, for a COSE key.
145    cose: Option<i64>,
146}
147
148/// COSE algorithms Bitwarden uses (private use, `bitwarden-crypto/src/cose`).
149const XCHACHA20_POLY1305: i64 = -70000;
150const XAES_256_GCM: i64 = -70010;
151const A256GCM: i64 = 3;
152
153/// Bitwarden's padding: the last byte says how many to drop.
154fn unpad(bytes: &[u8]) -> Option<&[u8]> {
155    let n = usize::from(*bytes.last()?);
156    (n >= 1 && n <= bytes.len()).then(|| &bytes[..bytes.len() - n])
157}
158
159impl Key {
160    /// The master key stretched (HKDF-Expand-SHA256, "enc" and "mac").
161    pub fn stretched(master: &[u8; 32]) -> Key {
162        let hkdf = hkdf::Hkdf::<Sha256>::from_prk(master).expect("32 bytes is a valid PRK");
163        let (mut enc, mut mac) = (Zeroizing::new(vec![0u8; 32]), Zeroizing::new(vec![0u8; 32]));
164        hkdf.expand(b"enc", &mut enc).expect("32 bytes");
165        hkdf.expand(b"mac", &mut mac).expect("32 bytes");
166        Key { enc, mac, cose: None }
167    }
168
169    /// A key from its decrypted bytes: 64 (encryption and MAC), 32 (an old
170    /// key without MAC), or a padded COSE key.
171    pub fn from_bytes(bytes: &[u8]) -> Result<Key, Problem> {
172        match bytes.len() {
173            64 => Ok(Key { enc: Zeroizing::new(bytes[..32].to_vec()), mac: Zeroizing::new(bytes[32..].to_vec()), cose: None }),
174            32 => Ok(Key { enc: Zeroizing::new(bytes.to_vec()), mac: Zeroizing::new(Vec::new()), cose: None }),
175            _ => {
176                let raw = unpad(bytes).unwrap_or(bytes);
177                let value: ciborium::Value = ciborium::from_reader(raw).map_err(|e| Problem::Refused(format!("key: {e}")))?;
178                let map = value.as_map().ok_or_else(|| Problem::Refused("key: not a COSE key".into()))?;
179                let label = |n: i64| map.iter().find(|(k, _)| k.as_integer().is_some_and(|i| i128::from(i) == i128::from(n))).map(|(_, v)| v);
180                let alg = label(3).and_then(|v| v.as_integer()).and_then(|i| i64::try_from(i).ok()).ok_or_else(|| Problem::Refused("key: no algorithm".into()))?;
181                let k = label(-1).and_then(|v| v.as_bytes()).ok_or_else(|| Problem::Refused("key: no key".into()))?;
182                Ok(Key { enc: Zeroizing::new(k.clone()), mac: Zeroizing::new(Vec::new()), cose: Some(alg) })
183            }
184        }
185    }
186}
187
188fn b64(text: &str) -> Result<Vec<u8>, Problem> {
189    STANDARD.decode(text.trim()).map_err(|e| Problem::Refused(format!("base64: {e}")))
190}
191
192/// AES-256-CBC with PKCS#7 padding.
193fn cbc(key: &[u8], iv: &[u8], data: &[u8]) -> Result<Zeroizing<Vec<u8>>, Problem> {
194    let decryptor = cbc::Decryptor::<aes::Aes256>::new_from_slices(key, iv).map_err(|_| Problem::Refused("AES key or IV".into()))?;
195    decryptor.decrypt_padded_vec_mut::<Pkcs7>(data).map(Zeroizing::new).map_err(|_| Problem::WrongPassword)
196}
197
198/// An EncString opened: type 0 (AES-CBC), 2 (AES-CBC with HMAC, checked first), 7 (COSE).
199pub fn decrypt(text: &str, key: &Key) -> Result<Zeroizing<Vec<u8>>, Problem> {
200    let (kind, rest) = match text.split_once('.') {
201        Some((kind, rest)) => (kind.parse::<u8>().map_err(|_| Problem::Refused("EncString type".into()))?, rest),
202        None => (0, text),
203    };
204    let parts: Vec<&str> = rest.split('|').collect();
205    match (kind, parts.as_slice()) {
206        // Unchecked text only for the oldest keys, which have no MAC key: a key
207        // with one opens checked text only, as in Bitwarden's clients, so a
208        // server cannot strip the check off.
209        (0, _) if !key.mac.is_empty() => Err(Problem::Refused("EncString type 0 for a key with a MAC key".into())),
210        (0, [iv, data]) => cbc(&key.enc, &b64(iv)?, &b64(data)?),
211        (2, [iv, data, mac]) => {
212            let (iv, data, mac) = (b64(iv)?, b64(data)?, b64(mac)?);
213            let mut check = HmacSha256::new_from_slice(&key.mac).map_err(|_| Problem::Refused("MAC key".into()))?;
214            check.update(&iv);
215            check.update(&data);
216            // Checked in constant time, before anything is decrypted.
217            check.verify_slice(&mac).map_err(|_| Problem::WrongPassword)?;
218            cbc(&key.enc, &iv, &data)
219        }
220        (7, [message]) => cose(&b64(message)?, key),
221        (kind, _) => Err(Problem::Refused(format!("EncString type {kind} is not read here"))),
222    }
223}
224
225/// A COSE_Encrypt0 message (RFC 9052 §5.2): its algorithm, its nonce, its
226/// text, opened with the protected header as additional data; padded UTF-8
227/// unpadded.
228fn cose(message: &[u8], key: &Key) -> Result<Zeroizing<Vec<u8>>, Problem> {
229    use ciborium::Value;
230    let bad = |what: &str| Problem::Refused(format!("COSE: {what}"));
231    let mut value: Value = ciborium::from_reader(message).map_err(|_| bad("not CBOR"))?;
232    if let Value::Tag(16, inner) = value {
233        value = *inner;
234    }
235    let parts = value.as_array().ok_or_else(|| bad("not an array"))?;
236    let [protected, unprotected, ciphertext] = parts.as_slice() else { return Err(bad("not Encrypt0")) };
237    let protected = protected.as_bytes().ok_or_else(|| bad("protected header"))?;
238    let header: Value = if protected.is_empty() { Value::Map(Vec::new()) } else { ciborium::from_reader(protected.as_slice()).map_err(|_| bad("protected header"))? };
239    let find = |map: &Value, label: i64| map.as_map().and_then(|m| m.iter().find(|(k, _)| k.as_integer().is_some_and(|i| i128::from(i) == i128::from(label))).map(|(_, v)| v.clone()));
240    let alg = find(&header, 1).and_then(|v| v.as_integer()).and_then(|i| i64::try_from(i).ok()).or(key.cose).ok_or_else(|| bad("no algorithm"))?;
241    let padded = find(&header, 3).and_then(|v| v.as_text().map(str::to_string)).is_some_and(|t| t == "application/x.bitwarden.utf8-padded");
242    let nonce = find(unprotected, 5).and_then(|v| v.as_bytes().cloned()).ok_or_else(|| bad("no nonce"))?;
243    let ciphertext = ciphertext.as_bytes().ok_or_else(|| bad("no text"))?;
244    // Enc_structure: ["Encrypt0", protected, external_aad].
245    let mut aad = Vec::new();
246    ciborium::into_writer(&Value::Array(vec![Value::Text("Encrypt0".into()), Value::Bytes(protected.clone()), Value::Bytes(Vec::new())]), &mut aad).map_err(|_| bad("AAD"))?;
247    use aes_gcm::aead::{Aead, KeyInit, Payload};
248    let payload = Payload { msg: ciphertext, aad: &aad };
249    let plain = match alg {
250        XCHACHA20_POLY1305 => {
251            let cipher = chacha20poly1305::XChaCha20Poly1305::new_from_slice(&key.enc).map_err(|_| bad("key length"))?;
252            if nonce.len() != 24 {
253                return Err(bad("nonce length"));
254            }
255            cipher.decrypt(chacha20poly1305::XNonce::from_slice(&nonce), payload).map_err(|_| Problem::WrongPassword)?
256        }
257        A256GCM => {
258            let cipher = aes_gcm::Aes256Gcm::new_from_slice(&key.enc).map_err(|_| bad("key length"))?;
259            if nonce.len() != 12 {
260                return Err(bad("nonce length"));
261            }
262            cipher.decrypt(aes_gcm::Nonce::from_slice(&nonce), payload).map_err(|_| Problem::WrongPassword)?
263        }
264        XAES_256_GCM => return Err(bad("XAES-256-GCM is not read here yet")),
265        other => return Err(bad(&format!("algorithm {other}"))),
266    };
267    let plain = Zeroizing::new(plain);
268    Ok(if padded { Zeroizing::new(unpad(&plain).ok_or_else(|| bad("padding"))?.to_vec()) } else { plain })
269}
270
271/// An EncString opened with an RSA key: type 3 (OAEP-SHA256), 4 (OAEP-SHA1), 5 and 6 (the same, their MAC unused).
272pub fn decrypt_rsa(text: &str, private: &RsaPrivateKey) -> Result<Zeroizing<Vec<u8>>, Problem> {
273    let (kind, rest) = text.split_once('.').ok_or_else(|| Problem::Refused("EncString type".into()))?;
274    let data = b64(rest.split('|').next().unwrap_or(rest))?;
275    let plain = match kind {
276        "3" | "5" => private.decrypt(Oaep::new::<Sha256>(), &data),
277        "4" | "6" => private.decrypt(Oaep::new::<sha1::Sha1>(), &data),
278        other => return Err(Problem::Refused(format!("EncString type {other}"))),
279    };
280    plain.map(Zeroizing::new).map_err(|_| Problem::Refused("RSA".into()))
281}
282
283/// A string field: an EncString opened as UTF-8.
284fn text(value: Option<&Value>, key: &Key) -> String {
285    value.and_then(Value::as_str).and_then(|t| decrypt(t, key).ok()).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default()
286}
287
288/// A field, in the camel case of current servers or the Pascal case of older ones.
289fn field<'a>(value: &'a Value, name: &str) -> Option<&'a Value> {
290    let mut pascal = name.to_string();
291    if let Some(first) = pascal.get_mut(..1) {
292        first.make_ascii_uppercase();
293    }
294    value.get(name).or_else(|| value.get(&pascal)).filter(|v| !v.is_null())
295}
296
297/// HTTPS only, redirects included: what logs in (your password's hash) and
298/// your vault never travel in clear, as mail never does.
299fn agent() -> ureq::Agent {
300    ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).https_only(true).build().into()
301}
302
303/// The version declared when the server does not say its own. Bitwarden
304/// refuses clients too far behind it ("Please update your app to continue
305/// using Bitwarden": 2024.12.0 was refused in October 2026), so the server's
306/// own version is declared when it gives one (`client_version`).
307const CLIENT_VERSION: &str = "2026.9.0";
308
309/// "2026.9.2": a version as Bitwarden writes them.
310fn is_version(text: &str) -> bool {
311    let parts: Vec<&str> = text.split('.').collect();
312    parts.len() >= 2 && parts.len() <= 4 && parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
313}
314
315/// The version to declare to this server: its own (`GET {api}/config`, kept
316/// for the session), so Sioul keeps within the versions it accepts; else
317/// `CLIENT_VERSION`.
318fn client_version(server: &Server) -> String {
319    static KNOWN: std::sync::Mutex<Option<(String, String)>> = std::sync::Mutex::new(None);
320    if let Ok(known) = KNOWN.lock()
321        && let Some((api, version)) = known.as_ref()
322        && *api == server.api
323    {
324        return version.clone();
325    }
326    let asked = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(10))).http_status_as_error(false).https_only(true).build();
327    let asked: ureq::Agent = asked.into();
328    let version = answer(asked.get(&format!("{}/config", server.api)).header("Accept", "application/json").call())
329        .ok()
330        .filter(|(status, _)| *status == 200)
331        .and_then(|(_, json)| field(&json, "version").and_then(Value::as_str).map(str::to_string))
332        .filter(|v| is_version(v));
333    let version = version.unwrap_or_else(|| CLIENT_VERSION.to_string());
334    if let Ok(mut known) = KNOWN.lock() {
335        *known = Some((server.api.clone(), version.clone()));
336    }
337    version
338}
339
340fn device_type() -> &'static str {
341    if cfg!(windows) {
342        "6"
343    } else if cfg!(target_os = "macos") {
344        "7"
345    } else {
346        "8"
347    }
348}
349
350/// Reads an answer: its status and its JSON. A large vault is more than
351/// ureq's 10 MB; one that could not be read whole is not an empty vault.
352fn answer(response: Result<ureq::http::Response<ureq::Body>, ureq::Error>) -> Result<(u16, Value), Problem> {
353    let mut response = response.map_err(|e| Problem::Network(e.to_string()))?;
354    let status = response.status().as_u16();
355    let body = match response.body_mut().with_config().limit(64 * 1024 * 1024).read_to_string() {
356        Ok(body) => body,
357        Err(e) if status == 200 => return Err(Problem::Network(e.to_string())),
358        Err(_) => String::new(),
359    };
360    Ok((status, serde_json::from_str(&body).unwrap_or(Value::Null)))
361}
362
363/// How the account derives its master key.
364fn prelogin(server: &Server, email: &str) -> Result<Kdf, Problem> {
365    let (status, json) = answer(agent().post(&format!("{}/accounts/prelogin", server.identity)).header("Content-Type", "application/json").send(serde_json::json!({ "email": email.trim() }).to_string()))?;
366    if status != 200 {
367        return Err(Problem::Refused(format!("prelogin: {status}")));
368    }
369    let settings = field(&json, "kdfSettings").unwrap_or(&json);
370    let number = |names: &[&str]| names.iter().find_map(|n| field(settings, n).or_else(|| field(&json, n))).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok());
371    Ok(match number(&["kdfType", "kdf"]).unwrap_or(0) {
372        1 => Kdf::Argon2id { iterations: number(&["iterations", "kdfIterations"]).unwrap_or(3), memory_mib: number(&["memory", "kdfMemory"]).unwrap_or(64), parallelism: number(&["parallelism", "kdfParallelism"]).unwrap_or(4) },
373        _ => Kdf::Pbkdf2 { iterations: number(&["iterations", "kdfIterations"]).unwrap_or(600_000) },
374    })
375}
376
377/// The vault, open: the keys that read it, and how to ask for it.
378pub struct Unlocked {
379    access: String,
380    user: Key,
381    private: Option<RsaPrivateKey>,
382    /// "Remember this device": the token that skips the second step next time.
383    pub remember: Option<String>,
384}
385
386/// Logs in and opens the vault. `factor` is a second step asked before
387/// (provider, code); `remembered` a token kept from a former login; `new_device`
388/// the code Bitwarden mailed for a new device.
389pub fn unlock(server: &Server, email: &str, password: &str, device: &str, factor: Option<(u8, &str)>, remembered: Option<&str>, new_device: Option<&str>) -> Result<Unlocked, Problem> {
390    let kdf = prelogin(server, email)?;
391    let master = master_key(password, email, kdf)?;
392    let hash = Zeroizing::new(password_hash(&master, password));
393    let mut form: Vec<(&str, String)> = vec![
394        ("grant_type", "password".into()),
395        ("username", email.trim().to_string()),
396        ("password", hash.to_string()),
397        ("scope", "api offline_access".into()),
398        ("client_id", "desktop".into()),
399        ("deviceType", device_type().into()),
400        ("deviceIdentifier", device.to_string()),
401        ("deviceName", "Sioul".into()),
402    ];
403    match (factor, remembered) {
404        (Some((provider, code)), _) => {
405            form.push(("twoFactorProvider", provider.to_string()));
406            form.push(("twoFactorToken", code.trim().replace(' ', "")));
407            form.push(("twoFactorRemember", "1".into()));
408        }
409        (None, Some(token)) => {
410            form.push(("twoFactorProvider", "5".into()));
411            form.push(("twoFactorToken", token.to_string()));
412        }
413        (None, None) => {}
414    }
415    if let Some(code) = new_device {
416        form.push(("newDeviceOtp", code.trim().to_string()));
417    }
418    let (status, json) = ask_token(server, Some(email), &form)?;
419    if status != 200 {
420        // Which second steps are offered, or why it was refused.
421        if let Some(providers) = field(&json, "twoFactorProviders").and_then(Value::as_array) {
422            let ids: Vec<u8> = providers.iter().filter_map(|p| p.as_u64().or_else(|| p.as_str().and_then(|s| s.parse().ok()))).filter_map(|p| u8::try_from(p).ok()).collect();
423            // The security key's challenge: what Bitwarden's page passes to the browser.
424            let webauthn = field(&json, "twoFactorProviders2").and_then(|all| all.get("7")).filter(|c| !c.is_null()).map(|c| match c {
425                Value::String(text) => text.clone(),
426                other => other.to_string(),
427            });
428            return Err(Problem::SecondFactor(ids, webauthn));
429        }
430        return Err(refusal(&json, status));
431    }
432    // The user key and the private key: at the top, or where newer servers put them.
433    let unlock_options = field(&json, "userDecryptionOptions").and_then(|o| field(o, "masterPasswordUnlock"));
434    let protected = field(&json, "key")
435        .or_else(|| unlock_options.and_then(|u| field(u, "masterKeyEncryptedUserKey")))
436        .and_then(Value::as_str)
437        .ok_or_else(|| Problem::Refused("no user key".into()))?;
438    // The user key: opened by the stretched master key, or by the master key itself for the oldest accounts.
439    let stretched = Key::stretched(&master);
440    let opened = decrypt(protected, &stretched).or_else(|_| decrypt(protected, &Key { enc: Zeroizing::new(master.to_vec()), mac: Zeroizing::new(Vec::new()), cose: None }))?;
441    session(&json, Key::from_bytes(&opened)?)
442}
443
444/// Asks Bitwarden for a session: `form` is the login; `email` goes in the
445/// header Bitwarden expects with a password.
446fn ask_token(server: &Server, email: Option<&str>, form: &[(&str, String)]) -> Result<(u16, Value), Problem> {
447    let body: String = form.iter().map(|(k, v)| format!("{k}={}", form_encode(v))).collect::<Vec<_>>().join("&");
448    let version = client_version(server);
449    let mut request = agent()
450        .post(&format!("{}/connect/token", server.identity))
451        .header("Content-Type", "application/x-www-form-urlencoded")
452        .header("Accept", "application/json")
453        .header("Bitwarden-Client-Name", "desktop")
454        .header("Bitwarden-Client-Version", &version)
455        .header("Device-Type", device_type());
456    if let Some(email) = email {
457        request = request.header("auth-email", URL_SAFE_NO_PAD.encode(email.trim().as_bytes()));
458    }
459    answer(request.send(body))
460}
461
462/// Why Bitwarden refused a login, in its words.
463fn refusal(json: &Value, status: u16) -> Problem {
464    let said = [field(json, "errorModel").and_then(|m| field(m, "message")), field(json, "error_description"), field(json, "message")]
465        .into_iter()
466        .flatten()
467        .filter_map(Value::as_str)
468        .next()
469        .unwrap_or("")
470        .to_string();
471    let lower = said.to_lowercase();
472    if lower.contains("new device") {
473        Problem::NewDevice
474    } else if lower.contains("username or password") || lower.contains("invalid_username_or_password") {
475        Problem::WrongPassword
476    } else {
477        Problem::Refused(if said.is_empty() { format!("login: {status}") } else { said })
478    }
479}
480
481/// The session Bitwarden gave, with its user key opened: the account's RSA
482/// key opened with it (at the top, or where newer servers put it).
483fn session(json: &Value, user: Key) -> Result<Unlocked, Problem> {
484    let access = field(json, "access_token").and_then(Value::as_str).ok_or_else(|| Problem::Refused("no access token".into()))?.to_string();
485    let private = field(json, "privateKey")
486        .or_else(|| field(json, "accountKeys").and_then(|k| field(k, "publicKeyEncryptionKeyPair")).and_then(|p| field(p, "wrappedPrivateKey")))
487        .and_then(Value::as_str)
488        .and_then(|p| decrypt(p, &user).ok())
489        .and_then(|der| RsaPrivateKey::from_pkcs8_der(&der).ok());
490    let remember = field(json, "twoFactorToken").and_then(Value::as_str).map(str::to_string);
491    Ok(Unlocked { access, user, private, remember })
492}
493
494/// The account a session is for: the e-mail its access token names, in lower case.
495pub fn account(unlocked: &Unlocked) -> Option<String> {
496    let claims = URL_SAFE_NO_PAD.decode(unlocked.access.split('.').nth(1)?.trim_end_matches('=')).ok()?;
497    let claims: Value = serde_json::from_slice(&claims).ok()?;
498    claims.get("email").and_then(Value::as_str).map(|e| e.trim().to_lowercase())
499}
500
501/// What Bitwarden asks of a passkey to log in: the options the browser
502/// takes (JSON), and the token that goes back with the key's answer.
503pub struct PasskeyAsk {
504    pub options: String,
505    pub token: String,
506}
507
508/// A passkey login begun: asked of no account, the key says whose it is.
509pub fn passkey_ask(server: &Server) -> Result<PasskeyAsk, Problem> {
510    let (status, json) = answer(
511        agent()
512            .get(&format!("{}/accounts/webauthn/assertion-options", server.identity))
513            .header("Accept", "application/json")
514            .header("Bitwarden-Client-Name", "desktop")
515            .header("Bitwarden-Client-Version", &client_version(server))
516            .header("Device-Type", device_type())
517            .call(),
518    )?;
519    match (status, field(&json, "options").filter(|o| o.is_object()), field(&json, "token").and_then(Value::as_str)) {
520        (200, Some(options), Some(token)) => Ok(PasskeyAsk { options: options.to_string(), token: token.to_string() }),
521        _ => Err(Problem::Refused(format!("passkey: {status}"))),
522    }
523}
524
525/// The salt of the key's secret that opens the vault (WebAuthn PRF), as
526/// Bitwarden's apps ask it: SHA-256 of "passwordless-login".
527pub fn passkey_salt() -> [u8; 32] {
528    Sha256::digest(b"passwordless-login").into()
529}
530
531/// Where a security key is asked: a page of the vault's own (its light
532/// security-key page, with nothing to show unasked), so the key signs for the
533/// one address Bitwarden takes keys' signatures from. Held unseen in the
534/// window; `key_script` asks the key there.
535pub fn key_page(server: &Server) -> String {
536    format!("{}/webauthn-connector.html", server.vault)
537}
538
539/// The script that asks the key in that page: `options` as Bitwarden gave
540/// them (a second step's, or `passkey_ask`'s for the key alone). The answer
541/// waits in the page until taken (`window.sioulKey()`): for a second step,
542/// {"token"}, the step's code; for the key alone, {"response", "secret"};
543/// else {"error"} ("not-allowed": not used in time, or cancelled).
544pub fn key_script(options: &str, passkey: bool) -> String {
545    let options: Value = serde_json::from_str(options).unwrap_or(Value::Null);
546    let mode = Value::String(if passkey { "passkey" } else { "factor" }.into());
547    let salt = Value::String(URL_SAFE_NO_PAD.encode(passkey_salt()));
548    format!("{}({mode}, {options}, {salt});\n", include_str!("bitwarden-key.js").trim_end())
549}
550
551/// Logs in with a passkey and opens the vault with the key's secret: `token`
552/// as `passkey_ask` gave it, `response` the key's answer (JSON, as
553/// Bitwarden's apps send it), `secret` the key's PRF output (base64url). No
554/// second step: Bitwarden counts the key's PIN as one.
555pub fn unlock_with_passkey(server: &Server, device: &str, token: &str, response: &str, secret: &str) -> Result<Unlocked, Problem> {
556    let secret = Zeroizing::new(URL_SAFE_NO_PAD.decode(secret.trim()).unwrap_or_default());
557    let secret: Zeroizing<[u8; 32]> = Zeroizing::new(secret.as_slice().try_into().map_err(|_| Problem::NoKeySecret)?);
558    let form: Vec<(&str, String)> = vec![
559        ("grant_type", "webauthn".into()),
560        ("token", token.to_string()),
561        ("deviceResponse", response.to_string()),
562        ("scope", "api offline_access".into()),
563        ("client_id", "desktop".into()),
564        ("deviceType", device_type().into()),
565        ("deviceIdentifier", device.to_string()),
566        ("deviceName", "Sioul".into()),
567    ];
568    let (status, json) = ask_token(server, None, &form)?;
569    if status != 200 {
570        return Err(refusal(&json, status));
571    }
572    let option = field(&json, "userDecryptionOptions").and_then(|o| field(o, "webAuthnPrfOption")).ok_or(Problem::PasskeyNotForVault)?;
573    session(&json, user_key_of_passkey(&secret, option)?)
574}
575
576/// The user key, opened with a passkey's secret: the secret stretched as a
577/// master key is (HKDF-Expand, "enc" and "mac") opens the RSA key Bitwarden
578/// keeps for the passkey, which opens the user key (`WebAuthnPrfOption`).
579fn user_key_of_passkey(secret: &[u8; 32], option: &Value) -> Result<Key, Problem> {
580    let wrapped = |name: &str| field(option, name).and_then(Value::as_str).ok_or(Problem::PasskeyNotForVault);
581    let private = decrypt(wrapped("encryptedPrivateKey")?, &Key::stretched(secret))?;
582    let private = RsaPrivateKey::from_pkcs8_der(&private).map_err(|_| Problem::Refused("passkey: RSA key".into()))?;
583    Key::from_bytes(&decrypt_rsa(wrapped("encryptedUserKey")?, &private)?)
584}
585
586/// Sends the code of the e-mail second step (provider 1).
587pub fn send_email_code(server: &Server, email: &str, password: &str, device: &str) -> Result<(), Problem> {
588    let kdf = prelogin(server, email)?;
589    let master = master_key(password, email, kdf)?;
590    let body = serde_json::json!({ "email": email.trim(), "masterPasswordHash": password_hash(&master, password), "deviceIdentifier": device });
591    let (status, json) = answer(
592        agent()
593            .post(&format!("{}/two-factor/send-email-login", server.api))
594            .header("Content-Type", "application/json")
595            .header("Bitwarden-Client-Name", "desktop")
596            .header("Bitwarden-Client-Version", &client_version(server))
597            .header("Device-Type", device_type())
598            .send(body.to_string()),
599    )?;
600    if (200..300).contains(&status) {
601        return Ok(());
602    }
603    let said = [field(&json, "errorModel").and_then(|m| field(m, "message")), field(&json, "message")].into_iter().flatten().filter_map(Value::as_str).next().unwrap_or("").to_string();
604    Err(Problem::Refused(if said.is_empty() { format!("e-mail code: {status}") } else { said }))
605}
606
607fn form_encode(text: &str) -> String {
608    text.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect()
609}
610
611/// A login of the vault.
612#[derive(Clone, Default, PartialEq, Eq)]
613pub struct Item {
614    /// Bitwarden's id of the item, to come back to the one chosen.
615    pub id: String,
616    pub name: String,
617    pub username: String,
618    pub password: String,
619    pub uris: Vec<String>,
620    /// The one-time code's secret: base32, `otpauth://…` or `steam://…`.
621    pub totp: String,
622}
623
624/// Printed without its secrets, should it ever be (a log, a test's failure).
625impl std::fmt::Debug for Item {
626    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627        f.debug_struct("Item").field("id", &self.id).field("name", &self.name).field("username", &self.username).field("uris", &self.uris).finish_non_exhaustive()
628    }
629}
630
631/// Its secrets wiped when it goes.
632impl Drop for Item {
633    fn drop(&mut self) {
634        use zeroize::Zeroize;
635        self.password.zeroize();
636        self.totp.zeroize();
637    }
638}
639
640/// This computer, as Bitwarden knows it: one identifier kept in `path`, so it
641/// is not a new device each time.
642pub fn device_id(path: &std::path::Path) -> String {
643    if let Some(id) = std::fs::read_to_string(path).ok().map(|t| t.trim().to_string()).filter(|t| t.len() >= 32) {
644        return id;
645    }
646    let id = uuid::Uuid::new_v4().to_string();
647    if let Some(parent) = path.parent() {
648        let _ = std::fs::create_dir_all(parent);
649    }
650    let _ = std::fs::write(path, &id);
651    id
652}
653
654/// Reads the vault: every login, opened.
655pub fn sync(server: &Server, unlocked: &Unlocked) -> Result<Vec<Item>, Problem> {
656    let (status, json) = answer(
657        agent()
658            .get(&format!("{}/sync?excludeDomains=true", server.api))
659            .header("Authorization", &format!("Bearer {}", unlocked.access))
660            .header("Bitwarden-Client-Name", "desktop")
661            .header("Bitwarden-Client-Version", &client_version(server))
662            .header("Device-Type", device_type())
663            .call(),
664    )?;
665    if status != 200 {
666        return Err(Problem::Refused(format!("sync: {status}")));
667    }
668    // Each organisation's key, through your RSA key.
669    let mut orgs: HashMap<String, Key> = HashMap::new();
670    if let (Some(private), Some(list)) = (unlocked.private.as_ref(), field(&json, "profile").and_then(|p| field(p, "organizations")).and_then(Value::as_array)) {
671        for org in list {
672            if let (Some(id), Some(key)) = (field(org, "id").and_then(Value::as_str), field(org, "key").and_then(Value::as_str))
673                && let Ok(bytes) = decrypt_rsa(key, private)
674                && let Ok(key) = Key::from_bytes(&bytes)
675            {
676                orgs.insert(id.to_string(), key);
677            }
678        }
679    }
680    let mut items = Vec::new();
681    for cipher in field(&json, "ciphers").and_then(Value::as_array).into_iter().flatten() {
682        // Logins only, and not those in the trash.
683        if field(cipher, "type").and_then(Value::as_u64) != Some(1) || field(cipher, "deletedDate").is_some() {
684            continue;
685        }
686        let base = match field(cipher, "organizationId").and_then(Value::as_str) {
687            Some(org) => match orgs.get(org) {
688                Some(key) => key,
689                None => continue,
690            },
691            None => &unlocked.user,
692        };
693        // An item may have its own key, opened with the user's or the organisation's.
694        let own = field(cipher, "key").and_then(Value::as_str).and_then(|k| decrypt(k, base).ok()).and_then(|b| Key::from_bytes(&b).ok());
695        let key = own.as_ref().unwrap_or(base);
696        let login = field(cipher, "login");
697        items.push(Item {
698            id: field(cipher, "id").and_then(Value::as_str).unwrap_or_default().to_string(),
699            name: text(field(cipher, "name"), key),
700            username: text(login.and_then(|l| field(l, "username")), key),
701            password: text(login.and_then(|l| field(l, "password")), key),
702            totp: text(login.and_then(|l| field(l, "totp")), key),
703            uris: login.and_then(|l| field(l, "uris")).and_then(Value::as_array).into_iter().flatten().map(|u| text(field(u, "uri"), key)).filter(|u| !u.is_empty()).collect(),
704        });
705    }
706    Ok(items)
707}
708
709/// The sites of a login, as their hosts in lower case: its addresses that are
710/// web pages, with or without "https://". An app's ("androidapp://…") is no
711/// site, nor is a pattern ("^https?://…", for Bitwarden's matching by regex).
712pub fn hosts_of(item: &Item) -> Vec<String> {
713    item.uris.iter().filter_map(|uri| web_host(uri)).collect()
714}
715
716fn web_host(uri: &str) -> Option<String> {
717    let uri = uri.trim();
718    let address = match uri.split_once("://") {
719        Some((scheme, _)) if !scheme.eq_ignore_ascii_case("https") && !scheme.eq_ignore_ascii_case("http") => return None,
720        Some(_) => uri.to_string(),
721        None => format!("https://{uri}"),
722    };
723    let host = sioul_core::sites::host_of(&address);
724    (!host.is_empty()).then_some(host)
725}
726
727/// The domain a host is registered under (`sites::domain_of`: "ameli.fr" for
728/// assure.ameli.fr); an address in numbers (192.168.1.1) or a computer's
729/// name (one label) is its own.
730fn domain(host: &str) -> String {
731    let numbers = host.split('.').all(|label| !label.is_empty() && label.bytes().all(|b| b.is_ascii_digit()));
732    if numbers || host.starts_with('[') || !host.contains('.') { host.to_string() } else { sioul_core::sites::domain_of(host) }
733}
734
735/// The registrable domain of an address or a host, as the search reads it:
736/// `ameli.fr` for `https://assure.ameli.fr/…`.
737pub fn domain_of_address(address: &str) -> String {
738    domain(&sioul_core::sites::host_of(address.trim()))
739}
740
741/// Case and accents aside, as everywhere in Sioul (`text::fold_char`).
742fn folded(text: &str) -> String {
743    text.chars().map(sioul_core::text::fold_char).collect()
744}
745
746/// Whether `word` begins a label of `host`, or a part of one between hyphens:
747/// "ameli" in ameli.fr and assure.ameli.fr, not in camelia.com; "agricole" in
748/// credit-agricole.fr; "ameli.f", typed on the way to "ameli.fr", in ameli.fr.
749fn begins_label(host: &str, word: &str) -> bool {
750    host.match_indices(word).any(|(at, _)| at == 0 || matches!(host.as_bytes()[at - 1], b'.' | b'-'))
751}
752
753/// Whether `word` begins a word of `text`: "ameli" in "Mon Ameli", not in "Camelia".
754fn begins_word(text: &str, word: &str) -> bool {
755    text.match_indices(word).any(|(at, _)| !text[..at].chars().next_back().is_some_and(char::is_alphanumeric))
756}
757
758/// Whether `word` is a whole word of `text`: "google" in "Google Play", not in "Googleplex".
759fn whole_word(text: &str, word: &str) -> bool {
760    text.match_indices(word).any(|(at, _)| !text[..at].chars().next_back().is_some_and(char::is_alphanumeric) && !text[at + word.len()..].chars().next().is_some_and(char::is_alphanumeric))
761}
762
763/// What a login is searched by, folded once as the vault opens: its name, its
764/// user name, its sites and their domains. Never its password, its notes or
765/// its one-time code's secret: no search reads them.
766struct Searched {
767    name: String,
768    user: String,
769    hosts: Vec<String>,
770    domains: Vec<String>,
771    /// Its sites as shown: `hosts` before folding.
772    shown: Vec<String>,
773}
774
775impl Searched {
776    fn of(item: &Item) -> Searched {
777        let shown = hosts_of(item);
778        let hosts: Vec<String> = shown.iter().map(|h| folded(h)).collect();
779        Searched { name: folded(&item.name), user: folded(&item.username), domains: hosts.iter().map(|h| domain(h)).collect(), hosts, shown }
780    }
781
782    /// How near the page its sites are: 0 its host, 1 its domain, 2 neither;
783    /// and which of its sites is.
784    fn nearness(&self, page: &Page) -> (u8, Option<usize>) {
785        if page.host.is_empty() {
786            return (2, None);
787        }
788        if let Some(at) = self.hosts.iter().position(|h| *h == page.host) {
789            return (0, Some(at));
790        }
791        match self.domains.iter().position(|d| *d == page.domain) {
792            Some(at) => (1, Some(at)),
793            None => (2, None),
794        }
795    }
796}
797
798/// The address the logins are wanted for: a site's page, an account's server.
799struct Page {
800    host: String,
801    domain: String,
802}
803
804impl Page {
805    fn of(url: &str) -> Page {
806        let host = folded(&sioul_core::sites::host_of(url.trim()));
807        Page { domain: domain(&host), host }
808    }
809}
810
811/// How a login's site matched the site searched, the best first.
812#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
813pub enum By {
814    /// The same host.
815    Host,
816    /// The same registrable domain.
817    Domain,
818    /// A host whose labels begin with the words typed.
819    Label,
820    /// The login's name.
821    Name,
822}
823
824/// The site searched, as typed: an address ("ameli.fr", a page's whole
825/// address), or words ("ameli", "credit agricole").
826enum SiteAsked {
827    Address {
828        host: String,
829        domain: String,
830        /// The domain's own name, for logins named after their site, as a
831        /// whole word: "ameli" for ameli.fr; "x" for x.com, not "Xbox".
832        name: String,
833    },
834    Words(Vec<String>),
835}
836
837impl SiteAsked {
838    fn of(text: &str) -> Option<SiteAsked> {
839        let text = folded(text.trim());
840        if text.is_empty() {
841            return None;
842        }
843        // An address: a scheme, or one word with a dot, a port or a path ("ameli.fr", "localhost:8080").
844        if text.contains("://") || (text.contains(['.', ':', '/']) && !text.contains(char::is_whitespace)) {
845            let host = sioul_core::sites::host_of(&if text.contains("://") { text.clone() } else { format!("https://{text}") });
846            if !host.is_empty() {
847                let domain = domain(&host);
848                let numbers = domain.bytes().all(|b| b.is_ascii_digit() || b == b'.');
849                let name = if numbers || domain.starts_with('[') { String::new() } else { domain.split('.').next().unwrap_or_default().to_string() };
850                return Some(SiteAsked::Address { host, domain, name });
851            }
852        }
853        Some(SiteAsked::Words(text.split_whitespace().map(str::to_string).collect()))
854    }
855
856    /// How a login matches, and which of its sites did; `None` when it does not.
857    fn matches(&self, login: &Searched, page: &Page) -> Option<(By, Option<usize>)> {
858        match self {
859            SiteAsked::Address { host, domain, name } => {
860                // A page's domain stands for the page: its own host is the same host.
861                let same = |h: &String| h == host || (*host == page.domain && *h == page.host);
862                if let Some(at) = login.hosts.iter().position(same) {
863                    return Some((By::Host, Some(at)));
864                }
865                if let Some(at) = login.domains.iter().position(|d| d == domain) {
866                    return Some((By::Domain, Some(at)));
867                }
868                if let Some(at) = login.hosts.iter().position(|h| begins_label(h, host)) {
869                    return Some((By::Label, Some(at)));
870                }
871                (!name.is_empty() && whole_word(&login.name, name)).then_some((By::Name, None))
872            }
873            SiteAsked::Words(words) => {
874                if let Some(at) = login.hosts.iter().position(|h| words.iter().all(|w| begins_label(h, w))) {
875                    return Some((By::Label, Some(at)));
876                }
877                words.iter().all(|w| begins_word(&login.name, w)).then_some((By::Name, None))
878            }
879        }
880    }
881}
882
883/// A match, before the best are kept: what it is ordered by (not the login
884/// chosen last, how its site matched, how near the page, not the user name
885/// typed whole), which login, how its site matched, which of its sites to show.
886struct Hit {
887    key: (bool, Option<By>, u8, bool),
888    login: usize,
889    by: Option<By>,
890    site: usize,
891}
892
893/// A login found by a search.
894pub struct Found<'a> {
895    pub item: &'a Item,
896    /// How its site matched: `None` when no site was searched.
897    pub by: Option<By>,
898    /// Its site to show: the one that matched, else the page's, else its first ("" without one).
899    pub site: String,
900    /// Made for the page's own domain (`find`'s `page`).
901    pub own: bool,
902}
903
904/// What the chooser opens with for a page (`Logins::opening`).
905#[derive(Debug, PartialEq, Eq)]
906pub struct Opening {
907    /// The page's registrable domain: the Site field's first text.
908    pub domain: String,
909    /// The domain of the login chosen there last, "" when none: the Site
910    /// field's first text instead, the page's own domain when that login is
911    /// made for it.
912    pub chosen: String,
913    /// The login filled at once: the page's only one, unless another was
914    /// chosen there last.
915    pub only: Option<String>,
916}
917
918/// The vault's logins, opened, with what each is searched by.
919pub struct Logins {
920    items: Vec<Item>,
921    searched: Vec<Searched>,
922}
923
924impl Logins {
925    pub fn new(items: Vec<Item>) -> Logins {
926        let searched = items.iter().map(Searched::of).collect();
927        Logins { items, searched }
928    }
929
930    pub fn items(&self) -> &[Item] {
931        &self.items
932    }
933
934    /// What the chooser opens with for a page, and what "Fill the login" fills
935    /// at once; `last` is the login chosen there last.
936    pub fn opening(&self, page: &str, last: Option<&str>) -> Opening {
937        let own = self.for_site(page);
938        let last = last.and_then(|id| self.items.iter().find(|item| item.id == id));
939        let only = match (own.as_slice(), last) {
940            ([one], None) => Some(one.id.clone()),
941            ([one], Some(last)) if last.id == one.id => Some(one.id.clone()),
942            _ => None,
943        };
944        let domain = domain_of_address(page);
945        let chosen = match last {
946            Some(last) if own.iter().any(|item| item.id == last.id) => domain.clone(),
947            Some(last) => hosts_of(last).first().map(|host| domain_of_address(host)).unwrap_or_default(),
948            None => String::new(),
949        };
950        Opening { domain, chosen, only }
951    }
952
953    /// The logins for a page: those made for its domain, its own host first.
954    pub fn for_site(&self, page: &str) -> Vec<&Item> {
955        let page = Page::of(page);
956        let mut found: Vec<(u8, &Item)> = self.items.iter().zip(&self.searched).map(|(item, s)| (s.nearness(&page).0, item)).filter(|(near, _)| *near < 2).collect();
957        found.sort_by_key(|(near, _)| *near);
958        found.into_iter().map(|(_, item)| item).collect()
959    }
960
961    /// The logins by site and by user name, each optional, both matching when
962    /// both are given; nothing when neither is.
963    /// - `site` matches the login's sites: the same host, then the same
964    ///   registrable domain, then a host whose labels begin with the words
965    ///   typed ("ameli": ameli.fr, assure.ameli.fr, not camelia.com), then the
966    ///   login's name (an address's own name, "ameli" for ameli.fr). Never its
967    ///   user name: "gmail" finds Gmail, not the logins with a Gmail address.
968    /// - `user` matches the user name only, holding every word typed.
969    ///
970    /// Case and accents aside. The best `limit` of them, the best first:
971    /// `first` (the login chosen last for the page), then by how the site
972    /// matched, the page's own (its host, then its domain), the user name
973    /// typed whole, the name; and how many matched in all. Only those kept are
974    /// sorted: a large vault, all of it matching, costs one pass.
975    pub fn find(&self, site: &str, user: &str, page: &str, first: Option<&str>, limit: usize) -> (Vec<Found<'_>>, usize) {
976        let asked = SiteAsked::of(site);
977        let user = folded(user.trim());
978        let words: Vec<&str> = user.split_whitespace().collect();
979        let whole = words.join(" ");
980        if asked.is_none() && words.is_empty() {
981            return (Vec::new(), 0);
982        }
983        let page = Page::of(page);
984        let mut hits: Vec<Hit> = Vec::new();
985        for (at, (item, login)) in self.items.iter().zip(&self.searched).enumerate() {
986            let matched = match &asked {
987                Some(asked) => match asked.matches(login, &page) {
988                    Some(matched) => Some(matched),
989                    None => continue,
990                },
991                None => None,
992            };
993            if !words.iter().all(|w| login.user.contains(w)) {
994                continue;
995            }
996            let (near, at_page) = login.nearness(&page);
997            let by = matched.map(|(by, _)| by);
998            hits.push(Hit { key: (first != Some(item.id.as_str()), by, near, words.is_empty() || login.user != whole), login: at, by, site: matched.and_then(|(_, site)| site).or(at_page).unwrap_or(0) });
999        }
1000        let total = hits.len();
1001        let order = |a: &Hit, b: &Hit| {
1002            let (x, y) = (&self.searched[a.login], &self.searched[b.login]);
1003            a.key.cmp(&b.key).then_with(|| x.name.cmp(&y.name)).then_with(|| x.user.cmp(&y.user)).then_with(|| a.login.cmp(&b.login))
1004        };
1005        if hits.len() > limit {
1006            if limit > 0 {
1007                hits.select_nth_unstable_by(limit - 1, order);
1008            }
1009            hits.truncate(limit);
1010        }
1011        hits.sort_unstable_by(order);
1012        let found = hits
1013            .into_iter()
1014            .map(|hit| Found { item: &self.items[hit.login], by: hit.by, site: self.searched[hit.login].shown.get(hit.site).cloned().unwrap_or_default(), own: hit.key.2 < 2 })
1015            .collect();
1016        (found, total)
1017    }
1018}
1019
1020/// RFC 4648 base32, letters and digits 2–7, spaces and padding ignored.
1021fn base32(text: &str) -> Option<Vec<u8>> {
1022    let mut bits: u64 = 0;
1023    let mut count = 0;
1024    let mut out = Vec::new();
1025    for c in text.chars().filter(|c| !c.is_whitespace() && *c != '=' && *c != '-') {
1026        let value = match c.to_ascii_uppercase() {
1027            c @ 'A'..='Z' => c as u64 - 'A' as u64,
1028            c @ '2'..='7' => c as u64 - '2' as u64 + 26,
1029            _ => return None,
1030        };
1031        bits = (bits << 5) | value;
1032        count += 5;
1033        if count >= 8 {
1034            count -= 8;
1035            out.push((bits >> count) as u8);
1036        }
1037    }
1038    Some(out)
1039}
1040
1041/// The one-time code now (RFC 6238; RFC 4226 for the truncation): from a base32
1042/// secret, an `otpauth://totp/…` address (its digits, period and SHA-1,
1043/// SHA-256 or SHA-512), or Steam's five characters.
1044pub fn totp(secret: &str, now: i64) -> Option<String> {
1045    let secret = secret.trim();
1046    let (key, digits, period, algorithm, steam) = if let Some(rest) = secret.strip_prefix("otpauth://") {
1047        let query = rest.split_once('?').map_or("", |(_, q)| q);
1048        let param = |name: &str| query.split('&').find_map(|p| p.split_once('=').filter(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.to_string()));
1049        let digits = param("digits").and_then(|d| d.parse().ok()).unwrap_or(6);
1050        let period = param("period").and_then(|p| p.parse().ok()).unwrap_or(30);
1051        (base32(&param("secret")?)?, digits, period, param("algorithm").unwrap_or_else(|| "SHA1".into()).to_uppercase(), false)
1052    } else if let Some(rest) = secret.strip_prefix("steam://") {
1053        (base32(rest)?, 5, 30, "SHA1".into(), true)
1054    } else {
1055        (base32(secret)?, 6, 30, "SHA1".into(), false)
1056    };
1057    if key.is_empty() || period == 0 {
1058        return None;
1059    }
1060    let counter = (now / period).to_be_bytes();
1061    let digest: Vec<u8> = match algorithm.as_str() {
1062        "SHA256" => {
1063            let mut mac = Hmac::<Sha256>::new_from_slice(&key).ok()?;
1064            mac.update(&counter);
1065            mac.finalize().into_bytes().to_vec()
1066        }
1067        "SHA512" => {
1068            let mut mac = Hmac::<sha2::Sha512>::new_from_slice(&key).ok()?;
1069            mac.update(&counter);
1070            mac.finalize().into_bytes().to_vec()
1071        }
1072        _ => {
1073            let mut mac = Hmac::<sha1::Sha1>::new_from_slice(&key).ok()?;
1074            mac.update(&counter);
1075            mac.finalize().into_bytes().to_vec()
1076        }
1077    };
1078    let offset = usize::from(digest.last()? & 0x0f);
1079    let code = u32::from_be_bytes(digest.get(offset..offset + 4)?.try_into().ok()?) & 0x7fff_ffff;
1080    if steam {
1081        const STEAM: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
1082        let mut value = code as usize;
1083        return Some((0..5).map(|_| {
1084            let c = STEAM[value % STEAM.len()] as char;
1085            value /= STEAM.len();
1086            c
1087        }).collect());
1088    }
1089    let digits = u32::clamp(digits, 4, 10);
1090    Some(format!("{:0width$}", u64::from(code) % 10u64.pow(digits), width = digits as usize))
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096
1097    #[test]
1098    fn keys_as_bitwarden_makes_them() {
1099        // Bitwarden's own test vectors (bitwarden-crypto, master_key.rs and utils.rs).
1100        for email in ["test@bitwarden.com", "TEST@bitwarden.com", " test@bitwarden.com"] {
1101            let master = master_key("asdfasdf", email, Kdf::Pbkdf2 { iterations: 100_000 }).unwrap();
1102            assert_eq!(password_hash(&master, "asdfasdf"), "wmyadRMyBZOH7P/a/ucTCbSghKgdzDpPqUnu/DAVtSw=");
1103        }
1104        let master = master_key("asdfasdf", "test_salt", Kdf::Argon2id { iterations: 4, memory_mib: 32, parallelism: 2 }).unwrap();
1105        assert_eq!(password_hash(&master, "asdfasdf"), "PR6UjYmjmppTYcdyTiNbAhPJuQQOmynKbdEl1oyi/iQ=");
1106        let master = [31, 79, 104, 226, 150, 71, 177, 90, 194, 80, 172, 209, 17, 129, 132, 81, 138, 167, 69, 167, 254, 149, 2, 27, 39, 197, 64, 42, 22, 195, 86, 75];
1107        let stretched = Key::stretched(&master);
1108        assert_eq!(stretched.enc.as_slice(), [111, 31, 178, 45, 238, 152, 37, 114, 143, 215, 124, 83, 135, 173, 195, 23, 142, 134, 120, 249, 61, 132, 163, 182, 113, 197, 189, 204, 188, 21, 237, 96]);
1109        assert_eq!(stretched.mac.as_slice(), [221, 127, 206, 234, 101, 27, 202, 38, 86, 52, 34, 28, 78, 28, 185, 16, 48, 61, 127, 166, 209, 247, 194, 87, 232, 26, 48, 85, 193, 249, 179, 155]);
1110    }
1111
1112    #[test]
1113    fn encstrings_open_and_refuse_a_changed_byte() {
1114        use aes::cipher::BlockEncryptMut;
1115        let key = Key::from_bytes(&[7u8; 64]).unwrap();
1116        let iv = [3u8; 16];
1117        let data = cbc::Encryptor::<aes::Aes256>::new_from_slices(&key.enc, &iv).unwrap().encrypt_padded_vec_mut::<Pkcs7>(b"hunter2");
1118        let mut mac = HmacSha256::new_from_slice(&key.mac).unwrap();
1119        mac.update(&iv);
1120        mac.update(&data);
1121        let mac = mac.finalize().into_bytes();
1122        let enc = format!("2.{}|{}|{}", STANDARD.encode(iv), STANDARD.encode(&data), STANDARD.encode(mac));
1123        assert_eq!(decrypt(&enc, &key).unwrap().as_slice(), b"hunter2");
1124        let mut bad = mac.to_vec();
1125        bad[0] ^= 1;
1126        let forged = format!("2.{}|{}|{}", STANDARD.encode(iv), STANDARD.encode(&data), STANDARD.encode(bad));
1127        assert_eq!(decrypt(&forged, &key).err(), Some(Problem::WrongPassword));
1128        // The same text without its check: refused by a key that has a MAC key,
1129        // opened by the oldest keys, which have none.
1130        let unchecked = format!("0.{}|{}", STANDARD.encode(iv), STANDARD.encode(&data));
1131        assert!(matches!(decrypt(&unchecked, &key), Err(Problem::Refused(_))));
1132        let oldest = Key::from_bytes(&key.enc).unwrap();
1133        assert_eq!(decrypt(&unchecked, &oldest).unwrap().as_slice(), b"hunter2");
1134    }
1135
1136    #[test]
1137    fn a_server_sets_no_key_derivation_out_of_bounds() {
1138        // Cheaper than Bitwarden's clients accept, or more than its server allows.
1139        for kdf in [
1140            Kdf::Pbkdf2 { iterations: 4_999 },
1141            Kdf::Pbkdf2 { iterations: u32::MAX },
1142            Kdf::Argon2id { iterations: 1, memory_mib: 64, parallelism: 4 },
1143            Kdf::Argon2id { iterations: 3, memory_mib: 1, parallelism: 4 },
1144            Kdf::Argon2id { iterations: 3, memory_mib: u32::MAX, parallelism: 4 },
1145            Kdf::Argon2id { iterations: 3, memory_mib: 64, parallelism: 0 },
1146            Kdf::Argon2id { iterations: 3, memory_mib: 64, parallelism: 255 },
1147        ] {
1148            assert!(matches!(master_key("asdfasdf", "test@example.org", kdf), Err(Problem::Refused(_))), "{kdf:?}");
1149        }
1150    }
1151
1152    #[test]
1153    fn cose_messages_open() {
1154        use chacha20poly1305::aead::{Aead, KeyInit, Payload};
1155        use ciborium::Value;
1156        // A COSE key as Bitwarden pads it (to 65 bytes at least, so never read
1157        // as a 64-byte key), then a padded UTF-8 message under it.
1158        let k = vec![9u8; 32];
1159        let mut clean = Vec::new();
1160        ciborium::into_writer(&Value::Map(vec![(Value::Integer(3.into()), Value::Integer(XCHACHA20_POLY1305.into())), (Value::Integer((-1).into()), Value::Bytes(k.clone()))]), &mut clean).unwrap();
1161        let pad = 65usize.saturating_sub(clean.len()).max(1);
1162        clean.extend(std::iter::repeat_n(pad as u8, pad));
1163        assert_eq!(clean.len(), 65);
1164        let key = Key::from_bytes(&clean).unwrap();
1165        let mut protected = Vec::new();
1166        ciborium::into_writer(&Value::Map(vec![(Value::Integer(1.into()), Value::Integer(XCHACHA20_POLY1305.into())), (Value::Integer(3.into()), Value::Text("application/x.bitwarden.utf8-padded".into()))]), &mut protected).unwrap();
1167        let mut aad = Vec::new();
1168        ciborium::into_writer(&Value::Array(vec![Value::Text("Encrypt0".into()), Value::Bytes(protected.clone()), Value::Bytes(Vec::new())]), &mut aad).unwrap();
1169        let nonce = [5u8; 24];
1170        let mut plain = b"secret".to_vec();
1171        let pad = 32 - plain.len();
1172        plain.extend(std::iter::repeat_n(pad as u8, pad));
1173        let sealed = chacha20poly1305::XChaCha20Poly1305::new_from_slice(&k).unwrap().encrypt(chacha20poly1305::XNonce::from_slice(&nonce), Payload { msg: &plain, aad: &aad }).unwrap();
1174        let mut message = Vec::new();
1175        ciborium::into_writer(&Value::Array(vec![Value::Bytes(protected), Value::Map(vec![(Value::Integer(5.into()), Value::Bytes(nonce.to_vec()))]), Value::Bytes(sealed)]), &mut message).unwrap();
1176        assert_eq!(decrypt(&format!("7.{}", STANDARD.encode(&message)), &key).unwrap().as_slice(), b"secret");
1177    }
1178
1179    #[test]
1180    fn one_time_codes() {
1181        // RFC 6238, appendix B: the ASCII secret "12345678901234567890", 8 digits.
1182        let rfc = "otpauth://totp/x?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&digits=8";
1183        assert_eq!(totp(rfc, 59).as_deref(), Some("94287082"));
1184        assert_eq!(totp(rfc, 1_111_111_109).as_deref(), Some("07081804"));
1185        assert_eq!(totp("GEZD GNBV GY3T QOJQ GEZD GNBV GY3T QOJQ", 59).as_deref(), Some("287082"));
1186        assert_eq!(totp("not base32 !", 59), None);
1187    }
1188
1189    #[test]
1190    fn servers() {
1191        assert_eq!(Server::of("bitwarden.eu").api, "https://api.bitwarden.eu");
1192        assert_eq!(Server::of("Vault.Bitwarden.eu/").api, "https://api.bitwarden.eu", "the vault's address, as a browser shows it");
1193        assert_eq!(Server::of("vault.bitwarden.com").identity, "https://identity.bitwarden.com");
1194        assert_eq!(Server::of("vault.example.org").identity, "https://vault.example.org/identity");
1195    }
1196    #[test]
1197    fn versions_read() {
1198        assert!(is_version("2026.9.2") && is_version("2025.12.0") && is_version("2026.10"));
1199        assert!(!is_version("") && !is_version("2026") && !is_version("v2026.9.2") && !is_version("2026.9.2-beta"));
1200        assert!(is_version(CLIENT_VERSION));
1201    }
1202
1203    /// Bitwarden's cloud takes Sioul's login request for a made-up account at
1204    /// example.org: refused for its password, not for its version. Run by
1205    /// hand: it asks Bitwarden's servers.
1206    #[test]
1207    #[ignore]
1208    fn the_cloud_takes_the_version() {
1209        let server = Server::of("");
1210        assert!(is_version(&client_version(&server)));
1211        let refused = unlock(&server, "nobody-sioul-test@example.org", "not a real password", &uuid::Uuid::new_v4().to_string(), None, None, None);
1212        assert!(matches!(refused, Err(Problem::WrongPassword)), "{:?}", refused.err());
1213    }
1214
1215    /// A fixed stream of bytes, for the RSA keys of the tests.
1216    struct Stream(u64);
1217
1218    impl rsa::rand_core::RngCore for Stream {
1219        fn next_u32(&mut self) -> u32 {
1220            self.next_u64() as u32
1221        }
1222        fn next_u64(&mut self) -> u64 {
1223            // SplitMix64.
1224            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
1225            let mut z = self.0;
1226            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1227            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1228            z ^ (z >> 31)
1229        }
1230        fn fill_bytes(&mut self, dest: &mut [u8]) {
1231            for chunk in dest.chunks_mut(8) {
1232                let bytes = self.next_u64().to_le_bytes();
1233                chunk.copy_from_slice(&bytes[..chunk.len()]);
1234            }
1235        }
1236        fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rsa::rand_core::Error> {
1237            self.fill_bytes(dest);
1238            Ok(())
1239        }
1240    }
1241
1242    impl rsa::rand_core::CryptoRng for Stream {}
1243
1244    /// A passkey's keys made as Bitwarden's web vault makes them when its
1245    /// encryption is turned on (an RSA pair; the private key sealed by the
1246    /// stretched secret; the user key sealed by the public key, OAEP-SHA1),
1247    /// then opened by the secret, as Bitwarden's server gives them back.
1248    #[test]
1249    fn a_passkey_opens_the_vault() {
1250        use aes::cipher::BlockEncryptMut;
1251        use rsa::pkcs8::EncodePrivateKey;
1252        let mut stream = Stream(7);
1253        let pair = RsaPrivateKey::new(&mut stream, 1024).unwrap();
1254        let secret = [42u8; 32];
1255        let sealing = Key::stretched(&secret);
1256        let iv = [9u8; 16];
1257        let der = pair.to_pkcs8_der().unwrap();
1258        let data = cbc::Encryptor::<aes::Aes256>::new_from_slices(&sealing.enc, &iv).unwrap().encrypt_padded_vec_mut::<Pkcs7>(der.as_bytes());
1259        let mut mac = HmacSha256::new_from_slice(&sealing.mac).unwrap();
1260        mac.update(&iv);
1261        mac.update(&data);
1262        let private = format!("2.{}|{}|{}", STANDARD.encode(iv), STANDARD.encode(&data), STANDARD.encode(mac.finalize().into_bytes()));
1263        let user: Vec<u8> = (0..64u8).collect();
1264        let sealed_user = rsa::RsaPublicKey::from(&pair).encrypt(&mut stream, Oaep::new::<sha1::Sha1>(), &user).unwrap();
1265        let option = serde_json::json!({ "EncryptedPrivateKey": private, "EncryptedUserKey": format!("4.{}", STANDARD.encode(sealed_user)), "CredentialId": "abc" });
1266        let opened = user_key_of_passkey(&secret, &option).unwrap();
1267        assert_eq!(opened.enc.as_slice(), &user[..32]);
1268        assert_eq!(opened.mac.as_slice(), &user[32..]);
1269        // Another key's secret is refused before anything is decrypted.
1270        assert_eq!(user_key_of_passkey(&[1u8; 32], &option).err(), Some(Problem::WrongPassword));
1271        // A passkey whose encryption is off: nothing to open.
1272        assert_eq!(user_key_of_passkey(&secret, &serde_json::json!({})).err(), Some(Problem::PasskeyNotForVault));
1273        // No secret from the key: refused before Bitwarden is asked.
1274        let none = unlock_with_passkey(&Server::of("vault.invalid"), "device", "token", "{}", "");
1275        assert_eq!(none.err(), Some(Problem::NoKeySecret));
1276    }
1277
1278    #[test]
1279    fn the_key_script() {
1280        let options = r#"{"challenge":"abc","rpId":"vault.bitwarden.com","allowCredentials":[]}"#;
1281        let script = key_script(options, true);
1282        assert!(script.starts_with("// SPDX-License-Identifier"), "{script}");
1283        assert!(script.contains(r#"})("passkey", {"#) && script.contains(r#""challenge":"abc""#), "{script}");
1284        // The salt Bitwarden's apps use: SHA-256 of "passwordless-login", base64url.
1285        assert!(script.trim_end().ends_with(&format!("\"{}\");", URL_SAFE_NO_PAD.encode(passkey_salt()))), "{script}");
1286        assert!(key_script(options, false).contains(r#"})("factor", {"#));
1287        assert_eq!(key_page(&Server::of("")), "https://vault.bitwarden.com/webauthn-connector.html");
1288        assert_eq!(key_page(&Server::of("bitwarden.eu")), "https://vault.bitwarden.eu/webauthn-connector.html");
1289    }
1290
1291    /// A made-up vault: every name, address and secret here is invented.
1292    fn fixture() -> Logins {
1293        // Items wipe their secrets when dropped: built field by field.
1294        let login = |id: &str, name: &str, user: &str, uris: &[&str], password: &str, totp: &str| {
1295            let mut item = Item::default();
1296            item.id = id.into();
1297            item.name = name.into();
1298            item.username = user.into();
1299            item.uris = uris.iter().map(|u| u.to_string()).collect();
1300            item.password = password.into();
1301            item.totp = totp.into();
1302            item
1303        };
1304        Logins::new(vec![
1305            login("ameli", "Ameli", "1 85 07 75 123 456 78", &["https://ameli.fr"], "x", ""),
1306            login("assure", "Compte assuré", "1 85 07 75 123 456 78", &["https://assure.ameli.fr/PortailAS/appmanager"], "x", ""),
1307            login("camelia", "Camelia fleurs", "moi@exemple.test", &["camelia.com"], "x", ""),
1308            login("google", "Google", "aurore.exemple@gmail.example", &["https://accounts.google.com/v3/signin"], "x", "otpauth://totp/Google?secret=JBSWY3DPEHPK3PXP"),
1309            login("forum", "Forum de photo", "aurore.exemple@gmail.example", &["https://forum.photo.test"], "x", ""),
1310            login("shop", "Boutique", "aurore.exemple@gmail.example", &["boutique.test"], "x", ""),
1311            login("impots", "Impôts", "1234567890123", &[], "x", ""),
1312            login("bank-a", "Banque, particuliers", "Élodie", &["https://particuliers.banque.test/connexion"], "x", ""),
1313            login("bank-b", "Banque, pro", "elodie.pro", &["https://pro.banque.test"], "x", ""),
1314            login("credit", "Crédit agricole", "12345678", &["https://www.credit-agricole.fr"], "x", ""),
1315            login("router", "Box", "admin", &["http://192.168.1.1", "androidapp://com.box.admin"], "x", ""),
1316            // Secrets that look like what is searched: never matched.
1317            login("secret", "Coffre", "quelqu-un", &["https://coffre.test"], "gmail ameli Élodie", "GMAILAMELI234567"),
1318        ])
1319    }
1320
1321    /// Every login a search finds, best first.
1322    fn search<'a>(vault: &'a Logins, site: &str, user: &str, page: &str, first: Option<&str>) -> Vec<Found<'a>> {
1323        let (found, total) = vault.find(site, user, page, first, usize::MAX);
1324        assert_eq!(found.len(), total);
1325        found
1326    }
1327
1328    fn ids(found: Vec<Found<'_>>) -> Vec<String> {
1329        found.into_iter().map(|f| f.item.id.clone()).collect()
1330    }
1331
1332    #[test]
1333    fn by_host_then_domain() {
1334        let vault = fixture();
1335        // Opened for a page, the field holding its domain: the page's host first, then its domain.
1336        assert_eq!(ids(search(&vault, "ameli.fr", "", "https://assure.ameli.fr/PortailAS/x", None)), ["assure", "ameli"]);
1337        assert_eq!(ids(search(&vault, "ameli.fr", "", "https://ameli.fr", None)), ["ameli", "assure"]);
1338        let found = search(&vault, "assure.ameli.fr", "", "", None);
1339        assert_eq!(found.iter().map(|f| (f.item.id.as_str(), f.by)).collect::<Vec<_>>(), [("assure", Some(By::Host)), ("ameli", Some(By::Domain))]);
1340        assert_eq!(found[0].site, "assure.ameli.fr");
1341        // A whole address, pasted.
1342        assert_eq!(ids(search(&vault, "https://www.credit-agricole.fr/particulier/acceder.html", "", "", None)), ["credit"]);
1343        // The login chosen last for the page comes first.
1344        assert_eq!(ids(search(&vault, "ameli.fr", "", "https://ameli.fr", Some("assure"))), ["assure", "ameli"]);
1345        // The page's own logins, as "Fill the login" counts them.
1346        let own: Vec<&str> = vault.for_site("https://pro.banque.test/espace").iter().map(|i| i.id.as_str()).collect();
1347        assert_eq!(own, ["bank-b", "bank-a"]);
1348        // An address in numbers is its own domain; an app's address is no site.
1349        assert_eq!(ids(search(&vault, "192.168.1.1", "", "", None)), ["router"]);
1350        assert_eq!(ids(search(&vault, "192.168.1.1:8080/admin", "", "", None)), ["router"], "a port and a path");
1351        assert!(search(&vault, "192.168.0.1", "", "", None).is_empty(), "not the same domain as 192.168.1.1");
1352        assert!(search(&vault, "admin", "", "", None).is_empty(), "androidapp://com.box.admin is no site");
1353        assert_eq!(hosts_of(&vault.items()[10]), ["192.168.1.1"]);
1354    }
1355
1356    #[test]
1357    fn what_a_page_opens_with() {
1358        let vault = fixture();
1359        let opening = |page: &str, last: Option<&str>| {
1360            let o = vault.opening(page, last);
1361            (o.domain, o.chosen, o.only)
1362        };
1363        let some = |id: &str| Some(id.to_string());
1364        // One login for the site: filled at once.
1365        assert_eq!(opening("https://www.credit-agricole.fr/particulier", None), ("credit-agricole.fr".into(), "".into(), some("credit")));
1366        assert_eq!(opening("https://www.credit-agricole.fr/", Some("credit")), ("credit-agricole.fr".into(), "credit-agricole.fr".into(), some("credit")));
1367        // Another login chosen there last (a sign-in with Google): the chooser opens on its domain.
1368        assert_eq!(opening("https://www.credit-agricole.fr/", Some("google")), ("credit-agricole.fr".into(), "google.com".into(), None));
1369        // A login gone from the vault since: as if none was chosen.
1370        assert_eq!(opening("https://www.credit-agricole.fr/", Some("gone")), ("credit-agricole.fr".into(), "".into(), some("credit")));
1371        // Two logins, or none: the chooser.
1372        assert_eq!(opening("https://pro.banque.test/espace", None).2, None);
1373        assert_eq!(opening("https://pro.banque.test/espace", Some("bank-a")), ("banque.test".into(), "banque.test".into(), None));
1374        assert_eq!(opening("https://unknown.test", None), ("unknown.test".into(), "".into(), None));
1375        // A mail server, the provider's login chosen for it before.
1376        assert_eq!(opening("https://imap.gmail.com", Some("google")), ("gmail.com".into(), "google.com".into(), None));
1377        // A login without a site chosen last: no domain to begin with.
1378        assert_eq!(opening("https://www.impots.gouv.fr", Some("impots")), ("impots.gouv.fr".into(), "".into(), None));
1379    }
1380
1381    #[test]
1382    fn by_the_start_of_labels() {
1383        let vault = fixture();
1384        assert_eq!(ids(search(&vault, "ameli", "", "", None)), ["ameli", "assure"], "ameli.fr and assure.ameli.fr, not camelia.com");
1385        let found = search(&vault, "ameli", "", "", None);
1386        assert!(found.iter().all(|f| f.by == Some(By::Label)));
1387        assert_eq!(ids(search(&vault, "cam", "", "", None)), ["camelia"]);
1388        assert_eq!(ids(search(&vault, "agricole", "", "", None)), ["credit"], "a part between hyphens");
1389        assert_eq!(ids(search(&vault, "credit agricole", "", "", None)), ["credit"], "every word");
1390        // Typed on the way to an address.
1391        assert_eq!(ids(search(&vault, "ameli.", "", "", None)), ["ameli", "assure"]);
1392        assert_eq!(ids(search(&vault, "ameli.f", "", "", None)), ["ameli", "assure"]);
1393        assert!(search(&vault, "meli", "", "", None).is_empty());
1394    }
1395
1396    #[test]
1397    fn by_name_when_no_site_matches() {
1398        let vault = fixture();
1399        let found = search(&vault, "impots", "", "", None);
1400        assert_eq!(found.iter().map(|f| (f.item.id.as_str(), f.by, f.site.as_str())).collect::<Vec<_>>(), [("impots", Some(By::Name), "")]);
1401        // An address's own name finds the logins named after it.
1402        assert_eq!(ids(search(&vault, "impots.gouv.fr", "", "https://www.impots.gouv.fr/accueil", None)), ["impots"]);
1403        // Sites before names: "banque" begins a label of both banks, and the name of neither matters then.
1404        let found = search(&vault, "banque", "", "", None);
1405        assert_eq!(found.iter().map(|f| (f.item.id.as_str(), f.by)).collect::<Vec<_>>(), [("bank-a", Some(By::Label)), ("bank-b", Some(By::Label))]);
1406        assert_eq!(ids(search(&vault, "fleurs", "", "", None)), ["camelia"]);
1407        assert!(search(&vault, "leurs", "", "", None).is_empty(), "words begin where the name's words begin");
1408        // An address's name is a whole word of the login's: "com" for com.box, not "Compte assuré".
1409        assert!(search(&vault, "com.box", "", "", None).is_empty());
1410        assert_eq!(ids(search(&vault, "box.example", "", "", None)), ["router"]);
1411    }
1412
1413    #[test]
1414    fn by_user_name_alone() {
1415        let vault = fixture();
1416        assert_eq!(ids(search(&vault, "", "aurore.exemple@gmail.example", "", None)), ["shop", "forum", "google"], "by name: Boutique, Forum de photo, Google");
1417        assert_eq!(ids(search(&vault, "", "aurore", "", None)), ["shop", "forum", "google"]);
1418        assert_eq!(ids(search(&vault, "", "@gmail.example", "", None)), ["shop", "forum", "google"]);
1419        assert_eq!(ids(search(&vault, "", "elodie pro", "", None)), ["bank-b"], "every word");
1420        // The page's own first, then the user name typed whole: an account's server.
1421        assert_eq!(ids(search(&vault, "", "aurore.exemple@gmail.example", "https://forum.photo.test", None)), ["forum", "shop", "google"]);
1422        assert!(search(&vault, "", "", "https://ameli.fr", None).is_empty(), "nothing asked, nothing found");
1423        assert!(search(&vault, "  ", " ", "", None).is_empty());
1424    }
1425
1426    #[test]
1427    fn both_fields_together() {
1428        let vault = fixture();
1429        assert_eq!(ids(search(&vault, "google", "aurore", "", None)), ["google"]);
1430        assert_eq!(ids(search(&vault, "banque.test", "elodie", "", None)), ["bank-a", "bank-b"]);
1431        assert_eq!(ids(search(&vault, "banque.test", "pro", "", None)), ["bank-b"]);
1432        assert!(search(&vault, "ameli", "aurore", "", None).is_empty());
1433        let found = search(&vault, "photo", "@gmail.example", "", None);
1434        assert_eq!(found.iter().map(|f| (f.item.id.as_str(), f.site.as_str())).collect::<Vec<_>>(), [("forum", "forum.photo.test")]);
1435    }
1436
1437    #[test]
1438    fn a_site_is_not_a_user_name() {
1439        let vault = fixture();
1440        // "gmail" as a site: Gmail's own logins, not every login whose user name is a Gmail address.
1441        assert!(search(&vault, "gmail", "", "", None).is_empty());
1442        assert!(search(&vault, "gmail.example", "", "", None).is_empty());
1443        assert_eq!(ids(search(&vault, "google", "", "", None)), ["google"]);
1444        // A user name is no site either.
1445        assert!(search(&vault, "", "ameli", "", None).is_empty());
1446    }
1447
1448    #[test]
1449    fn case_and_accents_aside() {
1450        let vault = fixture();
1451        assert_eq!(ids(search(&vault, "IMPÔTS", "", "", None)), ["impots"]);
1452        assert_eq!(ids(search(&vault, "Crédit", "", "", None)), ["credit"]);
1453        assert_eq!(ids(search(&vault, "AMELI.FR", "", "", None)), ["ameli", "assure"]);
1454        assert_eq!(ids(search(&vault, "", "ELODIE", "", None)), ["bank-a", "bank-b"]);
1455        assert_eq!(ids(search(&vault, "", "élodie.PRO", "", None)), ["bank-b"]);
1456        assert_eq!(ids(search(&vault, "compte assure", "", "", None)), ["assure"]);
1457    }
1458
1459    #[test]
1460    fn secrets_never_match() {
1461        let vault = fixture();
1462        // A password reads "gmail ameli Élodie", its one-time secret "GMAILAMELI…": found by neither.
1463        for (site, user) in [("gmail", ""), ("ameli", ""), ("", "gmail"), ("", "elodie"), ("", "GMAILAMELI"), ("gmailameli", ""), ("ameli.fr", "elodie")] {
1464            assert!(!ids(search(&vault, site, user, "", None)).contains(&"secret".to_string()), "{site:?} {user:?}");
1465        }
1466        // Google's one-time secret.
1467        for (site, user) in [("JBSWY3DP", ""), ("", "JBSWY3DP"), ("", "jbswy3dpehpk3pxp"), ("totp", ""), ("", "otpauth")] {
1468            assert!(search(&vault, site, user, "", None).is_empty(), "{site:?} {user:?}");
1469        }
1470        // Every password is "x": only the user names holding an x.
1471        assert_eq!(ids(search(&vault, "", "x", "", None)), ["shop", "camelia", "forum", "google"]);
1472        assert_eq!(ids(search(&vault, "coffre", "", "", None)), ["secret"], "found by its site");
1473    }
1474
1475    /// How long a search of a large vault takes (20,000 made-up logins), on the
1476    /// window's thread: printed, run by hand with `--ignored --nocapture`.
1477    #[test]
1478    #[ignore]
1479    fn a_large_vault_is_searched_quickly() {
1480        let items: Vec<Item> = (0..20_000)
1481            .map(|n| {
1482                let mut item = Item::default();
1483                item.id = n.to_string();
1484                item.name = format!("Login {n} société");
1485                item.username = format!("person{}@example{}.test", n % 97, n % 13);
1486                item.uris = vec![format!("https://www{}.site{}.example{}.test/login", n % 3, n, n % 7), format!("app{n}.test")];
1487                item
1488            })
1489            .collect();
1490        let started = std::time::Instant::now();
1491        let vault = Logins::new(items);
1492        println!("index: {:?}", started.elapsed());
1493        for (site, user) in [("site123", ""), ("example3.test", ""), ("", "person5"), ("site1", "example1"), ("societe", ""), ("", "@"), ("https://www1.site19999.example6.test/login", "")] {
1494            let started = std::time::Instant::now();
1495            let (found, total) = vault.find(site, user, "https://www1.site42.example0.test", None, 50);
1496            println!("{site:?} {user:?}: {} of {total} in {:?}", found.len(), started.elapsed());
1497        }
1498    }
1499
1500    #[test]
1501    fn the_best_are_kept() {
1502        let vault = fixture();
1503        let (found, total) = vault.find("", "@gmail.example", "", None, 2);
1504        assert_eq!((ids(found), total), (vec!["shop".to_string(), "forum".to_string()], 3));
1505        let (found, total) = vault.find("", "@gmail.example", "", Some("google"), 1);
1506        assert_eq!((ids(found), total), (vec!["google".to_string()], 3), "the login chosen last kept first");
1507        let (found, total) = vault.find("", "@gmail.example", "", None, 0);
1508        assert!(found.is_empty() && total == 3);
1509    }
1510
1511    #[test]
1512    fn the_account_of_a_session() {
1513        let claims = URL_SAFE_NO_PAD.encode(br#"{"email":" Someone@Example.org","premium":false}"#);
1514        let unlocked = Unlocked { access: format!("e30.{claims}.c2ln"), user: Key::from_bytes(&[0u8; 64]).unwrap(), private: None, remember: None };
1515        assert_eq!(account(&unlocked).as_deref(), Some("someone@example.org"));
1516    }
1517
1518    /// Bitwarden's cloud begins a passkey login: options for the browser, for
1519    /// its vault's address, the PIN asked; a token to send back. Run by hand:
1520    /// it asks Bitwarden's servers (no account).
1521    #[test]
1522    #[ignore]
1523    fn the_cloud_asks_a_passkey() {
1524        let ask = passkey_ask(&Server::of("")).unwrap();
1525        let options: Value = serde_json::from_str(&ask.options).unwrap();
1526        assert_eq!(options["rpId"], "vault.bitwarden.com");
1527        assert_eq!(options["userVerification"], "required");
1528        assert!(options["challenge"].as_str().is_some_and(|c| c.len() >= 16));
1529        assert!(ask.token.len() > 32);
1530    }
1531
1532}