From bc066364e4ab5083cb0f267f81bf1381b3e98cf7 Mon Sep 17 00:00:00 2001 From: Chris Rosenthal Date: Wed, 5 Aug 2026 16:02:29 -0700 Subject: [PATCH 1/4] fix: CXF import crash on negative timestamps Some credential managers (e.g., Google Password Manager) export creationAt/modifiedAt as the Windows FILETIME epoch (-11644473600) when no real date exists. The credential-exchange-format crate deserializes these fields as u64 and rejects negative values. Sanitize the JSON payload before deserialization by clamping negative timestamp values to 0. --- crates/bitwarden-exporters/src/cxf/import.rs | 30 +++++++- .../bitwarden-exporters/src/cxf/tests/mod.rs | 1 + .../src/cxf/tests/negative_timestamp_test.rs | 74 +++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index eaf5857ed2..f35c4966a9 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -29,13 +29,41 @@ 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) -> String { + let mut value: serde_json::Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(_) => return payload.to_string(), + }; + + if let Some(items) = value.get_mut("items").and_then(|v| v.as_array_mut()) { + for item in items { + for key in &["creationAt", "modifiedAt"] { + if let Some(n) = item.get(key).and_then(|v| v.as_i64()) { + if n < 0 { + item[key] = serde_json::Value::Number(0.into()); + } + } + } + } + } + + serde_json::to_string(&value).unwrap_or_else(|_| payload.to_string()) +} + /// 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..1c086707c2 --- /dev/null +++ b/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs @@ -0,0 +1,74 @@ +//! 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 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":0"#)); + 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":0"#)); + } + + #[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":0"#)); + assert!(result.contains(r#""modifiedAt":0"#)); + } + + #[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_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()); + } +} From 8806755865db1c987d0929bd94c11708540d17e1 Mon Sep 17 00:00:00 2001 From: Chris Rosenthal Date: Sat, 8 Aug 2026 15:27:40 -0700 Subject: [PATCH 2/4] Update crates/bitwarden-exporters/src/cxf/import.rs Co-authored-by: John Harrington <84741727+harr1424@users.noreply.github.com> --- crates/bitwarden-exporters/src/cxf/import.rs | 47 +++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/import.rs b/crates/bitwarden-exporters/src/cxf/import.rs index f35c4966a9..bc2459a6f0 100644 --- a/crates/bitwarden-exporters/src/cxf/import.rs +++ b/crates/bitwarden-exporters/src/cxf/import.rs @@ -43,25 +43,48 @@ pub(crate) fn parse_cxf(payload: String) -> Result, CxfErro /// 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) -> String { - let mut value: serde_json::Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(_) => return payload.to_string(), +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 { - for key in &["creationAt", "modifiedAt"] { - if let Some(n) = item.get(key).and_then(|v| v.as_i64()) { - if n < 0 { - item[key] = serde_json::Value::Number(0.into()); - } - } - } + 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; } } +} - serde_json::to_string(&value).unwrap_or_else(|_| payload.to_string()) +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`]. From 0e7e4c1749a858711338102da9906daffac40546 Mon Sep 17 00:00:00 2001 From: Chris Rosenthal Date: Sat, 8 Aug 2026 15:33:32 -0700 Subject: [PATCH 3/4] Update tests to match review suggestions - Assert null instead of 0 for clamped timestamps - Add test verifying unmodified input returns borrowed reference --- .../src/cxf/tests/negative_timestamp_test.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs b/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs index 1c086707c2..35923303bd 100644 --- a/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs +++ b/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs @@ -11,7 +11,7 @@ mod tests { 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":0"#)); + assert!(result.contains(r#""creationAt":null"#)); assert!(result.contains(r#""modifiedAt":1759783057"#)); } @@ -20,15 +20,15 @@ mod tests { 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":0"#)); + 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":0"#)); - assert!(result.contains(r#""modifiedAt":0"#)); + assert!(result.contains(r#""creationAt":null"#)); + assert!(result.contains(r#""modifiedAt":null"#)); } #[test] @@ -39,6 +39,14 @@ mod tests { 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); + // When no modification is needed, should return a borrowed reference to the original + assert_eq!(result.as_ref(), input); + } + #[test] fn test_parse_cxf_with_negative_timestamps_does_not_error() { let input = r#"{ From 9f9c360fb734cd56c6f8945a7766cf507045df8a Mon Sep 17 00:00:00 2001 From: Chris Rosenthal Date: Tue, 11 Aug 2026 11:15:13 -0700 Subject: [PATCH 4/4] Add collection and timestamp fallback tests per review - Test negative timestamps in collections array - Test negative timestamps in nested subCollections - Test that clamped timestamps fall back to approximately current time --- .../src/cxf/tests/negative_timestamp_test.rs | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs b/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs index 35923303bd..0ae61979d5 100644 --- a/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs +++ b/crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs @@ -5,6 +5,8 @@ #[cfg(test)] mod tests { + use chrono::Utc; + use crate::cxf::import::{parse_cxf, sanitize_timestamps}; #[test] @@ -43,10 +45,28 @@ mod tests { 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); - // When no modification is needed, should return a borrowed reference to the original 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#"{ @@ -79,4 +99,42 @@ mod tests { 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)); + } }