Skip to content

[PM-28361] feat: flexible timestamp serde#112

Open
Autoparallel wants to merge 3 commits intobitwarden:mainfrom
Autoparallel:main
Open

[PM-28361] feat: flexible timestamp serde#112
Autoparallel wants to merge 3 commits intobitwarden:mainfrom
Autoparallel:main

Conversation

@Autoparallel
Copy link

🎟️ Tracking

Adding flexible timestamp deserialization to support both UNIX timestamps and ISO8601 formats in the Credential Exchange Format, addressing compatibility with iOS native credential exchange protocol which outputs ISO8601 timestamps.

📔 Objective

This PR adds flexible timestamp deserialization support to the credential-exchange-format crate. The implementation allows the format to accept timestamps in two formats during deserialization:

  • UNIX timestamps (i64/u64) - seconds since Unix epoch
  • ISO8601 strings (RFC3339) - e.g., "2023-11-18T10:30:00Z"

All timestamps are consistently serialized as UNIX timestamps (i64) to maintain compatibility with the CXF standard.

Motivation: iOS's native credential exchange protocol (new in iOS 26) outputs timestamps in ISO8601 format. This change provides seamless interoperability without requiring format conversion at the application layer, while maintaining backward compatibility with existing UNIX timestamp-based implementations.

Key changes:

  • Added new timestamp module with custom serde serialization/deserialization functions
  • Implemented flexible deserialization using serde's visitor pattern
  • Applied to Header::timestamp, Collection::creation_at, Collection::modified_at, Item::creation_at, and Item::modified_at fields
  • Comprehensive documentation and test coverage
  • Zero additional dependencies (uses existing chrono with serde feature)

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed (no clippy warnings)
  • Written new unit and / or integration tests where applicable (18 comprehensive tests)
  • Protected functional changes with optionality (feature flags) - N/A (backward compatible change)
  • Used internationalization (i18n) for all UI strings - N/A (library crate)
  • CI builds passed (pending)
  • Communicated to DevOps any deployment requirements - N/A (library change)
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team - Added comprehensive module and function documentation

🦮 Reviewer guidelines

Areas to focus on:

  • Correctness of the serde visitor implementation for flexible deserialization
  • Test coverage for edge cases (invalid timestamps, type mismatches, null handling)
  • Documentation clarity for users of the timestamp module
  • Backward compatibility:
    • Existing UNIX timestamp serialization/deserialization still works identically
    • Types are changed, requires minor version bump to 0.4.0 via Rust semver

Testing notes:

  • All tests pass, covering:
    • Deserialization from UNIX timestamps (i64/u64)
    • Deserialization from ISO8601 strings (with and without timezone offsets)
    • Serialization always outputs UNIX timestamps
    • Roundtrip tests (both formats deserialize and serialize consistently)
    • Error handling for invalid inputs
    • Optional timestamp handling (Some/None cases)

@Autoparallel Autoparallel requested a review from a team as a code owner November 18, 2025 03:02
@CLAassistant
Copy link

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@bitwarden-bot
Copy link

Thank you for your contribution! We've added this to our internal tracking system for review.
ID: PM-28361
Link: https://bitwarden.atlassian.net/browse/PM-28361

Details on our contribution process can be found here: https://contributing.bitwarden.com/contributing/pull-requests/community-pr-process.

@bitwarden-bot bitwarden-bot changed the title feat: flexible timestamp serde [PM-28361] feat: flexible timestamp serde Nov 18, 2025
@Hinton
Copy link
Member

Hinton commented Nov 18, 2025

Hey @Autoparallel,

Could you provide some details about how you are using the library with the Apple APIs? I don't believe I've seen ASImportableAccount mapped to ISO8601 strings ...

On our side we're using it in our iOS application with the following relevant code snippets:

We do have to explicitly tell the json encoder to use .secondsSince1970 since otherwise it will use milliseconds which is the default in Apple encoders, but beyond that Apple should output a json encoding compliant with the Credential Exchange Format.

@Autoparallel
Copy link
Author

Hey @Autoparallel,

Could you provide some details about how you are using the library with the Apple APIs? I don't believe I've seen ASImportableAccount mapped to ISO8601 strings ...

On our side we're using it in our iOS application with the following relevant code snippets:

We do have to explicitly tell the json encoder to use .secondsSince1970 since otherwise it will use milliseconds which is the default in Apple encoders, but beyond that Apple should output a json encoding compliant with the Credential Exchange Format.

Yeah, this was my issue too. You can definitely specify the JSON encoder, but by default iOS is providing ISO8601. It seemed to me like having flexible deserialization in this library would make developer experience a bit smoother while any time it's serialized we adhere to the spec precisely. Similarly, using chrono for the actual datetime type allows for more capabilities while the data sits inside the Header struct (i.e., someone wants to do time-based filtering on their accounts prior to import to check for breaches or something).


Thanks for the quick reply by the way, I appreciate it! :)

@Hinton
Copy link
Member

Hinton commented Nov 18, 2025

Still a bit confused about the ISO8601 strings. While it's possible for them to return ISO dates the default is deferredToDate, which admittedly doesn't tell us much but in our and other's testing used the milliseconds encoding.
https://developer.apple.com/documentation/foundation/jsonencoder/dateencodingstrategy-swift.enum

Is it possible you might be re-using an existing Encoder/Decoder that has this manually configured to ISO?

FYI, I submitted an Apple Feedback (FB21076950) to add a note to the apple documentation about secondsSince1970.


I do agree that graceful parsing is possible. My concerns is that the import would now be inconsistent with the export flows, and you would still have to configure the JSONDecoder to handle the dates in the export flow. Is it actually worth gracefully handling this for importers if it means it blows up on exports instead?

@Autoparallel
Copy link
Author

I do agree that graceful parsing is possible. My concerns is that the import would now be inconsistent with the export flows, and you would still have to configure the JSONDecoder to handle the dates in the export flow. Is it actually worth gracefully handling this for importers if it means it blows up on exports instead?

That's a good point.

Is your preference to stick solely with a u64 repr for the secondsSince1970 internally then? I would understand if so.

@Hinton
Copy link
Member

Hinton commented Nov 20, 2025

I do agree that graceful parsing is possible. My concerns is that the import would now be inconsistent with the export flows, and you would still have to configure the JSONDecoder to handle the dates in the export flow. Is it actually worth gracefully handling this for importers if it means it blows up on exports instead?

That's a good point.

Is your preference to stick solely with a u64 repr for the secondsSince1970 internally then? I would understand if so.

From a rust consumer perspective I think moving over to DateTime<Utc> would be preferred since that's generally how you interact with time in rust. But I don't think we'd want/need the iso parsing and it's sufficient to only support unix epoch values in the serializers/deserializers.

@Progdrasil do you have any opinion on moving timestamps over to DateTime<Utc>? It would be a breaking change albeit a fairly minor one, and we're only on 0.3

@Hinton
Copy link
Member

Hinton commented Nov 20, 2025

I added a section about compatibility with apple to the credential-exchnge-format readme, #114.

@Progdrasil
Copy link
Contributor

I do agree that graceful parsing is possible. My concerns is that the import would now be inconsistent with the export flows, and you would still have to configure the JSONDecoder to handle the dates in the export flow. Is it actually worth gracefully handling this for importers if it means it blows up on exports instead?

That's a good point.
Is your preference to stick solely with a u64 repr for the secondsSince1970 internally then? I would understand if so.

From a rust consumer perspective I think moving over to DateTime<Utc> would be preferred since that's generally how you interact with time in rust. But I don't think we'd want/need the iso parsing and it's sufficient to only support unix epoch values in the serializers/deserializers.

@Progdrasil do you have any opinion on moving timestamps over to DateTime<Utc>? It would be a breaking change albeit a fairly minor one, and we're only on 0.3

I suspect as time goes on that we'll need to adapt the deserialization as much as possible to be flexible for spec non-compliant implementations. So long as the output of the library is spec compliant when serializing I think this is fine. It's also the same route we take for 1password/passkey-rs.

As for changing the type itself vs using a u64 repr, I think there's value in using a smarter type rather than a primitive here. So I'm okay with this breaking change.

@Autoparallel
Copy link
Author

This is getting stale -- not sure where the decision lies but I just wanted to ping here to see if there's still interest!

@Hinton
Copy link
Member

Hinton commented Mar 5, 2026

Hey @Autoparallel,

Apologies for not getting back earlier. I believe we're interested in this, I just need to find some time to review it. We have some broader issues with unexpected data (documented in #122 and #123). But I don't think those larger efforts should block this.

@Hinton Hinton self-assigned this Mar 5, 2026
@Hinton Hinton self-requested a review March 5, 2026 15:57
Copy link
Member

@Hinton Hinton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks good overall. There are a few things we should resolve.

Would you mind signing the CLA and also add a note to CHANGELOG.md, thanks!

/// the provider encounters this [Item].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified_at: Option<u64>,
pub modified_at: Option<DateTime<Utc>>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Should this also use with = "timestamp::option"?

use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Deserializer, Serializer};

/// Serializes a [`DateTime<Utc>`] as a UNIX timestamp (i64).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I think we need to use u64 here. The CX specification specifies machine readable datetimes as uint .size 8 and I don't believe it's valid to serialize as signed integers.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's definitely right. i64 would cause errors. Good catch!

Comment on lines +92 to +161
pub mod option {
//! Serialization and deserialization functions for `Option<DateTime<Utc>>`.
//!
//! This module provides the same flexible deserialization as the parent module,
//! but for optional timestamp fields.

use super::{DateTime, Deserialize, Deserializer, Serializer, TimeZone, Utc};

/// Serializes an `Option<DateTime<Utc>>` as either a UNIX timestamp (i64) or null.
///
/// This function is intended to be used with serde's `#[serde(with = "...")]` attribute.
///
/// # Errors
///
/// Returns an error if the serializer fails to serialize the timestamp value.
#[allow(clippy::ref_option)]
pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match date {
Some(dt) => serializer.serialize_some(&dt.timestamp()),
None => serializer.serialize_none(),
}
}

/// Deserializes an `Option<DateTime<Utc>>` from either a UNIX timestamp, an ISO8601 string,
/// or null.
///
/// This function is intended to be used with serde's `#[serde(with = "...")]` attribute.
///
/// # Accepted Formats
///
/// - UNIX timestamp as i64 or u64 (seconds since Unix epoch)
/// - ISO8601/RFC3339 string (e.g., `"2023-11-18T10:30:00Z"`)
/// - null
///
/// # Errors
///
/// Returns an error if:
/// - The timestamp value is invalid or out of range
/// - The ISO8601 string cannot be parsed
/// - The input is neither a number, string, nor null
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
where
D: Deserializer<'de>,
{
Option::<serde_json::Value>::deserialize(deserializer)?
.map(|v| {
v.as_i64().map_or_else(
|| {
v.as_str().map_or_else(
|| Err(serde::de::Error::custom("expected number or string")),
|s| {
s.parse::<DateTime<Utc>>().map_err(|e| {
serde::de::Error::custom(format!("invalid ISO8601: {e}"))
})
},
)
},
|num| {
Utc.timestamp_opt(num, 0)
.single()
.ok_or_else(|| serde::de::Error::custom("invalid timestamp"))
},
)
})
.transpose()
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I believe we can use a helper struct here which would allow re-use of the previous implementation which ensures consistency.

    #[allow(clippy::ref_option)]
    pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        #[derive(Serialize)]
        struct Helper(#[serde(serialize_with = "super::serialize")] DateTime<Utc>);

        date.as_ref().map(|dt| Helper(*dt)).serialize(serializer)
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct Helper(#[serde(deserialize_with = "super::deserialize")] DateTime<Utc>);

        Option::<Helper>::deserialize(deserializer).map(|opt| opt.map(|h| h.0))
    }

@Autoparallel
Copy link
Author

I think this looks good overall. There are a few things we should resolve.

Would you mind signing the CLA and also add a note to CHANGELOG.md, thanks!

Yes, I can do that. It may take me a few days as I have some other priorities right now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants