Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.8.0] - 2026-09-09

**A VAC is not a bearer credential.** This release implements the rule and removes the
field that was standing in for it.

### Changed — `verify_chain` requires the presenter to be the leaf's subject (breaking)

`authority::verify_chain` already took a `presenter`, and used it for one thing: comparing
it against the leaf's optional `audience`. A leaf without an `audience` was therefore
accepted from **anybody**, which made a captured presentation a bearer token — the chain
names what may be done, not who is doing it.

It now requires the leaf to grant to `presenter`, and refuses otherwise with the new
`AuthorityError::NotThePresenter`. This is the rule
[PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41) states normatively: a
verifier MUST NOT accept a party as holding the authority a VAC confers unless that party
demonstrates control of the verification method associated with the presented VAC's
`credentialSubject.id`. Only the leaf's subject demonstrates anything — the links above it
are not present and are asked for nothing, which is what keeps attenuation working.

Both known consumers were already doing this by hand, having each hit the gap
independently: `vti-rooms-dtg`'s chain verifier and its nomination check both call
`verify_chain` and then re-compare the subject themselves, with a comment explaining why
they must. Two copies of a check is one place for it to be forgotten, so it moves here.

`presenter` must be an identifier whose key control the caller has already established for
that request. Passing a value read out of the request body reduces the check to a string
comparison an attacker chooses both sides of.

### Removed — `authority.audience` (breaking, on the wire and in the API)

Gone from `AuthorityGrant`, from `attenuate` and `attenuate_from_json` (which each lose
their trailing `Option<String>` parameter), and from the verifier along with
`AuthorityError::WrongAudience`.

Once the presenter must be the subject, an `audience` can only name that same subject —
adding nothing — or name somebody else, which no presentation can ever satisfy. PR #41
removes it for exactly that reason, and it is removed here rather than kept as a weaker
second check.

The property was also being read two incompatible ways, which is what brought this
forward: this library compared it to the **presenter**, while
`trusttasks.org/spec/rooms/keys/present/0.1` described it as the party the presentation is
**for** — "a host's identifier, normally". An implementation that followed the registry
minted leaves the verifier refused every time. See
[dtgwg-trust-tasks-tf#414](https://github.com/trustoverip/dtgwg-trust-tasks-tf/issues/414).
The destination question is real and is answered one layer up, by the trust task
document's `recipient` member, which its `proof` covers.

**Migrating.** Drop the final argument from `attenuate`/`attenuate_from_json` calls. Where
you passed the agent's DID, it was already redundant with `subject`. Where you passed a
verifier's or host's identifier, that binding now belongs on the request document, not the
credential. Callers already comparing the leaf's subject to the presenter can delete that
check.


## [0.7.0] - 2026-09-08

Brings the library up to **Working Draft 02** of the DTG Core Credentials
Expand Down Expand Up @@ -134,6 +190,7 @@ this crate.

Three changes to the VAC are in flight upstream and are not here. `audience` is kept
until the last of them lands, rather than removing a shipped field twice.
*(Superseded by 0.8.0, which implements PR #41 and removes `audience`.)*

- Revocation via `credentialStatus`, cascading to everything attenuated below
([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)).
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "dtg-credentials"
description = "Decentralized Trust Graph (DTG) Credentials Library"
version = "0.7.0"
version = "0.8.0"
edition = "2024"
publish = true
authors = ["Glenn Gore <glenn@affinidi.com>"]
Expand Down
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,15 +237,23 @@ different claims orphans its children, while re-proofing it leaves them alone.
For a VAC that arrived from a counterparty, use `attenuate_from_json()` and give it
the bytes you received.

**A VAC is not a bearer credential.** `verify_chain()` takes the presenter and requires
the leaf to grant to it, so a captured presentation is worthless to whoever captured it.
Pass an identifier whose key control you have already established for *this* request — the
DID a transport authenticated, or one a signature over the request proved — never one read
out of the request body.

That rule is why there is no `audience`. Equipping an agent means naming the agent in
`subject`; a second field naming who may present could then only repeat the subject or
contradict it. Where a presentation may be *sent* is a different question, and it belongs
to the trust task carrying it rather than to the credential.

> [!NOTE]
> Three upstream changes to the VAC are **not** implemented yet: revocation via
> Two upstream changes to the VAC are **not** implemented yet: revocation via
> `credentialStatus`, cascading to everything attenuated below
> ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); a
> `maxAttenuation` ceiling ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40));
> and a key-control demonstration at invocation, which removes `audience` as
> redundant ([PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41)).
> `audience` is kept until that lands rather than being removed twice. A verified
> chain is not by itself evidence that the party presenting it is the leaf's subject.
> ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); and a
> `maxAttenuation` ceiling
> ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40)).

## Delegation (VDC)

Expand Down
8 changes: 4 additions & 4 deletions examples/data_room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
//! 2. Alice invites Bob — a **VIC**, issued by the room.
//! 3. Bob presents it and receives a **VMC pair** (membership) and a **VAC** (what he may do).
//! 4. Bob writes a record. Sealed under the epoch key, AAD-bound to its location.
//! 5. Bob attenuates a **read-only, four-hour, audience-bound VAC to his agent**, which
//! recalls the record. The agent never holds Bob's own authority.
//! 5. Bob attenuates a **read-only, four-hour VAC to his agent**, which recalls the
//! record. The agent never holds Bob's own authority, and — because a VAC is not a
//! bearer credential — nobody but the agent can present what the agent was granted.
//! 6. Alice removes Bob. The epoch rotates and the new key is sealed only to who remains.
//! Bob's agent can still read what it already could — and nothing written after.
//! 7. The host view: every byte the operator holds.
Expand Down Expand Up @@ -296,11 +297,10 @@ async fn main() -> Result<()> {
vec!["read".into()],
now,
now + Duration::hours(4),
Some(agent_did.clone()),
)?
.with_id("urn:uuid:vac-agent");
agent_vac.sign(&bob_secret, None).await?;
println!("Bob issued his agent a VAC: read only · 4 hours · audience-bound to the agent");
println!("Bob issued his agent a VAC: read only · 4 hours · presentable only by the agent");

// The agent presents the whole chain; the verifier walks it to the room.
let chain = vec![agent_vac.clone(), bob_vac.clone()];
Expand Down
76 changes: 52 additions & 24 deletions src/authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
//! | No link may widen `scope` | authority earned in one room used in another |
//! | No link may outlive its parent | an expiry escaped by re-delegation |
//! | Each link's issuer must be its parent's subject | grafting someone else's grant onto your own |
//! | `audience`, where set, must be the presenter | a leaked credential used by whoever holds it |
//! | The leaf's subject must be the presenter | a captured presentation replayed by whoever caught it |
//! | Depth is bounded | a denial-of-service against the verifier, which walks every link |
//! | Every link must carry `validUntil` | authority nobody can withdraw by waiting |
//!
Expand All @@ -37,18 +37,41 @@
//! which an identifier could not do: a parent re-issued with different claims does not
//! carry its old children with it.
//!
//! # A VAC is not a bearer credential
//!
//! [`verify_chain`] takes a `presenter` and requires the leaf to grant to it. That is the
//! rule [PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41) states normatively
//! — *a verifier MUST NOT accept a party as holding the authority a VAC confers unless that
//! party demonstrates control of the verification method associated with the presented
//! VAC's `credentialSubject.id`* — and it is why this module no longer has an `audience`.
//!
//! An earlier draft of the VAC carried an OPTIONAL `audience` naming the DID that had to
//! present the credential, and this module compared it against `presenter`. Once the
//! presenter must be the subject, that field can only name the same party (adding nothing)
//! or a different one (satisfiable by nobody), so it was removed rather than kept as a
//! weaker second check. The destination question it was sometimes read as answering —
//! *where* may this be presented — is not the credential's to answer; it belongs to the
//! trust task carrying the presentation, which binds its own recipient.
//!
//! **What `presenter` must be.** The identifier of a party whose key control the caller has
//! already established for *this request* — the DID a transport authenticated, or one a
//! signature over the request proved. Passing an identifier the caller merely read out of
//! the request body reduces this check to a string comparison an attacker chooses both
//! sides of.
//!
//! **Only the leaf's subject demonstrates anything.** The parties named in the links above
//! it are not present and are asked for nothing. Requiring otherwise would defeat
//! attenuation, whose whole purpose is that the party who attenuated is not in the loop
//! when its agent acts.
//!
//! # Still ahead of this module
//!
//! Three changes to the VAC are in flight upstream and are **not** implemented here:
//! Two changes to the VAC are in flight upstream and are **not** implemented here:
//! revocation via `credentialStatus`, cascading to everything attenuated below
//! ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); a `maxAttenuation`
//! ceiling bounding depth per-ancestor rather than only globally
//! ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40)); and a key-control
//! demonstration at invocation, which removes `audience` as redundant
//! ([PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41)). Until they land, a
//! caller wanting revocation must check [`crate::DTGCommon::credential_status`] itself, and
//! a chain verified here is not evidence that the party presenting it is the leaf's
//! subject.
//! ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); and a
//! `maxAttenuation` ceiling bounding depth per-ancestor rather than only globally
//! ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40)). Until they land, a
//! caller wanting revocation must check [`crate::DTGCommon::credential_status`] itself.

use chrono::{DateTime, Utc};

Expand Down Expand Up @@ -187,13 +210,15 @@ pub enum AuthorityError {
action: String,
},

/// A link was presented by a party other than its bound audience.
#[error("chain link {index} is bound to audience `{audience}`, presented by `{presenter}`")]
WrongAudience {
/// Position in the chain, leaf first.
index: usize,
/// Who the link is bound to.
audience: String,
/// The leaf grants to somebody other than the party presenting it.
///
/// A VAC is evidence that authority was conferred on somebody. It is not evidence that
/// whoever handed it over is that somebody, and a verifier that conflated the two would
/// authorize every captured presentation.
#[error("the chain's leaf grants to `{subject}`, but it was presented by `{presenter}`")]
NotThePresenter {
/// Who the leaf grants to.
subject: String,
/// Who presented it.
presenter: String,
},
Expand Down Expand Up @@ -292,15 +317,18 @@ pub fn verify_chain(
}
}

// The leaf must be presentable by whoever is presenting it.
// Key control at invocation: the leaf must grant to whoever is presenting it.
//
// Without this a presentation is a bearer object — it names what may be done, not who
// is doing it — so anyone who observes one inherits everything it confers. The check is
// only as good as `presenter`: see the module docs on what a caller must have
// established before passing one.
let leaf = &chain[0];
let leaf_grant = leaf.credential().authority().expect("checked above");
if let Some(audience) = &leaf_grant.audience
&& audience != presenter
{
return Err(AuthorityError::WrongAudience {
index: 0,
audience: audience.clone(),
let leaf_subject = leaf.credential().subject();
if leaf_subject != presenter {
return Err(AuthorityError::NotThePresenter {
subject: leaf_subject.to_string(),
presenter: presenter.to_string(),
});
}
Expand Down
16 changes: 7 additions & 9 deletions src/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,6 @@ impl DTGCredential {
scope,
actions,
parent: None,
audience: None,
},
}),
..Default::default()
Expand Down Expand Up @@ -314,8 +313,13 @@ impl DTGCredential {
/// - `self` must be a VAC.
/// - `actions` must be a subset of what `self` confers.
/// - `valid_until` must not exceed `self`'s.
/// - `audience` binds the derived credential to one presenter; strongly recommended
/// when equipping an agent, since it makes a leaked credential useless to anyone else.
///
/// # Binding the derivative to the agent is `subject`, not a separate field
///
/// A VAC is not a bearer credential: [crate::authority::verify_chain] requires the
/// party presenting the leaf to be its subject. So equipping an agent means naming the
/// agent in `subject`, and there is nothing further to bind. An earlier version of this
/// method took an `audience` for that job; it was removed with the property.
///
/// # Digests the model
///
Expand All @@ -330,7 +334,6 @@ impl DTGCredential {
actions: Vec<String>,
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
audience: Option<String>,
) -> Result<Self, DTGCredentialError> {
let parent_grant = self
.credential()
Expand All @@ -346,7 +349,6 @@ impl DTGCredential {
actions,
valid_from,
valid_until,
audience,
)
}

Expand All @@ -369,7 +371,6 @@ impl DTGCredential {
actions: Vec<String>,
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
audience: Option<String>,
) -> Result<Self, DTGCredentialError> {
let object = parent
.as_object()
Expand Down Expand Up @@ -425,7 +426,6 @@ impl DTGCredential {
actions,
valid_from,
valid_until,
audience,
)
}

Expand All @@ -440,7 +440,6 @@ impl DTGCredential {
actions: Vec<String>,
valid_from: DateTime<Utc>,
valid_until: DateTime<Utc>,
audience: Option<String>,
) -> Result<Self, DTGCredentialError> {
if actions.is_empty() {
return Err(DTGCredentialError::EmptyAuthorityActions);
Expand Down Expand Up @@ -472,7 +471,6 @@ impl DTGCredential {
scope: parent_grant.scope.clone(),
actions,
parent: Some(parent_digest),
audience,
},
}),
..Default::default()
Expand Down
23 changes: 5 additions & 18 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,9 +636,11 @@ pub enum DTGCredentialType {
/// specified actions within a named scope governed by the issuer.
///
/// Merged into DTG Core Credentials at Working Draft 02
/// (`trustoverip/dtgwg-cred-spec` PR #29). Three further changes to the VAC are in
/// flight and not implemented here — revocation (PR #39), a `maxAttenuation` ceiling
/// (PR #40), and key-control at invocation, which removes `audience` (PR #41).
/// (`trustoverip/dtgwg-cred-spec` PR #29). Key control at invocation — a VAC is not a
/// bearer credential — is implemented in [crate::authority::verify_chain], ahead of
/// PR #41 which states it normatively and removes the `audience` property it made
/// redundant. Two further changes are in flight and not implemented here: revocation
/// (PR #39) and a `maxAttenuation` ceiling (PR #40).
Authority,

/// Verifiable Delegation Credential (VDC) — establishes that one entity may act in
Expand Down Expand Up @@ -1288,21 +1290,6 @@ pub struct AuthorityGrant {
/// because the digest excludes `proof`.
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,

/// A DID that MUST be the presenter for this VAC to be accepted.
///
/// Absent means any holder may present it. Setting it is what makes a leaked agent
/// credential useless to anyone but that agent.
///
/// # Slated for removal upstream
///
/// `trustoverip/dtgwg-cred-spec` PR #41 removes this property, having made it
/// redundant: a VAC is not a bearer credential, and requiring the leaf's subject to
/// demonstrate key control at invocation already establishes that the presenter is the
/// subject. It is kept here until that lands, because removing a shipped field twice
/// is worse than removing it once.
#[serde(skip_serializing_if = "Option::is_none")]
pub audience: Option<String>,
}

/// The `delegation` object a [CredentialSubject::Delegation] carries.
Expand Down
Loading