1use 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#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Problem {
37 WrongPassword,
39 SecondFactor(Vec<u8>, Option<String>),
44 NewDevice,
46 NoKeySecret,
48 PasskeyNotForVault,
50 Network(String),
52 Refused(String),
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Server {
59 pub identity: String,
60 pub api: String,
61 pub vault: String,
63}
64
65impl Server {
66 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum Kdf {
84 Pbkdf2 { iterations: u32 },
85 Argon2id { iterations: u32, memory_mib: u32, parallelism: u32 },
86}
87
88fn 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
107pub 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(¶llelism) {
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
134pub fn password_hash(master: &[u8; 32], password: &str) -> String {
136 let hash = pbkdf2(master, password.as_bytes(), 1);
137 STANDARD.encode(&hash[..])
138}
139
140pub struct Key {
142 enc: Zeroizing<Vec<u8>>,
143 mac: Zeroizing<Vec<u8>>,
144 cose: Option<i64>,
146}
147
148const XCHACHA20_POLY1305: i64 = -70000;
150const XAES_256_GCM: i64 = -70010;
151const A256GCM: i64 = 3;
152
153fn 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 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 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
192fn 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
198pub 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 (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 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
225fn 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 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
271pub 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
283fn 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
288fn 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
297fn 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
303const CLIENT_VERSION: &str = "2026.9.0";
308
309fn 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
315fn 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
350fn 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
363fn 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
377pub struct Unlocked {
379 access: String,
380 user: Key,
381 private: Option<RsaPrivateKey>,
382 pub remember: Option<String>,
384}
385
386pub 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 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 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 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 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
444fn 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
462fn 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
481fn 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
494pub 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
501pub struct PasskeyAsk {
504 pub options: String,
505 pub token: String,
506}
507
508pub 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
525pub fn passkey_salt() -> [u8; 32] {
528 Sha256::digest(b"passwordless-login").into()
529}
530
531pub fn key_page(server: &Server) -> String {
536 format!("{}/webauthn-connector.html", server.vault)
537}
538
539pub 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
551pub 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
576fn 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
586pub 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#[derive(Clone, Default, PartialEq, Eq)]
613pub struct Item {
614 pub id: String,
616 pub name: String,
617 pub username: String,
618 pub password: String,
619 pub uris: Vec<String>,
620 pub totp: String,
622}
623
624impl 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
631impl Drop for Item {
633 fn drop(&mut self) {
634 use zeroize::Zeroize;
635 self.password.zeroize();
636 self.totp.zeroize();
637 }
638}
639
640pub 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
654pub 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 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 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 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
709pub 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
727fn 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
735pub fn domain_of_address(address: &str) -> String {
738 domain(&sioul_core::sites::host_of(address.trim()))
739}
740
741fn folded(text: &str) -> String {
743 text.chars().map(sioul_core::text::fold_char).collect()
744}
745
746fn 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
753fn 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
758fn 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
763struct Searched {
767 name: String,
768 user: String,
769 hosts: Vec<String>,
770 domains: Vec<String>,
771 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 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
798struct 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
813pub enum By {
814 Host,
816 Domain,
818 Label,
820 Name,
822}
823
824enum SiteAsked {
827 Address {
828 host: String,
829 domain: String,
830 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 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 fn matches(&self, login: &Searched, page: &Page) -> Option<(By, Option<usize>)> {
858 match self {
859 SiteAsked::Address { host, domain, name } => {
860 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
883struct Hit {
887 key: (bool, Option<By>, u8, bool),
888 login: usize,
889 by: Option<By>,
890 site: usize,
891}
892
893pub struct Found<'a> {
895 pub item: &'a Item,
896 pub by: Option<By>,
898 pub site: String,
900 pub own: bool,
902}
903
904#[derive(Debug, PartialEq, Eq)]
906pub struct Opening {
907 pub domain: String,
909 pub chosen: String,
913 pub only: Option<String>,
916}
917
918pub 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 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 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 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
1020fn 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
1041pub 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(¶m("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 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 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 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 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 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 #[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 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 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 #[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 assert_eq!(user_key_of_passkey(&[1u8; 32], &option).err(), Some(Problem::WrongPassword));
1271 assert_eq!(user_key_of_passkey(&secret, &serde_json::json!({})).err(), Some(Problem::PasskeyNotForVault));
1273 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 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 fn fixture() -> Logins {
1293 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 login("secret", "Coffre", "quelqu-un", &["https://coffre.test"], "gmail ameli Élodie", "GMAILAMELI234567"),
1318 ])
1319 }
1320
1321 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 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 assert_eq!(ids(search(&vault, "https://www.credit-agricole.fr/particulier/acceder.html", "", "", None)), ["credit"]);
1343 assert_eq!(ids(search(&vault, "ameli.fr", "", "https://ameli.fr", Some("assure"))), ["assure", "ameli"]);
1345 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 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 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 assert_eq!(opening("https://www.credit-agricole.fr/", Some("google")), ("credit-agricole.fr".into(), "google.com".into(), None));
1369 assert_eq!(opening("https://www.credit-agricole.fr/", Some("gone")), ("credit-agricole.fr".into(), "".into(), some("credit")));
1371 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 assert_eq!(opening("https://imap.gmail.com", Some("google")), ("gmail.com".into(), "google.com".into(), None));
1377 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 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 assert_eq!(ids(search(&vault, "impots.gouv.fr", "", "https://www.impots.gouv.fr/accueil", None)), ["impots"]);
1403 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 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 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 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 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 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 for (site, user) in [("JBSWY3DP", ""), ("", "JBSWY3DP"), ("", "jbswy3dpehpk3pxp"), ("totp", ""), ("", "otpauth")] {
1468 assert!(search(&vault, site, user, "", None).is_empty(), "{site:?} {user:?}");
1469 }
1470 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 #[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 #[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}