Skip to content

Working Draft 02: digest encoding, VAC parent digests, and the VDC - #20

Merged
stormer78 merged 2 commits into
mainfrom
feat/working-draft-02
Sep 8, 2026
Merged

Working Draft 02: digest encoding, VAC parent digests, and the VDC#20
stormer78 merged 2 commits into
mainfrom
feat/working-draft-02

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Brings the library up to Working Draft 02 of DTG Core Credentials. Both drafts 0.6.0 tracked have merged upstream — the VAC as PR #29 and the VDC as PR #19 — and the digest encoding changed underneath them.

Breaking on the wire as well as in the API. Version bumped to 0.7.0 and the changelog cut with today's date, so this is ready to tag on merge.

Two commits, reviewable in order:

  1. chore(deps): update to current releases
  2. feat!: bring the library up to Working Draft 02

The digest encoding

WD02 replaced sha256:<lowercase hex> with a base58btc multibase multihash, and renamed the property from digest to digestMultibase. Every cross-credential reference now uses that one encoding: the member-issued VMC's digest of the grant it acknowledges, the VWC's of the edge credential it attests, a VAC's authority.parent, and a VDC's delegation.parent / delegation.accepts.

  • digest_multibase() is the conformant digest and now excludes the top-level proof — which is what it was deprecated for. Un-deprecated. digest_multibase_json() is its wire-form counterpart.
  • digest() / digest_json() are deprecated but emit the old form unchanged, so a caller migrating can recompute a stored digest to compare against.
  • The old property name digest is still accepted when parsing, so WD01 credentials deserialize. Their values then fail with InvalidDigest rather than as a silent mismatch — the intended outcome.
  • Digests are compared as decoded bytes, never strings. The spec requires it because one digest has more than one spelling, and a string comparison would report a mismatch where the two credentials agree.
  • An unimplemented hash algorithm is rejected (UnsupportedDigestAlgorithm), not treated as a mismatch. A governing party may require a stronger hash; conflating the two would silently downgrade that choice into a failed comparison.

authority.parent is a digest, not an id

The change with the most reasoning behind it upstream. A digest names nothing that can be fetched — so verification never depends on network availability, a verifier cannot be induced to make a request against an address of the holder's choosing, and nobody hosting an identifier learns when a credential is used. It also binds a link to the exact claims its issuer narrowed from: re-issuing a parent orphans its children (each must be re-derived, which for a chain of narrowing authority is intended), while re-proofing leaves them undisturbed.

AttenuationParentHasNoId is deprecated and never returned — not needing a top-level identifier merely to be referenced is precisely what the change was for. Adds attenuate_from_json(), and an AuthorityError::Digest kept distinct from BrokenLink, since a malformed chain and a widening one are different findings.

The VDC

Previously a DelegationCredential type string over a bare subject: no delegation object, no edge, no chain verification. Now all three.

  • DelegationGrantscope, parent, maxDepth, accepts.
  • new_delegate_vdc() and accepts() — the grant/acceptance edge. The acceptance is required: a grant alone establishes what the delegator appointed, not what the delegate agreed to, and a delegator cannot produce the countersignature. Same consent rule as a membership edge.
  • redelegate() — opt-in, the opposite default from a VAC's attenuation. A delegate speaks in the principal's name, so the principal keeps the register of who may do so.
  • delegation::verify_chain — returns what the chain appoints for and deliberately not whether the act is permitted. A VDC moves the permission question; it does not answer it. Whether the principal may perform the act is the caller's check, and the reach of a delegated act is the intersection of the two.

Also

  • validUntil is REQUIRED on a VAC and a VDC. For a VAC the reasoning is sharper: nothing about the subject's standing is consulted at verification, so authority that never expires is authority nobody can withdraw by waiting.
  • credentialStatus is modelled, and DTGCommon::extra preserves unmodelled top-level members. Both because a parse-then-re-serialise used to drop them and silently change a credential's digest. This narrows rather than closes the hazard — a timestamp is still normalized on the way out — so the wire-form constructors stay the safe habit, with a test pinning exactly that. Status is modelled but not resolved; no revocation checking happens anywhere in this crate.

One real bug

new_member_vmc probed credentialSubject for digest to tell a grant from an acknowledgement. After the rename it would have accepted an acknowledgement as a grant — and acknowledging one forms no edge.

Deliberately not implemented

Three VAC changes are in flight upstream and are not here:

PR What it adds
#39 Revocation via credentialStatus, cascading to everything attenuated below
#40 A maxAttenuation ceiling, bounding depth per-ancestor rather than only globally
#41 Key control at invocation, which removes audience as redundant

audience is kept until #41 lands rather than removing a shipped field twice. Neither chain verifier establishes that the party presenting a chain controls the leaf's subject — both credentials are non-bearer, and that demonstration belongs to the trust task in which they are exercised.

Correlation scope (#30) retired the R-DID / M-DID / C-DID / P-DID types for a holder-declared pairwise | directed | public. Nothing to implement yet — the spec has not named the property that carries the declaration — so this only drops the retired names from the docs.

Dependencies

sha2 0.10 → 0.11 is the one worth noting: affinidi-data-integrity already pulled sha2 0.11 through affinidi-crypto, so the library was linking two copies and hashing with the older one. The library graph now carries a single sha2, and digest output is unchanged. Dev-only: affinidi-tdk 0.10 → 0.12, chacha20poly1305 0.10 → 0.11, rand 0.8 → 0.10 (the example moves to rand::rng() / rand::Rng).

Verification

115 tests pass, up from 73. tests/delegation_chain.rs is new at 28 cases and authority_chain.rs gained 8 for the digest-parent change; like the existing suite they are mostly attacks, since what makes either credential safe is a verifier refusing a chain that widens.

Clippy clean at zero warnings, cargo fmt clean, rustdoc clean, --no-default-features builds, cargo package succeeds.

The data_room example now runs both credentials side by side — the agent acting as itself under a VAC, and a scheduler acting in Bob's name under a VDC, with the VDC explicitly refused when offered as authority. That contrast is the whole reason they are separate credentials: a verifier can always tell which it was shown.

Three of these are semver-major. `sha2` 0.10 -> 0.11 is the one worth noting:
`affinidi-data-integrity` already pulls `sha2` 0.11 through `affinidi-crypto`, so
the library was linking two copies of it and hashing with the older one. The
library graph now carries a single `sha2`. Digest output is unchanged, which the
tests pinning known digests confirm.

The rest are dev-dependencies, so they affect the examples rather than the crate:
`affinidi-tdk` 0.10 -> 0.12, `chacha20poly1305` 0.10 -> 0.11, and `rand` 0.8 ->
0.10. The `data_room` example moves to the `rand` 0.10 API (`rand::rng()`,
`rand::Rng`) and off the now-deprecated `Key::from_slice` / `Nonce::from_slice`.

Everything else moves within its existing range via `cargo update`. Only
`generic-array` stays behind latest, held there by a transitive constraint.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Both drafts 0.6.0 tracked have merged upstream - the VAC as PR #29 and the VDC as
PR #19 - and the digest encoding changed underneath them. Breaking on the wire as
well as in the API.

The digest encoding. Working Draft 02 replaced `sha256:<lowercase hex>` with a
base58btc multibase multihash and renamed the property from `digest` to
`digestMultibase`. `digest_multibase()` becomes the conformant digest and now
excludes the top-level `proof`, which is what it was deprecated for; `digest()`
and `digest_json()` are deprecated but emit the old form unchanged, so a caller
migrating can recompute a stored digest to compare. The old property name is
still accepted when parsing, so credentials issued against WD01 deserialize -
their values then fail with `InvalidDigest` rather than as a silent mismatch.

Digests are compared as decoded bytes, never strings. The specification requires
it because one digest has more than one spelling, and a string comparison would
report a mismatch where the two credentials agree. An unimplemented hash
algorithm is rejected rather than treated as a mismatch: a governing party may
require a stronger hash, and conflating the two would silently downgrade that
choice into a failed comparison.

`authority.parent` is now a digest rather than an `id`. A digest names nothing
that can be fetched, so verification cannot come to depend on network
availability, a verifier cannot be induced to request an address of the holder's
choosing, and nobody hosting an identifier learns when a credential is used. It
also binds a link to the exact claims its issuer narrowed from: re-issuing a
parent orphans its children, while re-proofing leaves them alone. Adds
`attenuate_from_json` for a parent that arrived from a counterparty, and an
`AuthorityError::Digest` kept distinct from `BrokenLink` - a malformed chain and
a widening one are different findings.

The VDC, which was previously a type string over a bare subject. Now carries a
`delegation` object, forms a grant/acceptance edge, and has chain verification.
The acceptance is required: a grant alone establishes what the delegator
appointed, not what the delegate agreed to, and a delegator cannot produce the
countersignature. Re-delegation is opt-in, the opposite default from attenuation,
because a delegate speaks in the principal's name and the principal keeps the
register of who may do so. `delegation::verify_chain` returns what the chain
appoints for and deliberately not whether the act is permitted - a VDC moves the
permission question, it does not answer it.

`validUntil` becomes REQUIRED on a VAC and a VDC. For a VAC the reasoning is
sharper: nothing about the subject's current standing is consulted at
verification, so authority that does not expire is authority nobody can withdraw
by waiting.

`credentialStatus` is modelled and `DTGCommon::extra` preserves unmodelled
top-level members, both because a parse-then-re-serialise used to drop them and
silently change a credential's digest. That narrows rather than closes the hazard
- a timestamp is still normalized on the way out - so the wire-form constructors
remain the safe habit, with a test pinning exactly that. Status is modelled but
not resolved; no revocation checking happens anywhere in this crate.

Fixes one real bug: `new_member_vmc` probed `credentialSubject` for `digest` to
tell a grant from an acknowledgement, and after the rename would have accepted an
acknowledgement as a grant.

Deliberately not implemented: the three VAC changes in flight upstream -
revocation (PR #39), a `maxAttenuation` ceiling (PR #40), and key control at
invocation, which removes `audience` (PR #41). `audience` stays until that lands
rather than removing a shipped field twice. Correlation scope (PR #30) retired
the R-DID/M-DID/C-DID/P-DID types, but the specification has not yet named the
property carrying the declaration, so only the retired names leave the docs.

115 tests, up from 73. `tests/delegation_chain.rs` is new and, like
`authority_chain.rs`, is mostly attacks: what makes either credential safe is a
verifier refusing a chain that widens.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 requested a review from a team as a code owner September 8, 2026 13:36
@stormer78
stormer78 merged commit 985b810 into main Sep 8, 2026
7 checks passed
@stormer78
stormer78 deleted the feat/working-draft-02 branch September 8, 2026 13:44
@affinidi-appsecurity-bot

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

🔎 A manual security review is recommended before merging. Please contact the Security team for specifics and remediation guidance.

ℹ️ Detailed findings are not published on public repositories; the Security team holds the complete report.

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.

2 participants