From a178f464423564a59416b688766340cbc0cfd3f1 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 9 Sep 2026 20:13:51 +0800 Subject: [PATCH] feat: set `credentialStatus` on a credential being built; `PartialEq` on the type Both from the conformance audit in #10, which the VDC work closed without them. `credentialStatus` has been modelled since 0.7.0, but only so that an entry already on the wire survived a parse-then-re-serialise without changing the credential's digest. A credential built by one of the `new_*` constructors had no way to acquire one short of reaching through `credential_mut`, so this adds `with_credential_status` and `set_credential_status` alongside the existing `with_id`/`set_id` pair, with the same before-signing caveat: a Data Integrity proof covers the credential minus its `proof`, so a status entry spliced in after `sign()` leaves a document whose proof no longer verifies. The audit asked for it as a REQUIRED constructor parameter on `new_vdc`, reading the VDC draft. The text that merged makes it CONDITIONAL: 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 nothing here can 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, and prefers for a stated reason: a status check is a live lookup that reveals the verification event to whoever hosts the status list. Hence a setter rather than a constructor parameter, and the constructor keeps supplying no entry of its own. A long-lived appointment made in advance of a delegator's unavailability is the case the property exists for. Nothing here resolves the entry. Neither `delegation::verify_chain` nor `authority::verify_chain` checks revocation; both verify structure, scope and validity only, and that is unchanged. `PartialEq` (with `Eq`) on `DTGCredentialType` is unrelated and one line: consumers had to assert by pattern, and `assert_eq!` now reports the actual variant when it fails. The deprecated `RCard` variant provokes no warning from the derive. 121 tests, up from 118. Refs: trustoverip/dtgwg-cred-spec#19 Refs: #10 Signed-off-by: Glenn Gore --- CHANGELOG.md | 28 ++++++++++++++++ README.md | 43 ++++++++++++++++++++++++ src/create.rs | 70 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 231 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a23984..cead44f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index df32cd3..410c8a3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/create.rs b/src/create.rs index cc56b5c..9cce6b0 100644 --- a/src/create.rs +++ b/src/create.rs @@ -1084,6 +1084,76 @@ impl DTGCredential { pub fn set_id(&mut self, id: impl Into) { 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)] diff --git a/src/lib.rs b/src/lib.rs index 425e8fb..4828d79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -622,7 +622,11 @@ pub fn digest_json(doc: &Value) -> Result { } /// 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, @@ -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.