Skip to content
Merged
61 changes: 60 additions & 1 deletion crates/bitwarden-exporters/src/cxf/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,72 @@ use crate::{
* Parse CXF payload in the format compatible with Apple (At the Account-level)
*/
pub(crate) fn parse_cxf(payload: String) -> Result<Vec<ImportingCipher>, 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<ImportingCipher> = account.items.into_iter().flat_map(parse_item).collect();

Ok(items)
}

/// Replace negative `creationAt` and `modifiedAt` values with null, so that `Utc::now()` will be
/// used downstream
///
/// 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::<serde_json::Value>(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<Utc>`].
///
/// If the timestamp is None, the current time is used.
Expand Down
1 change: 1 addition & 0 deletions crates/bitwarden-exporters/src/cxf/tests/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod dashlane_import_test;
mod negative_timestamp_test;
mod one_password_import_test;
mod sample_import_test;
144 changes: 144 additions & 0 deletions crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs
Comment thread
harr1424 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//! 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_valid_timestamps_unchanged_returns_borrowed() {
let input = r#"{"id":"test","items":[{"id":"1","creationAt":1759783057,"modifiedAt":1759783057,"title":"Test","credentials":[]}]}"#;
let result = sanitize_timestamps(input);
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
}

#[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"#));
}

Comment thread
harr1424 marked this conversation as resolved.
#[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));
}
}
Loading