Skip to content

Issue-time validation: well-formed windows, bounded JSON depth, and _for grant constructors (0.10.0) - #28

Merged
stormer78 merged 4 commits into
mainfrom
sec-4045/issue-time-validation
Sep 12, 2026
Merged

stormer78 merged 4 commits into
mainfrom
sec-4045/issue-time-validation

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Summary

Four commits. No function signatures change, and nothing changes on the wire for a well-formed credential. The release is 0.10.0 because DTGCredentialError becomes #[non_exhaustive] and two constructors are deprecated (see Compatibility).

  1. fix: refuse a validity window that closes before it opens
    • New DTGCredentialError::InvalidValidityWindow { valid_from, valid_until }.
    • create::check_window runs first in new_vac, new_vdc, new_member_vmc(_for), new_delegate_vdc(_for), attenuate_inner and redelegate_inner. It refuses valid_until <= valid_from, compared at whole seconds because that is the wire precision.
    • New DTGCredential::validate(), called at the top of sign().
    • Backdated validFrom stays legal.
  2. fix: bound the depth of open JSON before digesting or signing
    • New pub const MAX_JSON_DEPTH: usize = 64, counted from the top of the document, and DTGCredentialError::JsonTooDeep { max }.
    • The check is an iterative walk with an explicit stack.
    • It is enforced in:
      • digest_multibase_json and digest_json, before proofless clones;
      • attenuate_from_json and redelegate_from_json, before a member is cloned out of the parent;
      • DTGCredential::digest_multibase and digest, on endorsement, card, credentialStatus and extra, before the credential is cloned;
      • validate(), which both sign() and verify_proof_with_public_key() now call. This covers with_credential_status / set_credential_status.
    • # Security docs added on new_vec and with_credential_status.
  3. feat: take the expected party when answering a grant; verify a grant's proof
    • DTGCredential::new_member_vmc_for(grant, member, valid_from, valid_until) and DTGCredential::new_delegate_vdc_for(grant, delegate, valid_from, valid_until).

      • They return NotTheGrantSubject { expected, found } when credentialSubject.id is not the given party.
      • They return OutlivesGrant { valid_until, grant_valid_until } when the answer ends after the grant, or has no end against a grant that expires.
      • A grant validUntil that is present but unreadable is refused.
    • new_member_vmc and new_delegate_vdc are now #[deprecated(since = "0.10.0")], pointing at the _for forms. Apart from the window check in commit 1, their behaviour is unchanged.

    • verify_grant_with_public_key(grant, public_key, at) is behind affinidi-signing. It runs these checks in order:

      • depth;
      • proof present (NotSigned);
      • proof verifies over the proofless wire form (DataIntegrity);
      • verification-method DID equals issuer (ProofNotFromIssuer);
      • window is well formed and contains at (InvalidValidityWindow / NotValidAt).

      Missing or unreadable members return MalformedCredential.

    • # Security sections added on acknowledges, accepts and both new constructors.

    • Internal tests, the README samples and examples/data_room.rs now use the new API. The example verifies each grant before answering it.

  4. chore(release): 0.10.0
    • DTGCredentialError marked #[non_exhaustive].
    • CHANGELOG entry, version bump, and README "Upgrading" notes (including a row in the breaking-release table).
    • Not published.

Compatibility

  • Version 0.10.0.
    • DTGCredentialError is now #[non_exhaustive] and gains seven variants: InvalidValidityWindow, JsonTooDeep, NotTheGrantSubject, OutlivesGrant, ProofNotFromIssuer, NotValidAt and MalformedCredential. An exhaustive match downstream needs a wildcard arm. Future variants will not be breaking.
    • Consumers on dtg-credentials = "0.9" do not pick this up with cargo update; the bump is deliberate. That matters because of the deprecations below.
  • Deprecations.
    • new_member_vmc and new_delegate_vdc still compile.
    • Builds with -D warnings will flag their call sites, and that includes clippy --all-targets.
  • Stricter behaviour.
    • Inverted or empty windows are now refused by every Result constructor, validate(), sign() and verify_proof_with_public_key().
    • Documents deeper than 64 levels are refused by the digest functions, the _from_json derivations, validate(), sign() and verify_proof_with_public_key().
    • A conforming issuer produces neither, so the README's "verifiers first" ordering still holds.

Callers of the deprecated functions in verifiable-trust-infrastructure

Checked against origin/main 5a6d4923 and not edited here. Both call sites are inside #[cfg(test)] modules:

  • vtc-service/src/members/inbound_vmc.rs:398: DTGCredential::new_member_vmc(&grant_wire, valid_from, None) (test module starts at line 371).
  • vtc-service/src/routes/relationships.rs:2140: DTGCredential::new_member_vmc(&grant_json, m.joined_at, None) (test module starts at line 1966).

There are no callers of new_delegate_vdc, and VTI does not match on DTGCredentialError variants.

VTI's CI runs cargo clippy --workspace --all-targets -- -D warnings. The change that bumps VTI to 0.10.0 therefore needs to move both call sites to new_member_vmc_for in the same commit.

Tests

161 tests with --all-features, up from 124. 146 with --no-default-features.

  • tests/membership_edge.rs (new, 14 tests).
    • A grant naming another subject returns NotTheGrantSubject, and the comparison is exact.
    • An acknowledgement outliving its grant returns OutlivesGrant, whether it ends later or has no end.
    • A grant with no expiry places no bound on the acknowledgement.
    • An unreadable grant expiry is refused.
    • Inverted and equal windows return InvalidValidityWindow.
    • Binding alone does not show that the grant was signed.
    • The deprecated constructor's behaviour is pinned, under #[allow(deprecated)].
    • With affinidi-signing:
      • an unsigned grant returns NotSigned;
      • a signed grant verifies and can then be acknowledged;
      • a grant altered after signing fails;
      • the wrong key fails;
      • a proof whose verification method is not the issuer's returns ProofNotFromIssuer;
      • a grant outside its window returns NotValidAt.
  • tests/delegation_chain.rs.
    • Moved to new_delegate_vdc_for.
    • Mirrored cases added: NotTheGrantSubject, OutlivesGrant, and an unsigned grant returning NotSigned (feature-gated).
    • Inverted and equal windows return InvalidValidityWindow for new_vdc, redelegate, redelegate_from_json and the acceptance.
  • tests/authority_chain.rs.
    • Inverted and equal windows are refused for new_vac, attenuate and attenuate_from_json.
    • A backdated window is accepted.
    • A window narrower than one second is refused.
  • tests/json_bounds.rs (new, 13 tests).
    • Depth MAX_JSON_DEPTH - 3 passes, and exactly at the bound passes; one level over returns JsonTooDeep.
    • Over-deep credentialStatus and extra are refused.
    • Depth 200 returns an error instead of aborting for digest_multibase_json, digest_multibase / validate, attenuate_from_json and redelegate_from_json. These run on a std::thread::Builder thread with a 256 KiB stack.
    • serde_json's parser limit is pinned: 127 levels parse and 128 fail.
    • A credential at the bound parses back.
    • Open members survive a round trip unchanged.
    • With affinidi-signing, sign and verify_proof_with_public_key refuse over-deep members.
  • src/lib.rs unit tests.
    • validate() refuses inverted and empty windows and accepts backdated or open-ended ones.
    • new_member_vmc refuses an inverted window.
    • sign() refuses an inverted window and leaves no proof.

Regression check. With the iterative guard temporarily made to always return false, each of the three small-stack tests aborts with thread 'small-stack' has overflowed its stack (SIGABRT). The guard was then restored.

Verification

Run locally on macOS arm64 with the repository's toolchain; results for the final commit are in the PR conversation.

  • cargo fmt --all --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test --all-features and cargo test --no-default-features
  • cargo package --locked
  • cargo run --example data_room: the example verifies both grants, then completes the membership and delegation edges.

Open questions

  • Is "an acknowledgement or acceptance MUST NOT outlive its grant" normative in Working Draft 02? OutlivesGrant enforces it in the new constructors only.
  • MAX_JSON_DEPTH = 64 as proposed. There is no byte-size cap; that is a policy call for the owners.

`new_vac`, `new_vdc`, `attenuate`, `redelegate` and both edge-answering
constructors accepted a `validUntil` at or before `validFrom`, and returned a
credential that is never valid. A chain built from one was caught at
verification, because `verify_chain` checks every link against an instant; a
standalone credential was caught nowhere, and `sign` put a proof on it.

Every constructor that returns a `Result` now checks the window before
anything else and refuses with the new
`DTGCredentialError::InvalidValidityWindow`. The constructors that return
`Self` cannot refuse, so the new `DTGCredential::validate` checks their output
instead, and `sign` calls it before signing. A caller that signs with another
backend can call it directly.

Both ends are compared at whole seconds, because that is all the wire form
carries: a window narrower than a second serializes as an empty one.

A `validFrom` in the past is still accepted. Backdating is how a re-issued
credential keeps the date the original took effect, so only the ordering of
the two ends is checked, never either end against the clock.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
A VEC's `endorsement`, `credentialStatus`, and the unmodelled top-level members
in `DTGCommon::extra` are held as open `serde_json::Value`s. Digesting, signing
and verifying clone, serialize and canonicalize them recursively, so a value
nested a few thousand levels deep overflowed the stack and aborted the process
rather than returning an error.

`MAX_JSON_DEPTH` (64, counted from the top of the credential) now bounds them,
and `DTGCredentialError::JsonTooDeep` reports a document that crosses it. The
check walks with an explicit stack, and runs before anything recursive touches
the value:

- `digest_multibase_json`, and the deprecated `digest_json`, on the document
  before `proof` is stripped from a clone of it. That covers every constructor
  that digests a grant or parent it was handed.
- `attenuate_from_json` and `redelegate_from_json`, before a member is cloned
  out of the parent.
- `DTGCredential::digest_multibase` and `digest`, on the open members, before
  the credential is cloned.
- `DTGCredential::validate`, which `sign` already calls and
  `verify_proof_with_public_key` now calls as well.

64 is half of serde_json's default parser limit, so anything this library signs
parses back in a stock verifier, and a credential received over the wire is
already bounded by that limit before it gets here. The bound cannot protect a
caller that builds a deeper value itself, because dropping a `Value` recurses
too; the parser remains the real boundary, and the constant's documentation
says so.

`new_vec` and `with_credential_status` gain a Security section. Both embed what
they are given verbatim, and a consumer must verify the proof and the issuer's
standing before relying on any of it.

The new `tests/json_bounds.rs` runs its deep cases on a 256 KiB stack. With the
check disabled, each of them aborts with a stack overflow.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…s proof

`new_member_vmc` and `new_delegate_vdc` read the member or delegate off the
grant they are handed, and check neither who is answering it nor whether
anybody signed it. The digest binds the answer to the grant, so the result is
binding evidence, but nothing in either signature obliged a caller to confirm
that the grant names the party doing the answering - the shape
`verify_chain` was in before 0.8.0 and 0.9.1 put the presenter in its
signature. Nor was the answer held to the grant's expiry.

Two new constructors take the party the caller expects:

- `DTGCredential::new_member_vmc_for(grant, member, valid_from, valid_until)`
- `DTGCredential::new_delegate_vdc_for(grant, delegate, valid_from, valid_until)`

Each refuses a grant whose `credentialSubject.id` is not that party, with
`DTGCredentialError::NotTheGrantSubject { expected, found }`, and an answer
whose `validUntil` is later than the grant's, or absent against a grant that
expires, with `OutlivesGrant`. A grant `validUntil` that cannot be read is
refused rather than treated as absent. The parties are still read off the
grant: the new parameter is compared with it, not substituted for it.

The old constructors are deprecated in favour of these and otherwise keep
their behaviour, so nothing breaks; a build with `-D warnings` names the
replacement.

Behind `affinidi-signing`, `verify_grant_with_public_key(grant, public_key, at)`
verifies a grant in its wire form before it is answered: the proof, over the
document without its `proof`; that the proof's verification method belongs to
the grant's `issuer` (`ProofNotFromIssuer` otherwise); and that the window is
well formed and contains `at` (`NotValidAt`). `MalformedCredential` covers a
grant with no `issuer` or `validFrom`, or with an unreadable timestamp or
proof.

`acknowledges`, `accepts` and both new constructors gain a Security section:
a binding is not a verified edge. The README samples and the `data_room`
example now verify the grant, then answer it with the new constructors.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
DTGCredentialError is now #[non_exhaustive]: it gains seven variants in this release, and future variants will not be breaking. With new_member_vmc and new_delegate_vdc deprecated, a -D warnings build changes on upgrade, so this ships as 0.10.0 rather than 0.9.2 and a cargo update on "0.9" does not pick it up silently.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 requested a review from a team as a code owner September 11, 2026 20:36
@stormer78
stormer78 merged commit 63f3af6 into main Sep 12, 2026
7 checks passed
@stormer78
stormer78 deleted the sec-4045/issue-time-validation branch September 12, 2026 05:46
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.

1 participant