8000 fix: emit proof `created` as a UTC dateTimeStamp by stormer78 · Pull Request #305 · affinidi/affinidi-ssi-dart · GitHub
[go: up one dir, main page]

Skip to content

fix: emit proof created as a UTC dateTimeStamp - #305

Merged
deepak-affinidi merged 1 commit into
mainfrom
fix/proof-created-utc
Sep 16, 2026
Merged

deepak-affinidi merged 1 commit into
mainfrom
fix/proof-created-utc

Conversation

@stormer78
@stormer78 stormer78 commented Sep 16, 2026 •
Copy link
Copy Markdown
Contributor

Summary

Every proof generator stamped created with DateTime.now() — a local DateTime — and toIso8601String() on a local DateTime emits no timezone designator. So every proof ssi signs, in every cryptosuite, carries a value like:

"created": "2026-09-16T12:35:11.650574"

This fixes all five generators with .toUtc().

Why it matters

It violates a MUST. VC Data Integrity §2.1:

The date and time the proof was created is OPTIONAL and, if included, MUST be specified as an [XMLSCHEMA11-2] dateTimeStamp string, 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.

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 Z follows from DateTime.isUtc, which is false for DateTime.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

created feeds both the signed proof configuration (jcs_utils.dart) and the emitted JSON (embedded_proof.dart), and both call toIso8601String() on the same DateTime. Normalising only in EmbeddedProof.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.

File Suites
base_jcs_generator.dart eddsa-jcs-2022, ecdsa-jcs-2019, mldsa44-jcs-2024
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

Tests

  • New proof_created_timestamp_test.dart covers the shared JCS generator and the RDFC-family suites.
  • The ML-DSA RDFC case is asserted in the existing data_integrity_mldsa_test.dart, which already has the DID resolver it needs.
  • Every new assertion fails on the previous code and passes with the fix, including under TZ=UTC.
  • Each test re-verifies its proof, so a divergence between the hashed and emitted created would also fail.
dart test          → +1077 ~2: All tests passed!
dart format        → 0 changed
dart analyze .     → 31 infos, identical to main (none introduced)

Not changed

expires, challenge and domain are caller-supplied. A caller passing a local DateTime for expires can 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 drops nonce before 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.

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.
@stormer78
stormer78 requested a review from a team as a code owner September 16, 2026 11:16
@github-actions
Copy link
Copy Markdown

Coverage after merging fix/proof-created-utc into main will be

80.24%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
lib/src
   types.dart70.83%100%100%70.83%133, 172–173, 185, 187, 202, 255
   digest_utils.dart100%100%100%100%
   utility.dart88.89%100%100%88.89%17
lib/src/credentials/jwt
   jwt_data_model_v1.dart100%100%100%100%
   jwt_dm_v1_suite.dart86.54%100%100%86.54%107, 109, 39, 41, 59, 76, 78
lib/src/credentials/linked_data
   ld_base_suite.dart90.77%100%100%90.77%121, 123, 175, 178, 212, 218
   ld_dm_v1_suite.dart100%100%100%100%
   ld_dm_v2_suite.dart100%100%100%100%
lib/src/credentials/models
   sdjwt_signer_adapter.dart100%100%100%100%
lib/src/credentials/models/field_types
   issuer.dart100%100%100%100%
   context.dart85.96%100%100%85.96%171, 173, 206–211
   credential_schema.dart100%100%100%100%
   credential_subject.dart74.36%100%100%74.36%42, 59, 61–62, 65, 68–69, 74–75, 97
   evidence.dart75%100%100%75%45–47, 49
   holder.dart100%100%100%100%
   terms_of_use.dart75%100%100%75%43–45, 47
lib/src/credentials/models/field_types/credential_status
   revocation_list_2020.dart20.90%100%100%20.90%101–104, 107, 110–112, 114–116, 118–120, 122–124, 14, 177, 179, 181–188, 22–27, 65, 68–75, 81, 84–85, 87–88, 90–91, 93–94, 99
   v1.dart73.33%100%100%73.33%61–62, 72, 74–77, 79, 83–84, 89–90
   v2.dart73.33%100%100%73.33%60–61, 71, 73–76, 78, 82–83, 88–89
lib/src/credentials/models/field_types/refresh_service
   v1.dart75%100%100%75%44–46, 48
   v2.dart18.18%100%100%18.18%35–36, 38, 55–56, 61, 66–67, 69
lib/src/credentials/models/v1
   mutable_vc_data_model_v1.dart89.83%100%100%89.83%155, 209–210, 217, 224, 231
   vc_data_model_v1.dart91.47%100%100%91.47%161, 163–164, 188–190, 213–216, 219
lib/src/credentials/models/v2
   mutable_vc_data_model_v2.dart88.14%100%100%88.14%185, 192, 203–204, 212, 219, 226
   vc_data_model_v2.dart90.91%100%100%90.91%166–168, 191–194, 197, 323, 330, 337
lib/src/credentials/parsers
   jwt_parser.dart85.71%100%100%85.71%52, 54
   ld_parser.dart85.71%100%100%85.71%26
   sdjwt_parser.dart70%100%100%70%40, 56, 58
lib/src/credentials/presentations/linked_data
   ld_vp_dm_v1_suite.dart100%100%100%100%
   ld_vp_dm_v2_suite.dart100%100%100%100%
lib/src/credentials/presentations/models
   vc_parse_present.dart60%100%100%60%102, 107–108, 35–37, 62–64, 89–90, 93–96, 99
lib/src/credentials/presentations/models/v1
   mutable_vp_data_model_v1.dart48.28%100%100%48.28%102, 104, 109, 76, 82–83, 85–87, 89, 91, 93, 95, 97–98
   vp_data_model_v1.dart88.89%100%100%88.89%112, 114, 139, 144, 85, 87–88
lib/src/credentials/presentations/models/v2
   vp_data_model_v2.dart88.41%100%100%88.41%117, 119, 143, 148, 212, 90, 92–93
   mutable_vp_data_model_v2.dart47.06%100%100%47.06%100, 102, 104–105, 109, 111, 116, 118–119, 123, 82, 88–89, 91, 93–94, 96, 98
lib/src/credentials/presentations/suites
   universal_presentation_parser.dart42.86%100%100%42.86%21–22, 24, 26
   universal_presentation_verifier.dart90.32%100%100%90.32%104–106
   vp_suites.dart61.54%100%100%61.54%31, 34, 56–58
lib/src/credentials/presentations/verification
   delegation_vc_verifier.dart100%100%100%100%
   holder_binding_verifier.dart97.30%100%100%97.30%64
   vp_expiry_verifier.dart100%100%100%100%
   vp_integrity_verifier.dart100%100%100%100%
   vp_domain_challenge_verifier.dart76.19%100%100%76.19%33, 54–55, 65–66
lib/src/credentials/proof
   data_integrity_ecdsa_suite.dart76.79%100%100%76.79%142, 151, 154, 157, 160, 163, 170, 173, 180, 270–271, 277, 279–280, 355, 360, 395, 397, 417–418, 424, 426–427, 52, 54–55
   data_integrity_eddsa_suite.dart79.69%100%100%79.69%136, 145, 148, 151, 154, 157, 164, 167, 174, 180, 49, 51–52
   data_integrity_mldsa_suite.dart91.14%100%100%91.14%280, 282–283, 60, 68–70
   ecdsa_secp256k1_signature2019_suite.dart80.52%100%100%80.52%129–130, 132, 134–135, 137, 139–140, 142, 146–147, 149, 79, 81, 84
   embedded_proof_suite.dart100%100%100%100%
   jcs_utils.dart49.06%100%100%49.06%17, 193, 196, 199–202, 221, 225, 229, 240–241, 249, 252, 42, 44–45, 53, 55, 61, 64, 72–73, 75, 79, 81, 84
   embedded_proof.dart72.46%100%100%72.46%105–106, 108, 115, 156–157, 163–168, 182, 190, 196, 198, 89, 94–95
   base_data_integrity_verifier.dart67.72%100%100%67.72%105, 121–124, 128–132, 158–163, 187, 213, 215, 217, 219, 222, 225–226, 298, 300–301, 336–337, 339, 341–342, 344, 346–347, 349, 352–353, 355, 91–92
   proof_validation_utils.dart100%100%100%100%
   base_jcs_generator.dart100%100%100%100%
   data_integrity_context_util.dart60%100%100%60%14, 23, 25, 31, 35, 37
   base_jcs_verifier.dart73.21%100%100%73.21%103, 105–106, 117,

@affinidi-appsecurity-bot
Copy link
Copy Markdown

🛡️ 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
deepak-affinidi merged commit 61ad45d into main Sep 16, 2026
6 checks passed
@deepak-affinidi
deepak-affinidi deleted the fix/proof-created-utc branch September 16, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

0