diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index eaf5857ed2..bc2459a6f0 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -29,13 +29,64 @@ use crate::{ * Parse CXF payload in the format compatible with Apple (At the Account-level) */ pub(crate) fn parse_cxf(payload: String) -> Result, CxfError> { - let account: CxfAccount = serde_json::from_str(&payload)?; + let sanitized = sanitize_timestamps(&payload); + let account: CxfAccount = serde_json::from_str(&sanitized)?; let items: Vec = account.items.into_iter().flat_map(parse_item).collect(); Ok(items) } +/// Replace negative `creationAt` and `modifiedAt` values with 0. +/// +/// Some credential managers (e.g., Google Password Manager) export timestamps +/// as the Windows FILETIME epoch (-11644473600) when no real date exists. The +/// `credential-exchange-format` crate deserializes these fields as `u64` and +/// cannot handle negative values. +pub(crate) fn sanitize_timestamps(payload: &str) -> std::borrow::Cow<'_, str> { + let Ok(mut value) = serde_json::from_str::(payload) else { + return std::borrow::Cow::Borrowed(payload); + }; + + let mut modified = false; + + if let Some(items) = value.get_mut("items").and_then(|v| v.as_array_mut()) { + for item in items { + clamp_timestamps(item, &mut modified); + } + } + if let Some(collections) = value.get_mut("collections").and_then(|v| v.as_array_mut()) { + for collection in collections { + clamp_collection_timestamps(collection, &mut modified); + } + } + + if !modified { + return std::borrow::Cow::Borrowed(payload); + } + serde_json::to_string(&value) + .map(std::borrow::Cow::Owned) + .unwrap_or(std::borrow::Cow::Borrowed(payload)) +} + +fn clamp_timestamps(item: &mut serde_json::Value, modified: &mut bool) { + for key in ["creationAt", "modifiedAt"] { + if item.get(key).and_then(|v| v.as_i64()).is_some_and(|n| n < 0) { + item[key] = serde_json::Value::Null; + *modified = true; + } + } +} + +fn clamp_collection_timestamps(collection: &mut serde_json::Value, modified: &mut bool) { + clamp_timestamps(collection, modified); + if let Some(subs) = collection.get_mut("subCollections").and_then(|v| v.as_array_mut()) { + for sub in subs { + clamp_collection_timestamps(sub, modified); + } + } +} + /// Convert a CXF timestamp to a [`DateTime`]. /// /// If the timestamp is None, the current time is used. diff --git a/crates/bitwarden-exporters/src/cxf/tests/mod.rs b/crates/bitwarden-exporters/src/cxf/tests/mod.rs index baacac0f94..180cd1c26d 100644 --- a/crates/bitwarden-exporters/src/cxf/tests/mod.rs +++ b/crates/bitwarden-exporters/src/cxf/tests/mod.rs @@ -1,3 +1,4 @@ mod dashlane_import_test; +mod negative_timestamp_test; mod one_password_import_test; mod sample_import_test; diff --git a/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs b/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs new file mode 100644 index 0000000000..0ae61979d5 --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs @@ -0,0 +1,140 @@ +//! Tests for handling negative timestamps in CXF import. +//! +//! Some credential managers (e.g., Google Password Manager) export timestamps +//! as the Windows FILETIME epoch (-11644473600) when no real date exists. + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use crate::cxf::import::{parse_cxf, sanitize_timestamps}; + + #[test] + fn test_sanitize_negative_creation_at() { + let input = r#"{"id":"test","items":[{"id":"1","creationAt":-11644473600,"modifiedAt":1759783057,"title":"Test","credentials":[]}]}"#; + let result = sanitize_timestamps(input); + assert!(result.contains(r#""creationAt":null"#)); + assert!(result.contains(r#""modifiedAt":1759783057"#)); + } + + #[test] + fn test_sanitize_negative_modified_at() { + let input = r#"{"id":"test","items":[{"id":"1","creationAt":1759783057,"modifiedAt":-11644473600,"title":"Test","credentials":[]}]}"#; + let result = sanitize_timestamps(input); + assert!(result.contains(r#""creationAt":1759783057"#)); + assert!(result.contains(r#""modifiedAt":null"#)); + } + + #[test] + fn test_sanitize_both_negative() { + let input = r#"{"id":"test","items":[{"id":"1","creationAt":-11644473600,"modifiedAt":-11644473600,"title":"Test","credentials":[]}]}"#; + let result = sanitize_timestamps(input); + assert!(result.contains(r#""creationAt":null"#)); + assert!(result.contains(r#""modifiedAt":null"#)); + } + + #[test] + fn test_sanitize_valid_timestamps_unchanged() { + let input = r#"{"id":"test","items":[{"id":"1","creationAt":1759783057,"modifiedAt":1759783057,"title":"Test","credentials":[]}]}"#; + let result = sanitize_timestamps(input); + assert!(result.contains(r#""creationAt":1759783057"#)); + assert!(result.contains(r#""modifiedAt":1759783057"#)); + } + + #[test] + fn test_sanitize_no_modification_returns_original() { + let input = r#"{"id":"test","items":[{"id":"1","creationAt":1759783057,"modifiedAt":1759783057,"title":"Test","credentials":[]}]}"#; + let result = sanitize_timestamps(input); + assert_eq!(result.as_ref(), input); + } + + #[test] + fn test_sanitize_negative_timestamps_in_collections() { + let input = r#"{"id":"test","items":[],"collections":[{"id":"1","creationAt":-11644473600,"modifiedAt":-11644473600,"title":"Test Collection"}]}"#; + let result = sanitize_timestamps(input); + assert!(result.contains(r#""creationAt":null"#)); + assert!(result.contains(r#""modifiedAt":null"#)); + } + + #[test] + fn test_sanitize_negative_timestamps_in_sub_collections() { + let input = r#"{"id":"test","items":[],"collections":[{"id":"1","creationAt":1759783057,"modifiedAt":1759783057,"title":"Parent","subCollections":[{"id":"2","creationAt":-11644473600,"modifiedAt":-11644473600,"title":"Child"}]}]}"#; + let result = sanitize_timestamps(input); + // Parent timestamps should be unchanged + assert!(result.contains(r#""creationAt":1759783057"#)); + // Child timestamps should be nulled + assert!(result.contains(r#""creationAt":null"#)); + assert!(result.contains(r#""modifiedAt":null"#)); + } + + #[test] + fn test_parse_cxf_with_negative_timestamps_does_not_error() { + let input = r#"{ + "id": "DZSXp7iBQY-Fg-OofakQtQ", + "username": "user@example.com", + "email": "user@example.com", + "fullName": "Test User", + "collections": [], + "items": [{ + "id": "9OF-QjVDQo2Wp2xWPw6ZhA", + "creationAt": -11644473600, + "modifiedAt": -11644473600, + "title": "Test Entry", + "credentials": [{ + "type": "basic-auth", + "username": { + "id": "-eZX0Gw-TzOsBFwt67N7ZA", + "fieldType": "string", + "value": "testuser" + }, + "password": { + "id": "wgu3wTcXSYawrGMWMtaANg", + "fieldType": "concealed-string", + "value": "testpass" + }, + "urls": ["https://example.com"] + }] + }] + }"#; + let result = parse_cxf(input.to_string()); + assert!(result.is_ok(), "parse_cxf should not error on negative timestamps: {:?}", result.err()); + } + + #[test] + fn test_parse_cxf_negative_timestamps_fallback_to_current_time() { + let input = r#"{ + "id": "DZSXp7iBQY-Fg-OofakQtQ", + "username": "user@example.com", + "email": "user@example.com", + "fullName": "Test User", + "collections": [], + "items": [{ + "id": "9OF-QjVDQo2Wp2xWPw6ZhA", + "creationAt": -11644473600, + "modifiedAt": -11644473600, + "title": "Test Entry", + "credentials": [{ + "type": "basic-auth", + "username": { + "id": "-eZX0Gw-TzOsBFwt67N7ZA", + "fieldType": "string", + "value": "testuser" + }, + "password": { + "id": "wgu3wTcXSYawrGMWMtaANg", + "fieldType": "concealed-string", + "value": "testpass" + }, + "urls": ["https://example.com"] + }] + }] + }"#; + let result = parse_cxf(input.to_string()).unwrap(); + + // When timestamps are negative (clamped to null), convert_date falls + // back to Utc::now(). Verify the resulting dates are approximately now. + let cipher = &result[0]; + assert!(cipher.creation_date > Utc::now() - chrono::Duration::seconds(5)); + assert!(cipher.revision_date > Utc::now() - chrono::Duration::seconds(5)); + } +}