The SNS event structs (SnsMessage, SnsSubscriptionMessage, SnsMessageObj) parse Timestamp into chrono::DateTime<Utc>, and re-serializing does not reproduce the string SNS sent: chrono's default serializer uses SecondsFormat::AutoSi, which omits the subsecond part when it is zero.
let ts = "2019-01-02T12:45:07.000Z"; // Timestamp from the SNS sample event in the Lambda docs
let parsed: chrono::DateTime<chrono::Utc> = serde_json::from_value(serde_json::json!(ts))?;
assert_eq!(serde_json::to_value(parsed)?, "2019-01-02T12:45:07Z"); // ".000" is gone
SNS itself always emits exactly three fractional digits, including .000 on whole seconds. The sample event in the Lambda developer guide shows "Timestamp": "2019-01-02T12:45:07.000Z", and I confirmed the fixed three-digit format against a captured production payload whose signature verifies against the corresponding SimpleNotificationService-*.pem signing certificate (chained to Amazon Root CA 1).
Impact
The SNS message signature covers the Timestamp string verbatim — the signature field docs on these structs say so. Anyone verifying SNS signatures on Lambda-delivered events who rebuilds the string-to-sign from these structs computes a canonical string that differs from what SNS signed whenever the message was published on a whole second. Roughly 1 in 1000 valid messages fails verification and gets dropped — silent, rare, and invisible in testing (any timestamp with nonzero milliseconds round-trips fine). I confirmed end to end with signed envelopes: a .000Z message verifies as delivered and fails after a round trip through SnsMessage; a .719Z message passes both ways.
Secondary effect: test events generated by serializing these structs (e.g. via the builders feature) carry timestamps in a format real SNS never produces.
Proposed fix (non-breaking)
Pin serialization to SNS's actual format, leaving deserialization and the field type unchanged:
// custom_serde
pub fn serialize_rfc3339_millis<S: Serializer>(
dt: &DateTime<Utc>,
s: S,
) -> Result<S::Ok, S::Error> {
s.serialize_str(&dt.to_rfc3339_opts(SecondsFormat::Millis, true))
}
#[serde(serialize_with = "crate::custom_serde::serialize_rfc3339_millis")]
pub timestamp: DateTime<Utc>,
With this, every timestamp SNS actually produces round-trips byte-identically, so the reconstructed string-to-sign matches and generated fixtures match real payloads. I'm happy to send this PR.
Alternative (breaking)
Preserve the raw string — pub timestamp: String, or a wrapper holding both the raw string and the parsed DateTime<Utc>, serializing the raw string verbatim. This is the only variant that stays correct even if AWS ever changes its timestamp precision, but it breaks every consumer of the field (and diverges from aws-lambda-go, whose SNSEntity uses time.Time with the same limitation). Mentioning it for completeness; the non-breaking fix above covers the observed format.
Regardless of the fix, it may be worth a doc note on these fields that signature verification is best performed against the raw payload bytes — that is what AWS's official validators do, and it is immune to representation issues entirely.
The SNS event structs (
SnsMessage,SnsSubscriptionMessage,SnsMessageObj) parseTimestampintochrono::DateTime<Utc>, and re-serializing does not reproduce the string SNS sent: chrono's default serializer usesSecondsFormat::AutoSi, which omits the subsecond part when it is zero.SNS itself always emits exactly three fractional digits, including
.000on whole seconds. The sample event in the Lambda developer guide shows"Timestamp": "2019-01-02T12:45:07.000Z", and I confirmed the fixed three-digit format against a captured production payload whose signature verifies against the correspondingSimpleNotificationService-*.pemsigning certificate (chained to Amazon Root CA 1).Impact
The SNS message signature covers the
Timestampstring verbatim — thesignaturefield docs on these structs say so. Anyone verifying SNS signatures on Lambda-delivered events who rebuilds the string-to-sign from these structs computes a canonical string that differs from what SNS signed whenever the message was published on a whole second. Roughly 1 in 1000 valid messages fails verification and gets dropped — silent, rare, and invisible in testing (any timestamp with nonzero milliseconds round-trips fine). I confirmed end to end with signed envelopes: a.000Zmessage verifies as delivered and fails after a round trip throughSnsMessage; a.719Zmessage passes both ways.Secondary effect: test events generated by serializing these structs (e.g. via the
buildersfeature) carry timestamps in a format real SNS never produces.Proposed fix (non-breaking)
Pin serialization to SNS's actual format, leaving deserialization and the field type unchanged:
With this, every timestamp SNS actually produces round-trips byte-identically, so the reconstructed string-to-sign matches and generated fixtures match real payloads. I'm happy to send this PR.
Alternative (breaking)
Preserve the raw string —
pub timestamp: String, or a wrapper holding both the raw string and the parsedDateTime<Utc>, serializing the raw string verbatim. This is the only variant that stays correct even if AWS ever changes its timestamp precision, but it breaks every consumer of the field (and diverges fromaws-lambda-go, whoseSNSEntityusestime.Timewith the same limitation). Mentioning it for completeness; the non-breaking fix above covers the observed format.Regardless of the fix, it may be worth a doc note on these fields that signature verification is best performed against the raw payload bytes — that is what AWS's official validators do, and it is immune to representation issues entirely.