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.