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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added — `credentialStatus` can be set on a credential being built

[`DTGCredential::with_credential_status`] and its non-consuming `set_credential_status`
attach the status mechanism through which a verifier determines whether a credential has
been revoked. `DTGCommon::credential_status` has been modelled since 0.7.0, but only so
that an entry already on the wire survived a round trip without changing the digest — a
credential built by one of the `new_*` constructors had no way to acquire one short of
reaching through `credential_mut`.

This is CONDITIONAL on a VDC rather than required, which is why it is a setter and not a
constructor parameter. A verifier must be able to establish that an appointment is in force
without contacting the delegator, and either of two things satisfies that: a `validUntil`
short enough that expiry alone bounds the exposure, or a status entry it can check. A VDC
MUST carry one where its validity exceeds the freshness window the governing VTC or VTN
defines for delegations, and MAY omit it otherwise. That window is governance this library
does not know, so it cannot decide which side of the condition a given VDC falls on;
demanding the entry from every caller would forbid the short-validity case the
specification prefers.

Neither `delegation::verify_chain` nor `authority::verify_chain` resolves the entry.
Revocation remains a live lookup the caller performs.

### Added — `PartialEq` on `DTGCredentialType`

Consumers had to assert by pattern (`matches!`) rather than by equality; `assert_eq!` now
works and reports the actual variant when it fails. `Eq` is derived alongside it.


## [0.8.0] - 2026-09-09

**A VAC is not a bearer credential.** This release implements the rule and removes the
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,49 @@ credentials by `id` has no way to accept one that has none.
> into the JSON after `sign()` produces a document whose proof no longer
> verifies.

## Revocation status

A credential may carry `credentialStatus`, the W3C VC mechanism through which a
verifier determines whether it has been revoked. The entry is opaque here: the
mechanism is chosen by the governing VTC or VTN, and this library neither
selects one nor resolves it. `BitstringStatusListEntry` is the common choice.

The `new_*()` constructors leave it unset. Chain `with_credential_status()`:

```Rust
let vdc = DTGCredential::new_vdc(delegator, delegate, valid_from, valid_to, scope, None)?
.with_credential_status(json!({
"id": "https://example.com/status/3#94567",
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "94567",
"statusListCredential": "https://example.com/status/3"
}));
```

On a VDC this is CONDITIONAL, not required. A verifier MUST be able to establish
that an appointment is currently in force without contacting the delegator, and
two things satisfy that: a `validUntil` short enough that expiry alone bounds the
exposure, with the delegator withdrawing by declining to re-issue; or a status
entry the verifier can check. A VDC MUST carry one where its validity period
exceeds the freshness window the governing VTC or VTN defines for delegations,
and MAY omit it otherwise.

That window is governance this library does not know, which is why this is a
setter rather than a constructor parameter — nothing here can tell which side of
the condition a given VDC falls on. Prefer short validity and re-issuance
wherever the delegator is reachable: a status check is a live lookup that reveals
the verification event to whoever hosts the status list. A long-lived appointment
made in advance of a delegator's unavailability is the case status exists for.

> [!IMPORTANT]
> Set it **before** signing, for the same reason as `id`.

> [!NOTE]
> Neither `delegation::verify_chain` nor `authority::verify_chain` resolves a
> status entry — both verify structure, scope and validity only. Revocation is a
> live lookup you perform.

## Signing credentials

By default the `affinidi-signing` feature is enabled which allows you to sign a
Expand Down
70 changes: 70 additions & 0 deletions src/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,76 @@ impl DTGCredential {
pub fn set_id(&mut self, id: impl Into<String>) {
self.credential.id = Some(id.into());
}

/// Attaches the status mechanism through which a verifier determines whether this
/// credential has been revoked.
///
/// The entry is opaque to this library: the mechanism is chosen by the governing VTC
/// or VTN, and nothing here selects one or resolves it. `BitstringStatusListEntry` is
/// the common choice.
///
/// ```
/// # use chrono::{Duration, Utc};
/// # use dtg_credentials::DTGCredential;
/// # use serde_json::json;
/// let vdc = DTGCredential::new_vdc(
/// "did:example:delegator".to_string(),
/// "did:example:delegate".to_string(),
/// Utc::now(),
/// Utc::now() + Duration::days(90),
/// vec!["sign:invoices".to_string()],
/// None,
/// )
/// .unwrap()
/// .with_credential_status(json!({
/// "id": "https://example.com/status/3#94567",
/// "type": "BitstringStatusListEntry",
/// "statusPurpose": "revocation",
/// "statusListIndex": "94567",
/// "statusListCredential": "https://example.com/status/3"
/// }));
/// assert!(vdc.credential().credential_status.is_some());
/// ```
///
/// # When a VDC needs one
///
/// CONDITIONAL, not required. A verifier MUST be able to establish that an appointment
/// is in force without contacting the delegator, and either of two things satisfies
/// that: a `validUntil` short enough that expiry alone bounds the exposure, or a status
/// entry the verifier can check. A VDC MUST carry one where its validity period exceeds
/// the freshness window the governing VTC or VTN defines for delegations, and MAY omit
/// it otherwise.
///
/// That window is governance this library does not know, so it cannot decide for a
/// caller which side of the condition a given VDC falls on — hence a setter rather than
/// a constructor parameter. Prefer short validity and re-issuance wherever the
/// delegator is reachable: a status check is a live lookup that reveals the
/// verification event to whoever hosts the status list. A long-lived appointment made
/// in advance of a delegator's unavailability is the case this exists for.
///
/// # Set it before signing
///
/// Same caveat as [DTGCredential::with_id] — a Data Integrity proof covers the
/// credential minus its `proof`, so attaching a status entry to an already-signed
/// credential leaves a document whose proof no longer verifies.
///
/// # This library does not check it
///
/// Neither [`crate::delegation::verify_chain`] nor [`crate::authority::verify_chain`]
/// resolves a status entry; both verify structure, scope and validity only. Revocation
/// is a live lookup the caller performs.
pub fn with_credential_status(mut self, status: Value) -> Self {
self.credential.credential_status = Some(status);
self
}

/// Attaches a revocation status mechanism in place.
///
/// The non-consuming form of [DTGCredential::with_credential_status]; the same "before
/// signing" caveat and the same CONDITIONAL rule apply.
pub fn set_credential_status(&mut self, status: Value) {
self.credential.credential_status = Some(status);
}
}

#[cfg(test)]
Expand Down
91 changes: 90 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,11 @@ pub fn digest_json(doc: &Value) -> Result<String, DTGCredentialError> {
}

/// TDG VC Type Identifiers
#[derive(Debug, Clone)]
///
/// `PartialEq` is derived so that a consumer can assert by equality
/// (`assert_eq!(cred.credential_type(), &DTGCredentialType::Delegation)`) rather than by
/// pattern (`matches!`), which reports the actual variant on failure.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DTGCredentialType {
Membership,
Expand Down Expand Up @@ -2363,6 +2367,91 @@ mod tests {
assert!(!grant.acknowledges(&grant).unwrap());
}

/// A VDC's `credentialStatus` is CONDITIONAL, not required: a delegation whose validity
/// exceeds the freshness window the governing VTC or VTN defines MUST carry one, and
/// one short enough to be bounded by expiry alone MAY omit it. This library does not
/// know that window, so the entry is attached rather than demanded — and once attached,
/// it must reach the wire.
#[test]
fn a_vdc_carries_the_credential_status_it_is_given() {
let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);
let valid_until = DateTime::parse_from_rfc3339("2026-12-11T00:00:00Z")
.unwrap()
.with_timezone(&Utc);

let status = serde_json::json!({
"id": "https://delegator.example/status#12",
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "12"
});

let vdc = DTGCredential::new_vdc(
"did:example:delegator".to_string(),
"did:example:delegate".to_string(),
valid_from,
valid_until,
vec!["sign:invoices".to_string()],
None,
)
.expect("a bounded grant is well formed");

// Omitting it is legitimate, so the constructor must not invent one.
assert!(
vdc.credential().credential_status.is_none(),
"a VDC MAY omit `credentialStatus`, so the constructor must not supply one"
);

let vdc = vdc.with_credential_status(status.clone());
assert_eq!(vdc.credential().credential_status.as_ref(), Some(&status));
assert_eq!(wire(&vdc).get("credentialStatus"), Some(&status));

// And it must survive the trip back, or a verifier reading the wire form loses the
// only thing that lets it check revocation.
let parsed: DTGCredential = serde_json::from_value(wire(&vdc)).expect("parses");
assert_eq!(
parsed.credential().credential_status.as_ref(),
Some(&status)
);
}

/// The non-consuming form sets the same field.
#[test]
fn set_credential_status_matches_the_builder() {
let status = serde_json::json!({ "type": "BitstringStatusListEntry" });

let mut vmc = DTGCredential::new_vmc(
"did:example:community".to_string(),
"did:example:member".to_string(),
Utc::now(),
None,
false,
);
vmc.set_credential_status(status.clone());

assert_eq!(vmc.credential().credential_status.as_ref(), Some(&status));
}

/// `DTGCredentialType` derives `PartialEq` so a consumer can assert by equality rather
/// than by pattern, and get the actual variant reported on failure.
#[test]
fn credential_types_compare_by_equality() {
let vdc = DTGCredential::new_vdc(
"did:example:delegator".to_string(),
"did:example:delegate".to_string(),
Utc::now(),
Utc::now() + chrono::Duration::days(1),
vec!["sign:invoices".to_string()],
None,
)
.expect("a bounded grant is well formed");

assert_eq!(vdc.type_(), DTGCredentialType::Delegation);
assert_ne!(vdc.type_(), DTGCredentialType::Membership);
}

/// `credentialStatus` used to be dropped by a parse-then-re-serialise round trip, which
/// silently changed a credential's digest. [`DTGCommon::credential_status`] models it,
/// and this pins that it survives.
Expand Down