Repository navigation
fix: emit proof created as a UTC dateTimeStamp - #305
Merged
Merged
Conversation
Every proof generator stamped `created` with `DateTime.now()`. That is a *local* DateTime, and `toIso8601String()` on a local DateTime carries no timezone designator, so every proof ssi signed — in every cryptosuite — emitted a value like `2026-09-16T12:35:11.650574`. VC Data Integrity §2.1 requires `created` to be an XMLSCHEMA11-2 `dateTimeStamp`: "either in Universal Coordinated Time (UTC), denoted by a Z at the end of the value, or with a time zone offset relative to UTC". An offset-less value is non-conforming. It is also wrong, not merely strict. §2.1 lets a processor accept an offset-less value but says it is "to be interpreted as UTC". ssi wrote local time, so a conforming reader places the signing instant off by the signer's UTC offset — two hours on a CEST machine, ten in Sydney. This does not depend on the machine's zone: the missing `Z` follows from `DateTime.isUtc`, which is false for `DateTime.now()` even on a UTC host. That is why CI never flagged it — on a UTC runner the numeric value happens to be right, and ssi's own verifier accepts the missing designator. Fixed at the five sources with `.toUtc()`: base_jcs_generator.dart (eddsa-jcs, ecdsa-jcs, mldsa-jcs) data_integrity_eddsa_suite.dart (eddsa-rdfc-2022) data_integrity_ecdsa_suite.dart (ecdsa-rdfc-2019) data_integrity_mldsa_suite.dart (mldsa44-rdfc-2024) ecdsa_secp256k1_signature2019_suite.dart (EcdsaSecp256k1Signature2019) The fix is deliberately at the source rather than in EmbeddedProof.toJson. `created` feeds both the signed proof configuration (jcs_utils) and the emitted JSON (embedded_proof), and both call toIso8601String() on the same DateTime. Normalising only toJson would emit a conforming timestamp over a signature computed for a different string — every proof would stop verifying. Making the DateTime itself UTC keeps the two identical. Tests: proof_created_timestamp_test.dart covers the JCS generator and the three RDFC-family suites; the ML-DSA RDFC case is asserted in the existing data_integrity_mldsa_test.dart because it needs that file's DID resolver. Every new assertion fails on the previous code and passes with the fix, including under TZ=UTC, and each test re-verifies its proof so a hash/JSON divergence would also fail. Full suite: 1077 passed, 2 skipped. `dart analyze` reports the same 31 pre-existing infos as main. Not changed: `expires`, `challenge` and `domain` are caller-supplied, so a caller passing a local DateTime for `expires` can still produce an offset-less value. Normalising those alters caller-facing behaviour and is left for a separate decision.
|
Coverage after merging fix/proof-created-utc into main will be
Coverage Report |
|
🛡️ AI Agentic Security Code Review — all clear. We checked this change and found nothing to report. Keep shipping secure code! Note: for major, breaking, or feature-introducing changes, you can always request an in-depth review from the security team. |
deepak-affinidi
approved these changes
Sep 16, 2026
maindotdev
approved these changes
Sep 16, 2026
marco-a-affinidi
approved these changes
Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every proof generator stamped
createdwithDateTime.now()— a localDateTime— andtoIso8601String()on a localDateTimeemits no timezone designator. So every proof ssi signs, in every cryptosuite, carries a value like:This fixes all five generators with
.toUtc().Why it matters
It violates a MUST. VC Data Integrity §2.1:
And it's wrong, not just strict. §2.1 allows a processor to accept an offset-less value, but says such values "are to be interpreted as UTC". ssi writes local time, so a conforming reader places the signing instant off by the signer's offset — two hours on a CEST machine, ten in Sydney.
It breaks interop with strict verifiers. Found while building Trust Tasks bindings: a Rust consumer using
chrono::DateTime<Utc>cannot deserialize an ssi-signed document at all. Rust → Dart verification works; Dart → Rust does not.Why CI never caught it
The missing
Zfollows fromDateTime.isUtc, which is false forDateTime.now()even on a UTC host. On a UTC runner the numeric value happens to be correct, and ssi's own verifier accepts the missing designator — so nothing looked wrong until a strict consumer read it.The fix is at the source, deliberately
createdfeeds both the signed proof configuration (jcs_utils.dart) and the emitted JSON (embedded_proof.dart), and both calltoIso8601String()on the sameDateTime. Normalising only inEmbeddedProof.toJsonwould emit a conforming timestamp over a signature computed for a different string — every proof would stop verifying. Making theDateTimeitself UTC keeps the two identical.base_jcs_generator.dartdata_integrity_eddsa_suite.dartdata_integrity_ecdsa_suite.dartdata_integrity_mldsa_suite.dartecdsa_secp256k1_signature2019_suite.dartTests
proof_created_timestamp_test.dartcovers the shared JCS generator and the RDFC-family suites.data_integrity_mldsa_test.dart, which already has the DID resolver it needs.TZ=UTC.createdwould also fail.Not changed
expires,challengeanddomainare caller-supplied. A caller passing a localDateTimeforexpirescan still produce an offset-less value. Normalising those changes caller-facing behaviour, so I've left it for a separate decision.Related
This fix alone does not restore Dart → Rust interop.
affinidi-data-integrity(Rust) also dropsnoncebefore hashing, so the Rust verifier re-hashes a different proof configuration than ssi signed. Companion PR: affinidi/affinidi-tdk-rs#802. Both are needed — without this PR an ssi-signed document cannot be deserialized by a Rust consumer; without #802 it deserializes but cannot be verified.