bitwarden_importers/importers/
kdbx.rs1use std::io::Cursor;
8
9use bitwarden_exporters::{CipherType, Field, ImportingCipher, Login, LoginUri};
10use chrono::Utc;
11use keepass::{
12 Database, DatabaseKey,
13 db::{
14 DatabaseOpenError,
15 fields::{NOTES, OTP, PASSWORD, TITLE, URL, USERNAME},
16 },
17};
18use uuid::Uuid;
19use zeroize::Zeroizing;
20
21use crate::{ImportError, pipeline::ParsedImport};
22
23const MAX_GROUP_DEPTH: usize = 256;
25
26const TOTP_FIELD_KEYS: &[&str] = &[
28 OTP,
29 "TimeOtp-Secret",
30 "TimeOtp-Secret-Hex",
31 "TimeOtp-Secret-Base32",
32 "TimeOtp-Secret-Base64",
33 "TimeOtp-Period",
34 "TimeOtp-Length",
35 "TimeOtp-Algorithm",
36];
37
38const KDBX_SIGNATURE: [u8; 4] = [0x03, 0xd9, 0xa2, 0x9a];
40
41const MAX_KDBX_SIZE: usize = 10 * 1024 * 1024;
43
44fn check_kdbx_size(len: usize) -> Result<(), ImportError> {
45 if len > MAX_KDBX_SIZE {
46 return Err(ImportError::KdbxFileTooLarge);
47 }
48 Ok(())
49}
50
51pub(crate) fn parse(
53 file: Vec<u8>,
54 password: Option<String>,
55 key_file: Option<Vec<u8>>,
56) -> Result<ParsedImport, ImportError> {
57 let file = Zeroizing::new(file);
58 let password = password.map(Zeroizing::new);
59 let key_file = key_file.map(Zeroizing::new);
60
61 parse_kdbx(
62 &file,
63 password.as_ref().map(|p| p.as_str()),
64 key_file.as_ref().map(|k| k.as_slice()),
65 )
66}
67
68fn parse_kdbx(
69 data: &[u8],
70 password: Option<&str>,
71 key_file: Option<&[u8]>,
72) -> Result<ParsedImport, ImportError> {
73 check_kdbx_size(data.len())?;
74
75 if data.len() < KDBX_SIGNATURE.len() || data[..KDBX_SIGNATURE.len()] != KDBX_SIGNATURE {
76 return Err(ImportError::KdbxInvalidFormat);
77 }
78
79 let mut key = DatabaseKey::new();
80 if let Some(password) = password {
81 key = key.with_password(password);
82 }
83 if let Some(key_file) = key_file {
84 key = key
85 .with_keyfile(&mut Cursor::new(key_file))
86 .map_err(|_| ImportError::KdbxCorruptOrUnsupported)?;
87 }
88
89 let db = Database::open(&mut Cursor::new(data), key).map_err(map_open_error)?;
90
91 let recycle_bin = if db.meta.recyclebin_enabled == Some(true) {
94 db.meta.recyclebin_uuid
95 } else {
96 None
97 };
98 let mut result = ParsedImport {
99 ciphers: Vec::new(),
100 folders: Vec::new(),
101 folder_relationships: Vec::new(),
102 };
103 traverse(&db.root(), true, "", recycle_bin, 0, &mut result)?;
104 Ok(result)
105}
106
107fn map_open_error(error: DatabaseOpenError) -> ImportError {
114 match error {
115 DatabaseOpenError::Key(_) | DatabaseOpenError::Cryptography(_) => {
116 ImportError::KdbxWrongCredentials
117 }
118 _ => ImportError::KdbxCorruptOrUnsupported,
119 }
120}
121
122fn traverse(
123 group: &keepass::db::GroupRef<'_>,
124 is_root: bool,
125 prefix: &str,
126 recycle_bin: Option<Uuid>,
127 depth: usize,
128 result: &mut ParsedImport,
129) -> Result<(), ImportError> {
130 if depth > MAX_GROUP_DEPTH {
131 return Err(ImportError::KdbxCorruptOrUnsupported);
132 }
133
134 if let Some(recycle_bin) = recycle_bin
135 && group.id().uuid() == recycle_bin
136 {
137 return Ok(());
138 }
139
140 let folder_index = result.folders.len();
141 let mut group_name = prefix.to_string();
142 if !is_root {
143 if !group_name.is_empty() {
144 group_name.push('/');
145 }
146 group_name.push_str(if group.name.trim().is_empty() {
147 "-"
148 } else {
149 &group.name
150 });
151 result.folders.push(group_name.clone());
152 }
153
154 for entry in group.entries() {
155 let cipher_index = result.ciphers.len();
156 result.ciphers.push(map_entry(&entry));
157 if !is_root {
158 result
159 .folder_relationships
160 .push((cipher_index, folder_index));
161 }
162 }
163
164 for subgroup in group.groups() {
165 traverse(
166 &subgroup,
167 false,
168 &group_name,
169 recycle_bin,
170 depth + 1,
171 result,
172 )?;
173 }
174
175 Ok(())
176}
177
178fn map_entry(entry: &keepass::db::EntryRef<'_>) -> ImportingCipher {
179 let totp = build_totp(entry);
180
181 let uris = match entry.get_url().filter(|u| !u.trim().is_empty()) {
182 Some(url) => vec![LoginUri {
183 uri: Some(url.to_string()),
184 r#match: None,
185 }],
186 None => vec![],
187 };
188
189 let mut login = Login {
190 username: non_empty(entry.get_username()),
191 password: non_empty(entry.get_password()),
192 login_uris: uris,
193 totp,
194 fido2_credentials: None,
195 };
196
197 login.sanitize_uris();
198
199 let notes = entry
200 .get(NOTES)
201 .filter(|n| !n.trim().is_empty())
202 .map(str::to_string);
203
204 let mut fields = Vec::new();
205 for (key, value) in &entry.fields {
206 if TOTP_FIELD_KEYS.contains(&key.as_str())
207 || [TITLE, USERNAME, PASSWORD, URL, NOTES].contains(&key.as_str())
208 {
209 continue;
210 }
211 let text = value.get();
212 if text.trim().is_empty() {
213 continue;
214 }
215 fields.push(Field {
217 name: Some(key.clone()),
218 value: Some(text.clone()),
219 r#type: if value.is_protected() { 1 } else { 0 },
220 linked_id: None,
221 });
222 }
223
224 let now = Utc::now();
225 ImportingCipher {
226 folder_id: None,
227 name: entry
228 .get_title()
229 .filter(|t| !t.trim().is_empty())
230 .unwrap_or("--")
231 .to_string(),
232 notes,
233 r#type: CipherType::Login(Box::new(login)),
234 favorite: false,
235 reprompt: 0,
236 fields,
237 revision_date: now,
238 creation_date: now,
239 deleted_date: None,
240 }
241}
242
243fn build_totp(entry: &keepass::db::EntryRef<'_>) -> Option<String> {
246 if let Some(otp) = entry.get(OTP).filter(|o| !o.trim().is_empty()) {
247 return Some(otp.strip_prefix("key=").unwrap_or(otp).to_string());
250 }
251
252 let secret = time_otp_secret_as_base32(entry)?;
253
254 let period = non_default(entry.get("TimeOtp-Period"), "30");
255 let digits = non_default(entry.get("TimeOtp-Length"), "6");
256 let algorithm = totp_algorithm(entry.get("TimeOtp-Algorithm"));
257
258 if period.is_none() && digits.is_none() && algorithm.is_none() {
259 return Some(secret);
260 }
261
262 let mut query = format!("secret={secret}");
263 if let Some(algorithm) = algorithm {
264 query.push_str(&format!("&algorithm={algorithm}"));
265 }
266 if let Some(digits) = digits {
267 query.push_str(&format!("&digits={digits}"));
268 }
269 if let Some(period) = period {
270 query.push_str(&format!("&period={period}"));
271 }
272 Some(format!("otpauth://totp/Imported?{query}"))
273}
274
275fn time_otp_secret_as_base32(entry: &keepass::db::EntryRef<'_>) -> Option<String> {
277 if let Some(base32) = entry
278 .get("TimeOtp-Secret-Base32")
279 .filter(|s| !s.trim().is_empty())
280 {
281 let normalized: String = base32
284 .chars()
285 .filter(|c| !c.is_whitespace() && *c != '=')
286 .collect::<String>()
287 .to_uppercase();
288 let bytes = data_encoding::BASE32_NOPAD
289 .decode(normalized.as_bytes())
290 .ok()?;
291 return Some(data_encoding::BASE32_NOPAD.encode(&bytes));
292 }
293 if let Some(base64) = entry
294 .get("TimeOtp-Secret-Base64")
295 .filter(|s| !s.trim().is_empty())
296 {
297 let bytes = data_encoding::BASE64
298 .decode(base64.trim().as_bytes())
299 .ok()?;
300 return Some(data_encoding::BASE32_NOPAD.encode(&bytes));
301 }
302 if let Some(hex) = entry
303 .get("TimeOtp-Secret-Hex")
304 .filter(|s| !s.trim().is_empty())
305 {
306 let bytes = data_encoding::HEXLOWER_PERMISSIVE
307 .decode(hex.trim().as_bytes())
308 .ok()?;
309 return Some(data_encoding::BASE32_NOPAD.encode(&bytes));
310 }
311 if let Some(utf8) = entry.get("TimeOtp-Secret").filter(|s| !s.trim().is_empty()) {
312 return Some(data_encoding::BASE32_NOPAD.encode(utf8.as_bytes()));
313 }
314 None
315}
316
317fn totp_algorithm(value: Option<&str>) -> Option<String> {
318 match value.map(|v| v.trim().to_uppercase()).as_deref() {
319 Some("HMAC-SHA-256") => Some("SHA256".to_string()),
320 Some("HMAC-SHA-512") => Some("SHA512".to_string()),
321 _ => None,
322 }
323}
324
325fn non_default(value: Option<&str>, default: &str) -> Option<String> {
326 let trimmed = value?.trim();
327 if trimmed.is_empty() || trimmed == default {
328 None
329 } else {
330 Some(trimmed.to_string())
331 }
332}
333
334fn non_empty(value: Option<&str>) -> Option<String> {
335 value.filter(|v| !v.trim().is_empty()).map(str::to_string)
336}
337
338#[cfg(test)]
339#[allow(clippy::unwrap_used)]
340mod tests {
341 use keepass::db::{GroupMut, fields};
342
343 use super::*;
344
345 const PASSWORD: &str = "test-password";
346
347 fn save(db: &Database) -> Vec<u8> {
349 let mut bytes = Vec::new();
350 db.save(&mut bytes, DatabaseKey::new().with_password(PASSWORD))
351 .unwrap();
352 bytes
353 }
354
355 fn build_db(build: impl FnOnce(&mut GroupMut<'_>)) -> Vec<u8> {
357 let mut db = Database::new();
358 {
359 let mut root = db.root_mut();
360 build(&mut root);
361 }
362 save(&db)
363 }
364
365 fn parse_bytes(bytes: &[u8]) -> ParsedImport {
366 parse_kdbx(bytes, Some(PASSWORD), None).unwrap()
367 }
368
369 fn login(cipher: &ImportingCipher) -> &Login {
370 match &cipher.r#type {
371 CipherType::Login(login) => login,
372 _ => panic!("expected login"),
373 }
374 }
375
376 #[test]
377 fn maps_standard_fields_and_group_to_folder() {
378 let bytes = build_db(|root| {
379 let mut group = root.add_group();
380 group.name = "Social".into();
381 let mut entry = group.add_entry();
382 entry.set_unprotected(fields::TITLE, "GitHub");
383 entry.set_unprotected(fields::USERNAME, "octocat");
384 entry.set_protected(fields::PASSWORD, "hunter2");
385 entry.set_unprotected(fields::URL, "https://github.com");
386 entry.set_unprotected(fields::NOTES, "my note");
387 entry.set_unprotected(fields::OTP, "JBSWY3DPEHPK3PXP");
388 });
389
390 let result = parse_bytes(&bytes);
391
392 assert_eq!(result.ciphers.len(), 1);
393 let cipher = &result.ciphers[0];
394 assert_eq!(cipher.name, "GitHub");
395 assert_eq!(cipher.notes.as_deref(), Some("my note"));
396 let login = login(cipher);
397 assert_eq!(login.username.as_deref(), Some("octocat"));
398 assert_eq!(login.password.as_deref(), Some("hunter2"));
399 assert_eq!(
400 login.login_uris[0].uri.as_deref(),
401 Some("https://github.com")
402 );
403 assert_eq!(login.totp.as_deref(), Some("JBSWY3DPEHPK3PXP"));
404
405 assert_eq!(result.folders, vec!["Social".to_string()]);
406 assert_eq!(result.folder_relationships, vec![(0, 0)]);
407 }
408
409 #[test]
410 fn nested_groups_become_folder_paths() {
411 let bytes = build_db(|root| {
412 let mut parent = root.add_group();
413 parent.name = "Parent".into();
414 let mut child = parent.add_group();
415 child.name = "Child".into();
416 child.add_entry().set_unprotected(fields::TITLE, "Nested");
417 });
418
419 let result = parse_bytes(&bytes);
420
421 assert!(result.folders.contains(&"Parent".to_string()));
422 assert!(result.folders.contains(&"Parent/Child".to_string()));
423 let child_index = result
424 .folders
425 .iter()
426 .position(|f| f == "Parent/Child")
427 .unwrap();
428 assert_eq!(result.folder_relationships, vec![(0, child_index)]);
429 }
430
431 #[test]
432 fn protected_strings_are_hidden_fields_plain_are_text() {
433 let bytes = build_db(|root| {
434 let mut entry = root.add_entry();
435 entry.set_unprotected(fields::TITLE, "Custom");
436 entry.set_unprotected("PlainField", "plain value");
437 entry.set_protected("SecretField", "secret value");
438 });
439
440 let cipher = &parse_bytes(&bytes).ciphers[0];
441 let plain = cipher
442 .fields
443 .iter()
444 .find(|f| f.name.as_deref() == Some("PlainField"))
445 .unwrap();
446 let secret = cipher
447 .fields
448 .iter()
449 .find(|f| f.name.as_deref() == Some("SecretField"))
450 .unwrap();
451 assert_eq!(plain.r#type, 0);
452 assert_eq!(plain.value.as_deref(), Some("plain value"));
453 assert_eq!(secret.r#type, 1);
454 assert_eq!(secret.value.as_deref(), Some("secret value"));
455 }
456
457 #[test]
458 fn time_otp_base32_maps_totp() {
459 let bytes = build_db(|root| {
460 let mut entry = root.add_entry();
461 entry.set_unprotected(fields::TITLE, "Entry with OTP");
462 entry.set_protected("TimeOtp-Secret-Base32", "JBSWY3DPEHPK3PXP");
463 });
464
465 let cipher = &parse_bytes(&bytes).ciphers[0];
466 assert_eq!(login(cipher).totp.as_deref(), Some("JBSWY3DPEHPK3PXP"));
467 assert!(
468 !cipher
469 .fields
470 .iter()
471 .any(|f| f.name.as_deref() == Some("TimeOtp-Secret-Base32"))
472 );
473 }
474
475 #[test]
476 fn time_otp_non_default_settings_build_otpauth_uri() {
477 let bytes = build_db(|root| {
478 let mut entry = root.add_entry();
479 entry.set_unprotected(fields::TITLE, "Custom OTP");
480 entry.set_protected("TimeOtp-Secret-Base32", "JBSWY3DPEHPK3PXP");
481 entry.set_unprotected("TimeOtp-Period", "60");
482 entry.set_unprotected("TimeOtp-Length", "8");
483 entry.set_unprotected("TimeOtp-Algorithm", "HMAC-SHA-256");
484 });
485
486 let cipher = &parse_bytes(&bytes).ciphers[0];
487 let totp = login(cipher).totp.as_deref().unwrap();
488 assert!(totp.starts_with("otpauth://totp/"));
489 assert!(totp.contains("secret=JBSWY3DPEHPK3PXP"));
490 assert!(totp.contains("period=60"));
491 assert!(totp.contains("digits=8"));
492 assert!(totp.contains("algorithm=SHA256"));
493 }
494
495 #[test]
496 fn time_otp_secret_encodings_convert_to_base32() {
497 for (field, value) in [
499 ("TimeOtp-Secret-Base64", "SGVsbG8="),
500 ("TimeOtp-Secret-Hex", "48656c6c6f"),
501 ("TimeOtp-Secret", "Hello"),
502 ] {
503 let bytes = build_db(|root| {
504 let mut entry = root.add_entry();
505 entry.set_unprotected(fields::TITLE, "Encoded OTP");
506 entry.set_protected(field, value);
507 });
508 let cipher = &parse_bytes(&bytes).ciphers[0];
509 assert_eq!(
510 login(cipher).totp.as_deref(),
511 Some("JBSWY3DP"),
512 "field {field}"
513 );
514 }
515 }
516
517 #[test]
518 fn wrong_password_is_wrong_credentials() {
519 let bytes = build_db(|root| {
520 root.add_entry().set_unprotected(fields::TITLE, "Secret");
521 });
522
523 assert!(matches!(
524 parse_kdbx(&bytes, Some("incorrect"), None),
525 Err(ImportError::KdbxWrongCredentials)
526 ));
527 }
528
529 #[test]
530 fn non_kdbx_input_is_invalid_format() {
531 assert!(matches!(
532 parse_kdbx(b"not a kdbx file", Some(PASSWORD), None),
533 Err(ImportError::KdbxInvalidFormat)
534 ));
535 }
536
537 #[test]
538 fn input_over_size_limit_is_rejected() {
539 assert!(check_kdbx_size(MAX_KDBX_SIZE).is_ok());
541 assert!(matches!(
542 check_kdbx_size(MAX_KDBX_SIZE + 1),
543 Err(ImportError::KdbxFileTooLarge)
544 ));
545 }
546
547 fn db_with_recycle_bin(enabled: bool) -> Vec<u8> {
549 let mut db = Database::new();
550 {
551 let mut root = db.root_mut();
552 let mut group = root.add_group();
553 group.name = "Trash".into();
554 group.add_entry().set_unprotected(fields::TITLE, "in trash");
555 }
556 let group_id = db.root().groups().next().unwrap().id().uuid();
557 db.meta.recyclebin_enabled = Some(enabled);
558 db.meta.recyclebin_uuid = Some(group_id);
559 save(&db)
560 }
561
562 #[test]
563 fn recycle_bin_group_is_skipped_when_enabled() {
564 let result = parse_bytes(&db_with_recycle_bin(true));
565 assert!(result.ciphers.is_empty());
566 assert!(result.folders.is_empty());
567 }
568
569 #[test]
570 fn recycle_bin_uuid_is_ignored_when_disabled() {
571 let result = parse_bytes(&db_with_recycle_bin(false));
573 assert_eq!(result.folders, vec!["Trash".to_string()]);
574 assert_eq!(result.ciphers.len(), 1);
575 assert_eq!(result.ciphers[0].name, "in trash");
576 }
577}