Skip to main content

bitwarden_importers/importers/
kdbx.rs

1//! KeePass KDBX (`.kdbx`) parser.
2//!
3//! Parses an encrypted KeePass database (3.1 and 4) via the `keepass` crate and maps its group tree
4//! into a [`ParsedImport`] (ciphers + folder paths + relationships) for the generic submit
5//! pipeline.
6
7use 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
23/// Maximum group nesting that will be traversed
24const MAX_GROUP_DEPTH: usize = 256;
25
26/// KeePass 2.x native TOTP fields and KeePassXC's `otp`
27const 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
38/// The first four bytes of every KDBX file: signature `0x9AA2D903`, little-endian.
39const KDBX_SIGNATURE: [u8; 4] = [0x03, 0xd9, 0xa2, 0x9a];
40
41/// Ceiling on the input file size
42const 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
51/// Parses a KeePass KDBX database, zeroizing the secret inputs (file bytes, password, key file).
52pub(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    // Only treat the UUID as the recycle bin when the feature is enabled; KeePass retains the last
92    // recycle-bin UUID after the feature is turned off, and it may still point at a real group.
93    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
107/// Maps `keepass` open errors to the credential-vs-corrupt distinction the UI surfaces.
108///
109/// A wrong password/key file surfaces as a key or decryption error (KDBX 3.1 has no key HMAC, so it
110/// fails as bad padding). keepass's `CryptographyError` isn't public, so we can't separate that
111/// from genuine corruption — bias both to wrong-credentials; everything else is
112/// corrupt/unsupported.
113fn 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        // Protected strings import as hidden fields (type 1); plain ones as text (type 0).
216        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
243/// Builds the login TOTP value from either KeePassXC's `otp` field or KeePass 2.x's `TimeOtp-*`
244/// fields, returning a Base32 secret for default settings or an otpauth URI otherwise.
245fn build_totp(entry: &keepass::db::EntryRef<'_>) -> Option<String> {
246    if let Some(otp) = entry.get(OTP).filter(|o| !o.trim().is_empty()) {
247        // KeePassXC stores either an `otpauth://` URI or a leading `key=<base32>`. Strip only a
248        // leading `key=` so a `key=` occurring elsewhere in a URI isn't mangled.
249        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
275/// Resolves the KeePass 2.x TOTP secret (in any supported encoding) to a Base32 secret.
276fn 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        // Validate by decoding and re-encoding canonically, so malformed input becomes `None`
282        // rather than a string that merely looks like a secret (matches the other branches).
283        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    /// Encrypts a database to KDBX4 bytes via the `save_kdbx4` test feature.
348    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    /// Builds a KDBX4 database and returns its encrypted bytes.
356    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        // "Hello" encodes to the Base32 secret "JBSWY3DP".
498        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        // Boundary-checked on length so the test doesn't allocate a multi-hundred-MB buffer.
540        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    /// Builds a db whose only group is referenced by `recyclebin_uuid`, with the feature toggled.
548    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        // The feature is off, so the still-present UUID must not cause the group to be dropped.
572        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}