Skip to main content

dtg_credentials/
lib.rs

1/*! Decentralized Trust Graph (DTG) Credentials
2*/
3
4use affinidi_data_integrity::DataIntegrityProof;
5#[cfg(feature = "affinidi-signing")]
6use affinidi_data_integrity::{DataIntegrityError, SignOptions, VerifyOptions};
7#[cfg(feature = "affinidi-signing")]
8use affinidi_secrets_resolver::secrets::Secret;
9use chrono::{DateTime, Utc};
10use multibase::Base;
11use serde::{Deserialize, Serialize, Serializer};
12use serde_json::Value;
13use sha2::{Digest, Sha256};
14use std::fmt::Display;
15use thiserror::Error;
16
17pub mod authority;
18pub mod create;
19pub mod delegation;
20
21/// What W3C VC Format is the credential using?
22#[derive(Clone, Copy, Debug)]
23pub enum W3CVCVersion {
24    /// <https://www.w3.org/2018/credentials/v1>
25    V1_1,
26
27    /// <https://www.w3.org/ns/credentials/v2>
28    V2_0,
29}
30
31impl TryFrom<&[String]> for W3CVCVersion {
32    type Error = DTGCredentialError;
33
34    /// Will return the W3C Version from the context array
35    fn try_from(types: &[String]) -> Result<Self, Self::Error> {
36        if types.contains(&"https://www.w3.org/2018/credentials/v1".to_string()) {
37            Ok(W3CVCVersion::V1_1)
38        } else if types.contains(&"https://www.w3.org/ns/credentials/v2".to_string()) {
39            Ok(W3CVCVersion::V2_0)
40        } else {
41            Err(DTGCredentialError::UnknownVCVersion)
42        }
43    }
44}
45
46/// Errors related to DTG Credentials
47#[derive(Error, Debug)]
48pub enum DTGCredentialError {
49    #[error("Unknown credential type")]
50    UnknownCredential,
51
52    #[cfg(feature = "affinidi-signing")]
53    #[error("Data Integrity Error: {0}")]
54    DataIntegrity(#[from] DataIntegrityError),
55
56    #[error("Credential is not signed")]
57    NotSigned,
58
59    #[error("Unknown W3C VC Version")]
60    UnknownVCVersion,
61
62    /// An AuthorityCredential (VAC) carried an empty `actions` list.
63    ///
64    /// Emptiness is never a wildcard: a VAC conferring no actions confers nothing, and is
65    /// rejected rather than treated as unrestricted.
66    #[error("AuthorityCredential carries an empty actions list, which confers nothing")]
67    EmptyAuthorityActions,
68
69    /// [DTGCredential::attenuate] was called on a credential that is not a VAC.
70    #[error("not an AuthorityCredential, so there is no authority to attenuate")]
71    NotAnAuthorityCredential,
72
73    /// [DTGCredential::attenuate] was called on a VAC with no `id`.
74    ///
75    /// No longer produced. Working Draft 02 makes `authority.parent` a **digest** of the
76    /// parent rather than its `id`, precisely so that no credential needs a top-level
77    /// identifier merely in order to be referenced.
78    #[deprecated(
79        since = "0.7.0",
80        note = "Never returned. `authority.parent` is a digest as of Working Draft 02, so a \
81                parent VAC no longer needs an `id` to be attenuated. This variant will be \
82                removed in a future release."
83    )]
84    #[error("cannot attenuate a credential with no id — the derived VAC could not name it")]
85    AttenuationParentHasNoId,
86
87    /// A digest value was not a well-formed `digestMultibase`.
88    ///
89    /// Either the multibase envelope or the multihash inside it failed to decode. A
90    /// `sha256:<hex>` value produced against Working Draft 01 lands here, which is the
91    /// intended outcome: it is reported rather than silently compared as unequal.
92    #[error("not a well-formed digestMultibase value: {0}")]
93    InvalidDigest(String),
94
95    /// A digest named a hash algorithm this library does not implement.
96    ///
97    /// The specification permits a governing party to require a stronger hash, and carries
98    /// the algorithm in the value itself. A verifier MUST reject an algorithm it does not
99    /// accept rather than treating it as a mismatch — hence a distinct error.
100    #[error("digest uses multihash algorithm 0x{0:x}, which this library does not accept")]
101    UnsupportedDigestAlgorithm(u64),
102
103    /// A DelegationCredential (VDC) was not a well-formed grant or acceptance.
104    #[error("malformed DelegationCredential: {0}")]
105    MalformedDelegation(String),
106
107    /// A delegation acknowledgement was built against something that is not a
108    /// delegation grant.
109    #[error("Not a delegation grant: {0}")]
110    NotADelegationGrant(String),
111
112    /// An attenuation attempted to confer more than its parent held.
113    #[error("attenuation would widen the parent grant: {0}")]
114    AttenuationWidens(String),
115
116    /// A WitnessCredential (VWC) was missing the REQUIRED `taskContext` property
117    #[error("WitnessCredential is missing the required taskContext property")]
118    MissingTaskContext,
119
120    /// The credential could not be canonicalized (JCS, RFC 8785) for digesting
121    #[error("Could not canonicalize credential: {0}")]
122    Canonicalization(String),
123
124    /// A credential was not of the type an operation requires
125    #[error("Expected a {expected}, got a {got}")]
126    WrongCredentialType { expected: String, got: String },
127
128    /// A membership acknowledgement was built against something that is not a
129    /// community-issued membership grant
130    #[error("Not a community-issued membership grant: {0}")]
131    NotAMembershipGrant(String),
132}
133
134/// Defined DTG Credentials
135#[derive(Serialize, Deserialize, Debug, Clone)]
136#[serde(try_from = "DTGCommon")]
137pub struct DTGCredential {
138    /// The DTG Credential inner struct
139    #[serde(flatten)]
140    credential: DTGCommon,
141
142    /// Type of the credential
143    #[serde(skip)]
144    type_: DTGCredentialType,
145
146    /// W3C VC Version
147    #[serde(skip)]
148    version: W3CVCVersion,
149}
150
151impl DTGCredential {
152    /// get the raw credential
153    pub fn credential(&self) -> &DTGCommon {
154        &self.credential
155    }
156
157    /// Get the raw credential as mutable
158    pub fn credential_mut(&mut self) -> &mut DTGCommon {
159        &mut self.credential
160    }
161
162    /// Has this credential been signed?
163    pub fn signed(&self) -> bool {
164        self.credential.signed()
165    }
166
167    /// get the credential type
168    pub fn type_(&self) -> DTGCredentialType {
169        self.type_.clone()
170    }
171
172    /// This credential's own identifier, if it has one.
173    ///
174    /// `None` for a credential built by one of the `new_*` constructors and never given one
175    /// with [DTGCredential::with_id]. See [DTGCommon::id] for why a counterparty may require
176    /// it.
177    pub fn id(&self) -> Option<&str> {
178        self.credential.id()
179    }
180
181    /// Returns the Issuer DID
182    pub fn issuer(&self) -> &str {
183        self.credential.issuer()
184    }
185
186    /// Returns the Subject DID
187    pub fn subject(&self) -> &str {
188        self.credential.subject()
189    }
190
191    /// Returns the valid_from timestamp
192    pub fn valid_from(&self) -> DateTime<Utc> {
193        self.credential.valid_from()
194    }
195
196    /// Returns the valid until timestamp
197    pub fn valid_until(&self) -> Option<DateTime<Utc>> {
198        self.credential.valid_until()
199    }
200
201    /// The `threadId` of the trust task exchange this credential was issued in, if set
202    ///
203    /// This is always `Some` for [DTGCredentialType::Witness] credentials, where the spec
204    /// makes `taskContext` REQUIRED.
205    pub fn task_context(&self) -> Option<&str> {
206        self.credential.task_context()
207    }
208
209    /// This credential's digest, in the encoding a credential that references it carries —
210    /// a member-issued VMC acknowledging a membership grant, a VWC attesting an edge
211    /// credential, or the `parent` of an attenuated VAC.
212    ///
213    /// Per DTG Core Credentials [Digest Encoding], that is the SHA-256 hash of the
214    /// credential's JSON representation **excluding its top-level `proof` member**,
215    /// canonicalized with the JSON Canonicalization Scheme
216    /// ([JCS, RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)), wrapped in a
217    /// `sha2-256` multihash and encoded base58btc with a multibase `z` prefix.
218    ///
219    /// [Digest Encoding]: https://github.com/trustoverip/dtgwg-cred-spec
220    ///
221    /// # Why `proof` is excluded
222    ///
223    /// The digest binds to what the credential *says*, not to a particular signature over
224    /// it. A referencing credential therefore survives a re-proofing of its referent: a
225    /// re-signed grant carrying identical claims still satisfies an acknowledgement made
226    /// against the earlier signature. It also means the digest can be computed before the
227    /// referent is signed, and is stable whichever of its proofs a holder happens to have.
228    ///
229    /// # Prefer the wire form for a credential you received
230    ///
231    /// This digests the model. [`DTGCommon::extra`] carries top-level members this library
232    /// does not model through a round trip, so for most received credentials the two agree
233    /// — but a member *inside* `credentialSubject` that the subject types do not model is
234    /// still not represented. Where you still hold the bytes a counterparty sent, digest
235    /// those with [`digest_multibase_json`].
236    pub fn digest_multibase(&self) -> Result<String, DTGCredentialError> {
237        let unsigned = DTGCommon {
238            proof: None,
239            ..self.credential.clone()
240        };
241        let value = serde_json::to_value(&unsigned)
242            .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
243        digest_multibase_json(&value)
244    }
245
246    /// This credential's digest in the superseded `sha256:<hex>` encoding.
247    #[deprecated(
248        since = "0.7.0",
249        note = "Working Draft 02 replaced the `sha256:<hex>` digest with a base58btc \
250                multibase multihash under the property name `digestMultibase`. Use \
251                DTGCredential::digest_multibase. This method will be removed in a future \
252                release."
253    )]
254    pub fn digest(&self) -> Result<String, DTGCredentialError> {
255        let unsigned = DTGCommon {
256            proof: None,
257            ..self.credential.clone()
258        };
259        let value = serde_json::to_value(&unsigned)
260            .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
261        #[allow(deprecated)]
262        digest_json(&value)
263    }
264
265    /// The digest this credential carries of the credential it references, if it carries one.
266    ///
267    /// `Some` for a member-issued VMC (which MUST carry one), for a VWC bound to the edge
268    /// credential it attests, for an attenuated VAC (`authority.parent`), and for a
269    /// derived or accepting VDC (`delegation.parent` / `delegation.accepts`). `None` for a
270    /// community-issued VMC, which MUST omit it, and for a credential that references
271    /// nothing.
272    pub fn subject_digest(&self) -> Option<&str> {
273        match &self.credential.credential_subject {
274            CredentialSubject::Membership(subject) => subject.digest_multibase.as_deref(),
275            CredentialSubject::Witness(subject) => subject.digest_multibase.as_deref(),
276            CredentialSubject::Authority(subject) => subject.authority.parent.as_deref(),
277            CredentialSubject::Delegation(subject) => subject
278                .delegation
279                .accepts
280                .as_deref()
281                .or(subject.delegation.parent.as_deref()),
282            _ => None,
283        }
284    }
285
286    /// Checks that the digest this credential carries matches the credential it claims to
287    /// reference.
288    ///
289    /// Answers one question only — whether the hashes agree. It does not check that the two
290    /// credentials are of the types the reference requires, nor that their issuers and
291    /// subjects line up. For a membership acknowledgement, [DTGCredential::acknowledges]
292    /// checks all of that together and is what a verifier completing an edge should call.
293    ///
294    /// # Compares bytes, not strings
295    ///
296    /// The specification requires a verifier to decode the multibase envelope and the
297    /// multihash inside it, and to compare the algorithm identifier and the raw digest —
298    /// never the encoded strings. Two equal digests can be written differently, and a
299    /// string comparison would report a mismatch where the credentials agree.
300    ///
301    /// Returns `Ok(false)` if the digests do not match, or if this credential carries no
302    /// digest, in which case there is nothing to rely on.
303    ///
304    /// # Errors
305    ///
306    /// [DTGCredentialError::InvalidDigest] if the carried value is not a well-formed
307    /// `digestMultibase` — a Working Draft 01 `sha256:<hex>` value among them — and
308    /// [DTGCredentialError::UnsupportedDigestAlgorithm] if it names a hash this library
309    /// does not implement. Both are reported rather than folded into `Ok(false)`: a digest
310    /// that cannot be read is not a digest that disagrees.
311    pub fn verify_digest(&self, referenced: &DTGCredential) -> Result<bool, DTGCredentialError> {
312        let Some(carried) = self.subject_digest() else {
313            return Ok(false);
314        };
315
316        digests_match(carried, &referenced.digest_multibase()?)
317    }
318
319    /// Does this member-issued VMC acknowledge `grant`, completing that membership edge?
320    ///
321    /// A membership edge is complete only when both VMCs of the pair exist and are valid:
322    /// the community-issued VMC that grants membership, and the member-issued VMC that
323    /// acknowledges it. This checks everything that binds the two together:
324    ///
325    /// 1. `grant` is a `MembershipCredential` carrying no `digest` — a community-issued grant
326    /// 2. `self` is a `MembershipCredential` carrying one — a member-issued acknowledgement
327    /// 3. the two name the same pair of parties, in mirrored roles: this credential's issuer
328    ///    is the grant's subject, and its subject is the grant's issuer
329    /// 4. the `digest` matches the grant
330    ///
331    /// Returns `Ok(false)` where any of those does not hold, rather than distinguishing
332    /// them: a caller deciding whether an edge is complete has one decision to make, and
333    /// every failing case answers it the same way.
334    ///
335    /// # What this does not check
336    ///
337    /// Neither credential's proof, and neither validity window. Both are the caller's to
338    /// verify — proof verification needs a resolver this crate does not hold, and whether a
339    /// window is current is a question about an instant the caller chooses. An edge is
340    /// complete when both VMCs are *valid* as well as bound, and this covers only the
341    /// binding.
342    pub fn acknowledges(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
343        if !matches!(self.type_, DTGCredentialType::Membership)
344            || !matches!(grant.type_, DTGCredentialType::Membership)
345        {
346            return Ok(false);
347        }
348
349        // The grant is the half that MUST omit `digest`; a credential carrying one is an
350        // acknowledgement, and an acknowledgement of an acknowledgement is not an edge.
351        if grant.subject_digest().is_some() {
352            return Ok(false);
353        }
354
355        if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
356            return Ok(false);
357        }
358
359        self.verify_digest(grant)
360    }
361
362    /// Does this delegate-issued VDC accept `grant`, completing that delegation edge?
363    ///
364    /// A delegation edge is complete only when both VDCs exist and are valid: the
365    /// delegator's grant, and the delegate's acceptance of it. This checks everything that
366    /// binds the two together:
367    ///
368    /// 1. `grant` is a `DelegationCredential` carrying `scope` and no `accepts` — a grant
369    /// 2. `self` is a `DelegationCredential` carrying `accepts` — an acceptance
370    /// 3. the two name the same pair of parties in mirrored roles: this credential's issuer
371    ///    is the grant's subject, and its subject is the grant's issuer
372    /// 4. the `accepts` digest matches the grant
373    ///
374    /// Returns `Ok(false)` where any of those does not hold, rather than distinguishing
375    /// them: a caller deciding whether an edge is complete has one decision to make, and
376    /// every failing case answers it the same way.
377    ///
378    /// # What this does not check
379    ///
380    /// Neither credential's proof, neither validity window, and neither's revocation
381    /// status. Nor does it establish that the *delegator* may perform the act in question
382    /// — that is a separate question, asked of the delegator at the time of the act, which
383    /// a VDC moves but never answers. This covers the binding.
384    pub fn accepts(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
385        if !matches!(self.type_, DTGCredentialType::Delegation)
386            || !matches!(grant.type_, DTGCredentialType::Delegation)
387        {
388            return Ok(false);
389        }
390
391        let (Some(acceptance), Some(appointment)) =
392            (self.credential.delegation(), grant.credential.delegation())
393        else {
394            return Ok(false);
395        };
396
397        // The grant is the half carrying `scope` and no `accepts`; accepting an acceptance
398        // is not an edge.
399        if appointment.accepts.is_some() || appointment.scope.is_none() {
400            return Ok(false);
401        }
402        let Some(carried) = &acceptance.accepts else {
403            return Ok(false);
404        };
405
406        if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
407            return Ok(false);
408        }
409
410        digests_match(carried, &grant.digest_multibase()?)
411    }
412
413    /// Returns the proof value if signed else None
414    pub fn proof_value(&self) -> Option<&str> {
415        if let Some(proof) = &self.credential.proof {
416            proof.proof_value.as_deref()
417        } else {
418            None
419        }
420    }
421
422    #[cfg(feature = "affinidi-signing")]
423    /// Sign the credential using W3C Data Integrity Proof with JCS EdDSA 2022
424    /// signing_secret: The secret key to use to sign the credential
425    /// create_time: Optional creation time for the proof, defaults to now if None
426    pub async fn sign(
427        &mut self,
428        signing_secret: &Secret,
429        create_time: Option<DateTime<Utc>>,
430    ) -> Result<DataIntegrityProof, DTGCredentialError> {
431        let mut options = SignOptions::new();
432        if let Some(ts) = create_time {
433            options = options.with_created(ts);
434        }
435
436        let proof = DataIntegrityProof::sign(self, signing_secret, options).await?;
437
438        self.credential.proof = Some(proof.clone());
439        Ok(proof)
440    }
441
442    #[cfg(feature = "affinidi-signing")]
443    /// Verify the credential if you already know the public key bytes
444    /// otherwise use the affinidi_tdk:verify_data() method
445    /// public_key_bytes: The public key bytes to use to verify the credential
446    pub fn verify_proof_with_public_key(
447        &self,
448        public_key_bytes: &[u8],
449    ) -> Result<(), DTGCredentialError> {
450        let proof = if let Some(proof) = &self.credential.proof {
451            proof.clone()
452        } else {
453            use tracing::warn;
454
455            warn!("Trying to verify a DTG Credential that has no proof");
456            return Err(DTGCredentialError::NotSigned);
457        };
458
459        let unsigned = DTGCommon {
460            proof: None,
461            ..self.credential.clone()
462        };
463
464        proof.verify_with_public_key(&unsigned, public_key_bytes, VerifyOptions::new())?;
465        Ok(())
466    }
467
468    /// Is this credential a W3C VC Version 1.1 or 2.0 credential?
469    pub fn get_w3c_vc_version(&self) -> W3CVCVersion {
470        self.version
471    }
472
473    /// returns true if this credential a personhood credential (PHC)
474    pub fn is_personhood_credential(&self) -> bool {
475        if let DTGCredentialType::Membership = self.type_ {
476            self.credential
477                .type_
478                .contains(&"PersonhoodCredential".to_string())
479        } else {
480            false
481        }
482    }
483}
484
485/// The `sha2-256` multihash code, per the [multicodec] table.
486///
487/// [multicodec]: https://www.w3.org/TR/cid-1.0/#multihash
488const MULTIHASH_SHA2_256: u64 = 0x12;
489
490/// Strips a credential's top-level `proof` member, if it has one.
491fn proofless(doc: &Value) -> Value {
492    match doc {
493        Value::Object(members) => {
494            let mut members = members.clone();
495            members.remove("proof");
496            Value::Object(members)
497        }
498        // Not an object: canonicalize as-is. A shape check belongs to the caller, which
499        // has a better error to give than this would.
500        other => other.clone(),
501    }
502}
503
504/// The digest a DTG credential carries of another credential, computed over that
505/// credential in its **wire form**.
506///
507/// This is the encoding DTG Core Credentials calls `digestMultibase`, and every
508/// cross-credential reference in the specification uses it: the member-issued VMC's
509/// `digestMultibase` of the grant it acknowledges, the VWC's of the edge credential it
510/// attests, an attenuated VAC's `authority.parent`, and a VDC's `delegation.parent` and
511/// `delegation.accepts`.
512///
513/// Four steps, per [CID v1.0](https://www.w3.org/TR/cid-1.0/):
514///
515/// 1. canonicalize `doc` with its top-level `proof` member removed, using JCS (RFC 8785);
516/// 2. SHA-256 the resulting UTF-8 bytes;
517/// 3. prefix the `sha2-256` multihash header (`0x12`) and the length (`0x20`);
518/// 4. encode base58btc with the multibase `z` prefix.
519///
520/// # Digest what you received, not what you parsed
521///
522/// Take the document as it arrived. [`DTGCommon::extra`] preserves unmodelled *top-level*
523/// members through a round trip, but the subject types do not model every member a
524/// `credentialSubject` may carry, so a parse-then-re-serialise of an unusual credential
525/// can still differ from the bytes its issuer hashed. Where you hold those bytes, hash
526/// them.
527///
528/// # Why `proof` is excluded
529///
530/// The digest binds to what the credential says, not to a signature over it, so a
531/// reference survives its referent being re-signed. A re-issued credential carries
532/// different claims and therefore a different digest, which is what makes renewal force
533/// re-acknowledgement.
534pub fn digest_multibase_json(doc: &Value) -> Result<String, DTGCredentialError> {
535    let canonical = serde_json_canonicalizer::to_vec(&proofless(doc))
536        .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
537
538    let digest = Sha256::digest(&canonical);
539
540    // multihash prefix: 0x12 = sha2-256, 0x20 = 32 byte digest length. Both are varints,
541    // and both are single-byte at these values.
542    let mut multihash = Vec::with_capacity(2 + digest.len());
543    multihash.push(MULTIHASH_SHA2_256 as u8);
544    multihash.push(digest.len() as u8);
545    multihash.extend_from_slice(&digest);
546
547    Ok(multibase::encode(Base::Base58Btc, &multihash))
548}
549
550/// Decodes a `digestMultibase` value into the algorithm it names and the raw digest bytes.
551///
552/// The specification requires verifiers to compare digests this way rather than as
553/// strings, so that two encodings of the same digest are recognised as equal and an
554/// algorithm the verifier does not accept is *rejected* rather than reported as a
555/// mismatch.
556///
557/// # Errors
558///
559/// [DTGCredentialError::InvalidDigest] if the multibase or multihash envelope is
560/// malformed, or if the declared length does not match the bytes present.
561/// [DTGCredentialError::UnsupportedDigestAlgorithm] if the multihash names anything other
562/// than `sha2-256`.
563pub fn decode_digest_multibase(digest: &str) -> Result<(u64, Vec<u8>), DTGCredentialError> {
564    let (_, bytes) = multibase::decode(digest)
565        .map_err(|e| DTGCredentialError::InvalidDigest(format!("multibase: {e}")))?;
566
567    // Both the code and the length are varints. Every algorithm this library accepts has a
568    // single-byte code and a single-byte length, so a two-byte header is all that is read;
569    // a continuation bit in either is an algorithm we would reject anyway.
570    let (&code, rest) = bytes
571        .split_first()
572        .ok_or_else(|| DTGCredentialError::InvalidDigest("empty multihash".into()))?;
573    if code & 0x80 != 0 {
574        return Err(DTGCredentialError::InvalidDigest(
575            "multi-byte multihash code, which names no algorithm this library accepts".into(),
576        ));
577    }
578    let (&length, raw) = rest
579        .split_first()
580        .ok_or_else(|| DTGCredentialError::InvalidDigest("multihash has no length".into()))?;
581
582    if code as u64 != MULTIHASH_SHA2_256 {
583        return Err(DTGCredentialError::UnsupportedDigestAlgorithm(code as u64));
584    }
585    if length as usize != raw.len() {
586        return Err(DTGCredentialError::InvalidDigest(format!(
587            "multihash declares {length} bytes but carries {}",
588            raw.len()
589        )));
590    }
591
592    Ok((code as u64, raw.to_vec()))
593}
594
595/// Do two `digestMultibase` values refer to the same credential?
596///
597/// Decodes both and compares the algorithm and the raw digest bytes, as
598/// [`decode_digest_multibase`] describes. Never compares the encoded strings.
599pub fn digests_match(left: &str, right: &str) -> Result<bool, DTGCredentialError> {
600    Ok(decode_digest_multibase(left)? == decode_digest_multibase(right)?)
601}
602
603/// A credential's digest in the superseded `sha256:<hex>` encoding.
604#[deprecated(
605    since = "0.7.0",
606    note = "Working Draft 02 replaced the `sha256:<hex>` digest with a base58btc multibase \
607            multihash under the property name `digestMultibase`. Use \
608            digest_multibase_json. This function will be removed in a future release."
609)]
610pub fn digest_json(doc: &Value) -> Result<String, DTGCredentialError> {
611    let canonical = serde_json_canonicalizer::to_vec(&proofless(doc))
612        .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
613
614    const HEX: &[u8; 16] = b"0123456789abcdef";
615    let mut out = String::with_capacity("sha256:".len() + 64);
616    out.push_str("sha256:");
617    for byte in Sha256::digest(&canonical) {
618        out.push(HEX[(byte >> 4) as usize] as char);
619        out.push(HEX[(byte & 0x0f) as usize] as char);
620    }
621    Ok(out)
622}
623
624/// TDG VC Type Identifiers
625///
626/// `PartialEq` is derived so that a consumer can assert by equality
627/// (`assert_eq!(cred.credential_type(), &DTGCredentialType::Delegation)`) rather than by
628/// pattern (`matches!`), which reports the actual variant on failure.
629#[derive(Debug, Clone, PartialEq, Eq)]
630#[non_exhaustive]
631pub enum DTGCredentialType {
632    Membership,
633    Relationship,
634    Invitation,
635    Persona,
636    Endorsement,
637    Witness,
638
639    /// Verifiable Authority Credential (VAC) — confers authority on a party to perform
640    /// specified actions within a named scope governed by the issuer.
641    ///
642    /// Merged into DTG Core Credentials at Working Draft 02
643    /// (`trustoverip/dtgwg-cred-spec` PR #29). Key control at invocation — a VAC is not a
644    /// bearer credential — is implemented in [crate::authority::verify_chain], ahead of
645    /// PR #41 which states it normatively and removes the `audience` property it made
646    /// redundant. Two further changes are in flight and not implemented here: revocation
647    /// (PR #39) and a `maxAttenuation` ceiling (PR #40).
648    Authority,
649
650    /// Verifiable Delegation Credential (VDC) — establishes that one entity may act in
651    /// another's name.
652    ///
653    /// Merged into DTG Core Credentials at Working Draft 02
654    /// (`trustoverip/dtgwg-cred-spec` PR #19).
655    Delegation,
656
657    /// R-Card is no longer a DTG credential type.
658    #[deprecated(
659        since = "0.2.0",
660        note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
661                It was removed from the DTG Core Credentials specification in Working Draft 01 \
662                and will be defined by the planned DTG Verifiable Data Structures specification. \
663                This variant will be removed in a future release."
664    )]
665    RCard,
666}
667
668impl Display for DTGCredentialType {
669    #[allow(deprecated)]
670    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
671        match self {
672            DTGCredentialType::Membership => write!(f, "MembershipCredential"),
673            DTGCredentialType::Relationship => write!(f, "RelationshipCredential"),
674            DTGCredentialType::Invitation => write!(f, "InvitationCredential"),
675            DTGCredentialType::Persona => write!(f, "PersonaCredential"),
676            DTGCredentialType::Endorsement => write!(f, "EndorsementCredential"),
677            DTGCredentialType::Witness => write!(f, "WitnessCredential"),
678            DTGCredentialType::Authority => write!(f, "AuthorityCredential"),
679            DTGCredentialType::Delegation => write!(f, "DelegationCredential"),
680            DTGCredentialType::RCard => write!(f, "RCardCredential"),
681        }
682    }
683}
684
685/// This helps with matching the right credential type to the [DTGCredentialType]
686const DTG_TYPES: [&str; 9] = [
687    "MembershipCredential",
688    "RelationshipCredential",
689    "InvitationCredential",
690    "PersonaCredential",
691    "EndorsementCredential",
692    "WitnessCredential",
693    "AuthorityCredential",
694    "DelegationCredential",
695    "RCardCredential",
696];
697
698impl TryFrom<&[String]> for DTGCredentialType {
699    type Error = DTGCredentialError;
700
701    #[allow(deprecated)]
702    fn try_from(types: &[String]) -> Result<Self, Self::Error> {
703        if let Some(type_) = DTG_TYPES.iter().find(|t| types.contains(&t.to_string())) {
704            match *type_ {
705                "MembershipCredential" => Ok(DTGCredentialType::Membership),
706                "RelationshipCredential" => Ok(DTGCredentialType::Relationship),
707                "InvitationCredential" => Ok(DTGCredentialType::Invitation),
708                "PersonaCredential" => Ok(DTGCredentialType::Persona),
709                "EndorsementCredential" => Ok(DTGCredentialType::Endorsement),
710                "WitnessCredential" => Ok(DTGCredentialType::Witness),
711                "AuthorityCredential" => Ok(DTGCredentialType::Authority),
712                "DelegationCredential" => Ok(DTGCredentialType::Delegation),
713                "RCardCredential" => Ok(DTGCredentialType::RCard),
714                _ => Err(DTGCredentialError::UnknownCredential),
715            }
716        } else {
717            Err(DTGCredentialError::UnknownCredential)
718        }
719    }
720}
721
722/// All DTG Credentials follow a common structure.
723#[derive(Serialize, Deserialize, Debug, Clone)]
724#[serde(rename_all = "camelCase")]
725pub struct DTGCommon {
726    /// JSON-LD links to contexts
727    /// Must contain at least:
728    /// - <https://www.w3.org/ns/credentials/v2>
729    /// - <https://firstperson.network/credentials/dtg/v1>
730    #[serde(rename = "@context")]
731    pub context: Vec<String>,
732
733    /// Credential type identifiers
734    /// Must contain at least:
735    /// DTGCredential
736    /// VerifiableCredential
737    #[serde(rename = "type")]
738    pub type_: Vec<String>,
739
740    /// OPTIONAL identifier for this specific credential, per the W3C VC Data Model.
741    ///
742    /// When present it MUST be a single URL. A `urn:uuid:` URN is the usual choice for a
743    /// credential with no dereferenceable home.
744    ///
745    /// This is the handle a holder or verifier stores the credential *under*, so it is what
746    /// makes re-delivery of the same credential idempotent and re-issuance of a different one
747    /// recognisable as a renewal rather than a duplicate. A counterparty that keys credentials
748    /// by `id` cannot accept one that has none — so issue with an `id` unless you know nobody
749    /// on the other side needs it.
750    ///
751    /// # Set it before signing
752    ///
753    /// A Data Integrity proof covers the credential minus its `proof`, which includes this
754    /// property. Set it while building — [DTGCredential::with_id] — never after
755    /// [DTGCredential::sign], which would leave a document whose proof no longer verifies.
756    #[serde(skip_serializing_if = "Option::is_none", default)]
757    pub id: Option<String>,
758
759    /// DID of the entity issuing this credential
760    pub issuer: String,
761
762    /// ISO 8601 format of when this credentials become valid from
763    #[serde(serialize_with = "iso8601_format", alias = "issuanceDate")]
764    pub valid_from: DateTime<Utc>,
765
766    /// ISO 8601 format of when these credentials are valid to
767    #[serde(serialize_with = "iso8601_format_option")]
768    #[serde(
769        skip_serializing_if = "Option::is_none",
770        alias = "expirationDate",
771        default
772    )]
773    pub valid_until: Option<DateTime<Utc>>,
774
775    /// Identifier (`threadId`) of the trust task exchange in which this credential was issued.
776    ///
777    /// REQUIRED for [DTGCredentialType::Witness] credentials, OPTIONAL for all other DTG
778    /// credential types. A DTG credential without a `taskContext` MUST be interpretable
779    /// standing alone, independent of any exchange.
780    ///
781    /// NOTE: A verifier MUST NOT interpret a `taskContext`-bearing credential as proof that
782    /// the associated trust task completed unless the matching trust task outcome evidence is
783    /// also present and verified.
784    #[serde(skip_serializing_if = "Option::is_none", default)]
785    pub task_context: Option<String>,
786
787    /// The assertion between the entities involved
788    pub credential_subject: CredentialSubject,
789
790    /// A W3C VC status mechanism through which a verifier determines whether this
791    /// credential has been revoked.
792    ///
793    /// Held as an opaque [`Value`]: the mechanism is chosen by the governing VTC or VTN,
794    /// and this library neither selects one nor resolves it. `BitstringStatusListEntry` is
795    /// the common choice.
796    ///
797    /// CONDITIONAL on a VDC — REQUIRED where the appointment outlives the freshness window
798    /// the governing party defines for delegations, and permitted to be absent otherwise,
799    /// with short validity and re-issuance preferred wherever the delegator is reachable.
800    /// A status check is a live lookup that reveals the verification event to whoever
801    /// hosts the status list.
802    ///
803    /// # Modelled so that digests survive a round trip
804    ///
805    /// Every VMC issued against a status list carries this, and before it was modelled a
806    /// parse-then-re-serialise dropped it silently — producing a digest its issuer would
807    /// not recognise. See [`DTGCommon::extra`], which closes the same gap for members this
808    /// library does not name at all.
809    #[serde(skip_serializing_if = "Option::is_none", default)]
810    pub credential_status: Option<Value>,
811
812    /// Cryptographic proof of credential authenticity
813    #[serde(skip_serializing_if = "Option::is_none", default)]
814    pub proof: Option<DataIntegrityProof>,
815
816    /// Top-level members this library does not model, preserved verbatim.
817    ///
818    /// A DTG credential may legitimately carry properties beyond the ones named here —
819    /// `credentialSchema`, `termsOfUse`, `evidence`, an extension a governing party
820    /// defines. Without somewhere to keep them, a parse-then-re-serialise round trip drops
821    /// them, and the digest computed over the result matches nothing the issuer signed.
822    ///
823    /// Capturing them makes [DTGCredential::digest_multibase] agree with
824    /// [`digest_multibase_json`] over the wire form for any credential whose extra members
825    /// are top-level. It is not a complete answer — the `credentialSubject` types still
826    /// reject members they do not model — so where you hold the bytes a counterparty sent,
827    /// hashing those remains the safe habit.
828    #[serde(flatten)]
829    pub extra: serde_json::Map<String, Value>,
830}
831
832impl DTGCommon {
833    /// Has this credential been signed?
834    /// Returns true if a proof exists
835    /// NOTE: This does NOT validate the proof itself
836    pub fn signed(&self) -> bool {
837        self.proof.is_some()
838    }
839
840    /// This credential's own identifier, if it has one. See [DTGCommon::id].
841    pub fn id(&self) -> Option<&str> {
842        self.id.as_deref()
843    }
844
845    /// Returns the issuer DID
846    pub fn issuer(&self) -> &str {
847        &self.issuer
848    }
849
850    /// Returns the subject DID
851    #[allow(deprecated)]
852    pub fn subject(&self) -> &str {
853        match &self.credential_subject {
854            CredentialSubject::Basic(subject) => &subject.id,
855            CredentialSubject::Endorsement(subject) => &subject.id,
856            CredentialSubject::Witness(subject) => &subject.id,
857            CredentialSubject::Membership(subject) => &subject.id,
858            CredentialSubject::Authority(subject) => &subject.id,
859            CredentialSubject::Delegation(subject) => &subject.id,
860            CredentialSubject::RCard(subject) => &subject.id,
861        }
862    }
863
864    /// The `authority` grant, when this credential is a VAC.
865    ///
866    /// `None` for every other credential type — the accessor is deliberately fallible
867    /// rather than panicking, so a caller handed a credential of unknown type can ask
868    /// without first matching on `type_`.
869    pub fn authority(&self) -> Option<&AuthorityGrant> {
870        match &self.credential_subject {
871            CredentialSubject::Authority(subject) => Some(&subject.authority),
872            _ => None,
873        }
874    }
875
876    /// Mutable access to the `authority` grant, when this credential is a VAC.
877    ///
878    /// Present so that a caller can construct chains this library's own
879    /// [DTGCredential::attenuate] would refuse — which is exactly what a verifier must be
880    /// tested against, since nothing stops another implementation emitting such JSON.
881    pub fn authority_mut(&mut self) -> Option<&mut AuthorityGrant> {
882        match &mut self.credential_subject {
883            CredentialSubject::Authority(subject) => Some(&mut subject.authority),
884            _ => None,
885        }
886    }
887
888    /// The `delegation` object, when this credential is a VDC.
889    ///
890    /// `None` for every other credential type, for the same reason [DTGCommon::authority]
891    /// is fallible: a caller handed a credential of unknown type can ask without first
892    /// matching on `type_`.
893    pub fn delegation(&self) -> Option<&DelegationGrant> {
894        match &self.credential_subject {
895            CredentialSubject::Delegation(subject) => Some(&subject.delegation),
896            _ => None,
897        }
898    }
899
900    /// Mutable access to the `delegation` object, when this credential is a VDC.
901    ///
902    /// Present for the same reason as [DTGCommon::authority_mut]: a verifier must be
903    /// testable against chains this library's own constructors would refuse to build,
904    /// since nothing stops another implementation emitting such JSON.
905    pub fn delegation_mut(&mut self) -> Option<&mut DelegationGrant> {
906        match &mut self.credential_subject {
907            CredentialSubject::Delegation(subject) => Some(&mut subject.delegation),
908            _ => None,
909        }
910    }
911
912    /// The credential is valid from this timestamp
913    pub fn valid_from(&self) -> DateTime<Utc> {
914        self.valid_from
915    }
916
917    /// The credential is valid until this timestamp, if set
918    pub fn valid_until(&self) -> Option<DateTime<Utc>> {
919        self.valid_until
920    }
921
922    /// The `threadId` of the trust task exchange this credential was issued in, if set
923    pub fn task_context(&self) -> Option<&str> {
924        self.task_context.as_deref()
925    }
926}
927
928/// Helps ensure default starting point is correct
929impl Default for DTGCommon {
930    fn default() -> Self {
931        DTGCommon {
932            context: vec![
933                "https://www.w3.org/ns/credentials/v2".to_string(),
934                "https://firstperson.network/credentials/dtg/v1".to_string(),
935            ],
936            type_: vec![
937                "VerifiableCredential".to_string(),
938                "DTGCredential".to_string(),
939            ],
940            id: None,
941            issuer: String::new(),
942            valid_from: Utc::now(),
943            valid_until: None,
944            task_context: None,
945            credential_subject: CredentialSubject::Basic(CredentialSubjectBasic {
946                id: String::new(),
947            }),
948            credential_status: None,
949            proof: None,
950            extra: serde_json::Map::new(),
951        }
952    }
953}
954
955/// Post deserialize setup of a CredentialSubject and CredntialType
956impl TryFrom<DTGCommon> for DTGCredential {
957    type Error = DTGCredentialError;
958
959    #[allow(deprecated)]
960    fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
961        match &value.type_.as_slice().try_into()? {
962            DTGCredentialType::Membership => {
963                // Normalize whichever variant the untagged subject match landed on into
964                // `Membership`, so a caller matching on the subject of a VMC sees one shape
965                // rather than two. See [CredentialSubject::Membership] for why the untagged
966                // match cannot make this decision itself.
967                let subject = match &value.credential_subject {
968                    // Already normalized — a credential built by `new_vmc` /
969                    // `new_member_vmc` rather than deserialized.
970                    CredentialSubject::Membership(subject) => subject.clone(),
971
972                    // `{ id }` — the community-issued grant, which MUST omit `digest`.
973                    CredentialSubject::Basic(subject) => CredentialSubjectMembership {
974                        id: subject.id.clone(),
975                        digest_multibase: None,
976                    },
977
978                    // `{ id, digest }` — the member-issued acknowledgement. Shape-identical
979                    // to a VWC subject, which wins the untagged match; on a
980                    // MembershipCredential it is this. A `witnessContext` alongside it is
981                    // not: that property belongs to a VWC and has no meaning here, so a VMC
982                    // carrying one is malformed rather than merely surprising.
983                    CredentialSubject::Witness(subject) if subject.witness_context.is_none() => {
984                        CredentialSubjectMembership {
985                            id: subject.id.clone(),
986                            digest_multibase: subject.digest_multibase.clone(),
987                        }
988                    }
989
990                    _ => return Err(DTGCredentialError::UnknownCredential),
991                };
992
993                Ok(DTGCredential {
994                    type_: DTGCredentialType::Membership,
995                    version: value.context.as_slice().try_into()?,
996                    credential: DTGCommon {
997                        credential_subject: CredentialSubject::Membership(subject),
998                        ..value
999                    },
1000                })
1001            }
1002            DTGCredentialType::Relationship => Ok(DTGCredential {
1003                type_: DTGCredentialType::Relationship,
1004                version: value.context.as_slice().try_into()?,
1005                credential: value,
1006            }),
1007            DTGCredentialType::Invitation => Ok(DTGCredential {
1008                type_: DTGCredentialType::Invitation,
1009                version: value.context.as_slice().try_into()?,
1010                credential: value,
1011            }),
1012            DTGCredentialType::Persona => Ok(DTGCredential {
1013                type_: DTGCredentialType::Persona,
1014                version: value.context.as_slice().try_into()?,
1015                credential: value,
1016            }),
1017            DTGCredentialType::Endorsement => {
1018                if let CredentialSubject::Endorsement { .. } = &value.credential_subject {
1019                    Ok(DTGCredential {
1020                        type_: DTGCredentialType::Endorsement,
1021                        version: value.context.as_slice().try_into()?,
1022                        credential: value,
1023                    })
1024                } else {
1025                    Err(DTGCredentialError::UnknownCredential)
1026                }
1027            }
1028            DTGCredentialType::Witness => {
1029                // taskContext is REQUIRED on a VWC: the meaning of a witness attestation
1030                // depends on the conditions it was made under, which live in the trust task
1031                // exchange it is bound to.
1032                if value.task_context.is_none() {
1033                    return Err(DTGCredentialError::MissingTaskContext);
1034                }
1035
1036                match &value.credential_subject {
1037                    CredentialSubject::Witness(_) => Ok(DTGCredential {
1038                        type_: DTGCredentialType::Witness,
1039                        version: value.context.as_slice().try_into()?,
1040                        credential: value,
1041                    }),
1042                    CredentialSubject::Basic(subject) => {
1043                        // If Witness CredentialSubject only contains id, it is still valid
1044                        Ok(DTGCredential {
1045                            type_: DTGCredentialType::Witness,
1046                            version: value.context.as_slice().try_into()?,
1047                            credential: DTGCommon {
1048                                credential_subject: CredentialSubject::Witness(
1049                                    CredentialSubjectWitness {
1050                                        id: subject.id.clone(),
1051                                        digest_multibase: None,
1052                                        witness_context: None,
1053                                    },
1054                                ),
1055                                ..value
1056                            },
1057                        })
1058                    }
1059                    _ => Err(DTGCredentialError::UnknownCredential),
1060                }
1061            }
1062            DTGCredentialType::Authority => {
1063                // A VAC's subject must actually carry the grant. `Basic` — a bare `{ id }` —
1064                // is the shape a caller lands on when the `authority` member is missing
1065                // entirely, and a credential that confers nothing is malformed rather than
1066                // merely empty. There is no normalization to do here (unlike VMC/VWC, whose
1067                // shapes collide): `authority` is unique to this subject.
1068                match &value.credential_subject {
1069                    CredentialSubject::Authority(subject) => {
1070                        if subject.authority.actions.is_empty() {
1071                            // Emptiness is never a wildcard. Refusing here means a caller
1072                            // cannot construct one by deserialization either.
1073                            return Err(DTGCredentialError::EmptyAuthorityActions);
1074                        }
1075                        Ok(DTGCredential {
1076                            type_: DTGCredentialType::Authority,
1077                            version: value.context.as_slice().try_into()?,
1078                            credential: value,
1079                        })
1080                    }
1081                    _ => Err(DTGCredentialError::UnknownCredential),
1082                }
1083            }
1084            DTGCredentialType::Delegation => {
1085                // A VDC's subject must carry the appointment. `Basic` — a bare `{ id }` —
1086                // is where a caller lands when `delegation` is missing entirely, and a
1087                // credential that appoints nobody to nothing is malformed rather than
1088                // merely empty.
1089                match &value.credential_subject {
1090                    CredentialSubject::Delegation(subject) => {
1091                        let d = &subject.delegation;
1092
1093                        // The two halves are distinguished by `accepts`, and each half has
1094                        // exactly one shape. Refusing the mixtures here means a caller
1095                        // cannot construct one by deserialization either.
1096                        match (&d.accepts, &d.scope) {
1097                            (Some(_), Some(_)) => {
1098                                return Err(DTGCredentialError::MalformedDelegation(
1099                                    "carries both `accepts` and `scope`: an acceptance \
1100                                     consents to the scope of the grant it names rather \
1101                                     than restating it"
1102                                        .into(),
1103                                ));
1104                            }
1105                            (Some(_), None) => {
1106                                if d.parent.is_some() || d.max_depth.is_some() {
1107                                    return Err(DTGCredentialError::MalformedDelegation(
1108                                        "an acceptance carries `accepts` and nothing else".into(),
1109                                    ));
1110                                }
1111                            }
1112                            (None, Some(scope)) => {
1113                                if scope.is_empty() {
1114                                    return Err(DTGCredentialError::MalformedDelegation(
1115                                        "a grant's `scope` MUST contain at least one \
1116                                         entry — emptying it is not how an unbounded \
1117                                         appointment is expressed, because there is no \
1118                                         way to express one"
1119                                            .into(),
1120                                    ));
1121                                }
1122                            }
1123                            (None, None) => {
1124                                return Err(DTGCredentialError::MalformedDelegation(
1125                                    "carries neither `scope` nor `accepts`, so it is \
1126                                     neither a grant nor an acceptance"
1127                                        .into(),
1128                                ));
1129                            }
1130                        }
1131
1132                        Ok(DTGCredential {
1133                            type_: DTGCredentialType::Delegation,
1134                            version: value.context.as_slice().try_into()?,
1135                            credential: value,
1136                        })
1137                    }
1138                    _ => Err(DTGCredentialError::UnknownCredential),
1139                }
1140            }
1141            DTGCredentialType::RCard => match &value.credential_subject {
1142                CredentialSubject::RCard { .. } => Ok(DTGCredential {
1143                    type_: DTGCredentialType::RCard,
1144                    version: value.context.as_slice().try_into()?,
1145                    credential: value,
1146                }),
1147                _ => Err(DTGCredentialError::UnknownCredential),
1148            },
1149        }
1150    }
1151}
1152
1153/// This correctly formats timestamps into the correct iso8601 specification for W3C Verifiable
1154/// Credentials
1155fn iso8601_format<S>(timestamp: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
1156where
1157    S: Serializer,
1158{
1159    s.serialize_str(
1160        timestamp
1161            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1162            .as_str(),
1163    )
1164}
1165
1166fn iso8601_format_option<S>(timestamp: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
1167where
1168    S: Serializer,
1169{
1170    if let Some(timestamp) = timestamp {
1171        s.serialize_str(
1172            timestamp
1173                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1174                .as_str(),
1175        )
1176    } else {
1177        s.serialize_none()
1178    }
1179}
1180
1181// ****************************************************************************
1182// Credential Subject types
1183// ****************************************************************************
1184// NOTE: The DTG credential spec overloads the JSON attributes for different credential payloads.
1185// The following enum will map the credential subject schema to correct Struct type
1186
1187/// This represents all possible credential subjects
1188/// The order of the enum is important as it will match on first match
1189#[allow(deprecated)]
1190#[derive(Serialize, Deserialize, Debug, Clone)]
1191#[serde(untagged)]
1192pub enum CredentialSubject {
1193    /// Verifiable Endorsement Credential subject
1194    Endorsement(CredentialSubjectEndorsement),
1195
1196    /// R-Card Credential subject
1197    #[deprecated(
1198        since = "0.2.0",
1199        note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
1200                See DTGCredentialType::RCard. This variant will be removed in a future release."
1201    )]
1202    RCard(CredentialSubjectRCard),
1203
1204    /// Credential Subject of just `id`
1205    /// Used by a community-issued VMC, and by VRC, VIC and VPC
1206    Basic(CredentialSubjectBasic),
1207
1208    /// Verifiable Witness Credential subject
1209    Witness(CredentialSubjectWitness),
1210
1211    /// Verifiable Authority Credential subject.
1212    ///
1213    /// Unambiguous under the untagged match: no other DTG subject carries an `authority`
1214    /// member, and `deny_unknown_fields` keeps a subject that does not have one from
1215    /// landing here.
1216    Authority(CredentialSubjectAuthority),
1217
1218    /// Verifiable Delegation Credential subject.
1219    ///
1220    /// Unambiguous for the same reason as [CredentialSubject::Authority]: `delegation` is
1221    /// carried by no other DTG subject.
1222    Delegation(CredentialSubjectDelegation),
1223
1224    /// Membership Credential subject, carrying the OPTIONAL `digest` that a member-issued
1225    /// VMC MUST set.
1226    ///
1227    /// # Never selected by the untagged match, deliberately
1228    ///
1229    /// This variant sits last because its two shapes are already claimed above: `{ id }` is
1230    /// [CredentialSubject::Basic], and `{ id, digest }` is indistinguishable from a VWC
1231    /// subject with no `witnessContext`, which [CredentialSubject::Witness] takes first.
1232    /// Nothing in the subject object itself separates a membership acknowledgement from a
1233    /// witness attestation — only the credential's `type` does.
1234    ///
1235    /// So the shape is not decided here. `TryFrom<DTGCommon> for DTGCredential` normalizes
1236    /// whichever variant the untagged match landed on into this one when `type` includes
1237    /// `MembershipCredential`, the same way it already re-wraps a `Basic` subject as
1238    /// `Witness` on a VWC. Deserialization is therefore deterministic rather than
1239    /// order-dependent, and a `Membership` subject reaching a matcher has been through that
1240    /// normalization.
1241    Membership(CredentialSubjectMembership),
1242}
1243
1244/// id of the credential subject only
1245#[derive(Serialize, Deserialize, Debug, Clone)]
1246#[serde(deny_unknown_fields)]
1247pub struct CredentialSubjectBasic {
1248    pub id: String,
1249}
1250
1251/// The `authority` object a [CredentialSubject::Authority] carries.
1252///
1253/// # Attenuation
1254///
1255/// A holder may derive a narrower VAC from one they hold without involving the issuer. An
1256/// attenuated VAC sets [AuthorityGrant::parent] to the **digest** of the credential it
1257/// derives from, and MUST NOT widen `actions`, `scope`, or the validity window. Verification walks
1258/// the chain to a VAC issued by the party governing the scope — see
1259/// [crate::authority::verify_chain], which is where the security of this credential
1260/// actually lives. Issuing one is a struct and a signature; refusing a widening link is the
1261/// part that matters.
1262#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1263#[serde(rename_all = "camelCase", deny_unknown_fields)]
1264pub struct AuthorityGrant {
1265    /// The DID or URI the authority applies to.
1266    ///
1267    /// Matched exactly. A verifier rejects a VAC whose `scope` is not the resource being
1268    /// accessed; nothing here implies containment between scopes.
1269    pub scope: String,
1270
1271    /// The permitted actions, from a vocabulary the governing party defines.
1272    ///
1273    /// MUST NOT be empty. An empty list confers nothing — emptiness is never a wildcard,
1274    /// which is the failure mode this rule exists to prevent. Action strings are compared
1275    /// exactly and case-sensitively, and no action implies another: `admin` does not grant
1276    /// `write` unless both are listed.
1277    pub actions: Vec<String>,
1278
1279    /// The **digest** of the VAC this one was attenuated from, as
1280    /// [DTGCredential::digest_multibase] computes it.
1281    ///
1282    /// Absent means this VAC was issued directly by the party governing the scope, and is
1283    /// therefore a chain root.
1284    ///
1285    /// # A digest, not an identifier
1286    ///
1287    /// Working Draft 02 made this deliberate rather than incidental. A digest names
1288    /// nothing that can be fetched, so verification cannot come to depend on network
1289    /// availability, a verifier cannot be induced to make a request against an address of
1290    /// the holder's choosing, and nobody hosting an identifier learns when a credential is
1291    /// used. It also binds an attenuated VAC to the exact claims its issuer narrowed from:
1292    /// re-issuing a parent with different claims does not re-parent the children of the
1293    /// old one, while re-proofing it with identical claims leaves them undisturbed,
1294    /// because the digest excludes `proof`.
1295    #[serde(skip_serializing_if = "Option::is_none")]
1296    pub parent: Option<String>,
1297}
1298
1299/// The `delegation` object a [CredentialSubject::Delegation] carries.
1300///
1301/// A VDC is one of a **pair**. The delegator issues a *grant* — carrying `scope`, and
1302/// optionally `parent` and `maxDepth` — and the delegate answers with an *acceptance*
1303/// carrying `accepts` and nothing else. The two together form a complete DTG edge, and a
1304/// verifier MUST have both: a grant alone establishes what the delegator appointed, not
1305/// what the delegate agreed to.
1306///
1307/// # A VDC is not authority
1308///
1309/// It never supplies permission the delegator did not itself hold. A verifier presented
1310/// with one substitutes the delegator for the delegate and then asks the permission
1311/// question it would have asked of the delegator directly — live, at the time of the act.
1312/// The reach of a delegated act is the *intersection* of what the delegator may do and
1313/// what the chain appoints the delegate for. See [AuthorityGrant] for the credential that
1314/// answers the permission question.
1315#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
1316#[serde(rename_all = "camelCase", deny_unknown_fields)]
1317pub struct DelegationGrant {
1318    /// The acts the delegate may perform in the delegator's name.
1319    ///
1320    /// REQUIRED on a grant and MUST contain at least one entry — a VDC MUST NOT express an
1321    /// unbounded appointment by omitting or emptying it. MUST be omitted on an acceptance,
1322    /// which consents to the scope of the grant it names rather than restating it.
1323    ///
1324    /// Entries are opaque strings compared for exact equality. The specification defines no
1325    /// wildcard, prefix or hierarchical semantics, so the subset test on a chain is set
1326    /// inclusion over exact matches; a governing vocabulary that wants structure must put
1327    /// it in the terms themselves.
1328    #[serde(skip_serializing_if = "Option::is_none", default)]
1329    pub scope: Option<Vec<String>>,
1330
1331    /// The digest of the VDC this delegation was derived from, when the delegator is
1332    /// itself acting under a delegation. A VDC with no `parent` is a **root delegation**.
1333    #[serde(skip_serializing_if = "Option::is_none", default)]
1334    pub parent: Option<String>,
1335
1336    /// The number of further re-delegations permitted below this one.
1337    ///
1338    /// `0` prohibits re-delegation, and so does **absence** — the default is a single hop.
1339    /// Setting it above `0` is the delegator's explicit authorisation to re-delegate;
1340    /// there is no other. Note that this is the opposite default from a VAC, where
1341    /// attenuation is permitted unless forbidden: a delegate speaks in the principal's
1342    /// name, so the principal keeps the register of who may do so.
1343    #[serde(skip_serializing_if = "Option::is_none", default)]
1344    pub max_depth: Option<u32>,
1345
1346    /// The digest of the grant being accepted.
1347    ///
1348    /// REQUIRED on an acceptance and MUST be omitted on a grant. Its presence is what
1349    /// distinguishes the two halves of a delegation edge.
1350    #[serde(skip_serializing_if = "Option::is_none", default)]
1351    pub accepts: Option<String>,
1352}
1353
1354/// Delegation Credential subject
1355#[derive(Serialize, Deserialize, Debug, Clone)]
1356#[serde(rename_all = "camelCase", deny_unknown_fields)]
1357pub struct CredentialSubjectDelegation {
1358    /// DID of the delegate on a grant; DID of the delegator on an acceptance.
1359    pub id: String,
1360
1361    /// The appointment itself.
1362    pub delegation: DelegationGrant,
1363}
1364
1365/// Verifiable Authority Credential (VAC) subject.
1366#[derive(Serialize, Deserialize, Debug, Clone)]
1367#[serde(rename_all = "camelCase", deny_unknown_fields)]
1368pub struct CredentialSubjectAuthority {
1369    /// DID of the party receiving the authority.
1370    pub id: String,
1371
1372    /// What the subject may do, and where.
1373    pub authority: AuthorityGrant,
1374}
1375
1376/// Membership Credential subject
1377///
1378/// The two directions of a membership edge share this shape and are told apart by
1379/// `digest`: a community-issued VMC (the membership grant) MUST omit it, and a
1380/// member-issued VMC (the membership acknowledgement) MUST carry it. Where both endpoints
1381/// are community identifiers, as in VTN membership, `digestMultibase` is the only
1382/// discriminator — the issuer and subject rules cannot separate the directions.
1383#[derive(Serialize, Deserialize, Debug, Clone)]
1384#[serde(rename_all = "camelCase", deny_unknown_fields)]
1385pub struct CredentialSubjectMembership {
1386    pub id: String,
1387
1388    /// Digest of the community-issued VMC this acknowledges, as
1389    /// [DTGCredential::digest_multibase] computes it.
1390    ///
1391    /// REQUIRED on the member-issued VMC, and MUST be omitted on the community-issued VMC.
1392    /// `Option` rather than two structs because the same property distinguishes the two
1393    /// directions: a type that could not represent both could not deserialize the pair.
1394    ///
1395    /// Serializes as `digestMultibase`. The Working Draft 01 name `digest` is accepted on
1396    /// the wire so that credentials issued against that draft still parse; the *value*
1397    /// encoding also changed, so such a credential parses and then fails to compare, with
1398    /// [DTGCredentialError::InvalidDigest] rather than a silent mismatch.
1399    #[serde(
1400        rename = "digestMultibase",
1401        alias = "digest",
1402        skip_serializing_if = "Option::is_none",
1403        default
1404    )]
1405    pub digest_multibase: Option<String>,
1406}
1407
1408/// Endorsement Credential subject
1409#[derive(Serialize, Deserialize, Debug, Clone)]
1410#[serde(deny_unknown_fields)]
1411pub struct CredentialSubjectEndorsement {
1412    pub id: String,
1413    /// There is no spec for the endorsement content, so we use a generic JSON value
1414    pub endorsement: Value,
1415}
1416
1417/// Witness Credential subject
1418#[derive(Serialize, Deserialize, Debug, Clone)]
1419#[serde(rename_all = "camelCase", deny_unknown_fields)]
1420pub struct CredentialSubjectWitness {
1421    pub id: String,
1422
1423    /// Digest of the witnessed edge credential, as [DTGCredential::digest_multibase]
1424    /// computes it. REQUIRED by the specification — a VWC without one names the observed
1425    /// party and the exchange, but not which edge was witnessed.
1426    ///
1427    /// Serializes as `digestMultibase`; the Working Draft 01 name `digest` is accepted on
1428    /// the wire.
1429    #[serde(
1430        rename = "digestMultibase",
1431        alias = "digest",
1432        skip_serializing_if = "Option::is_none",
1433        default
1434    )]
1435    pub digest_multibase: Option<String>,
1436
1437    /// There is no spec for the witness context content, so we use a generic JSON value
1438    #[serde(skip_serializing_if = "Option::is_none")]
1439    pub witness_context: Option<WitnessContext>,
1440}
1441
1442/// Witness Credential Context
1443#[derive(Serialize, Deserialize, Debug, Clone)]
1444#[serde(rename_all = "camelCase", deny_unknown_fields)]
1445pub struct WitnessContext {
1446    /// Human-readable event name
1447    pub event: Option<String>,
1448
1449    /// Session or nonce identifier
1450    pub session_id: Option<String>,
1451
1452    ///Verification method used
1453    pub method: Option<String>,
1454}
1455
1456/// R-Card Credential subject
1457#[deprecated(
1458    since = "0.2.0",
1459    note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
1460            See DTGCredentialType::RCard. This struct will be removed in a future release."
1461)]
1462#[derive(Serialize, Deserialize, Debug, Clone)]
1463#[serde(deny_unknown_fields)]
1464pub struct CredentialSubjectRCard {
1465    pub id: String,
1466
1467    /// JCard spec, generic JSON value
1468    pub card: Value,
1469}
1470
1471#[cfg(test)]
1472#[allow(deprecated)]
1473mod tests {
1474    use crate::{
1475        CredentialSubject, CredentialSubjectRCard, DTGCommon, DTGCredential, DTGCredentialError,
1476        DTGCredentialType, W3CVCVersion, decode_digest_multibase, digest_multibase_json,
1477        digests_match,
1478    };
1479    use chrono::{DateTime, Utc};
1480    use multibase::Base;
1481    use serde_json::Value;
1482    use sha2::{Digest, Sha256};
1483
1484    #[test]
1485    fn test_vmc_vc_1_deserialize() {
1486        // tests deserialize a W3C VC Version 1.1 credential
1487        let vmc: DTGCredential = match serde_json::from_str(
1488            r#"{
1489"@context": [
1490    "https://www.w3.org/2018/credentials/v1",
1491    "https://firstperson.network/credentials/dtg/v1",
1492    "https://w3id.org/security/suites/ed25519-2020/v1"
1493  ],
1494  "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1495  "issuer": "did:web:chess-club.example",
1496  "issuanceDate": "2026-01-06T10:00:00Z",
1497  "expirationDate": "2027-01-06T10:00:00Z",
1498  "credentialSubject": {
1499    "id": "did:key:z6MkpTHR8VNs..."
1500  }
1501            }"#,
1502        ) {
1503            Ok(vmc) => vmc,
1504            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1505        };
1506
1507        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1508        assert!(matches!(
1509            vmc.credential().credential_subject,
1510            CredentialSubject::Membership(_)
1511        ));
1512        assert!(matches!(vmc.version, W3CVCVersion::V1_1));
1513        assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V1_1));
1514    }
1515
1516    #[test]
1517    fn test_missing_w3c_context() {
1518        // tests deserialize a W3C VC Version 1.1 credential
1519        assert!(
1520            serde_json::from_str::<DTGCredential>(
1521                r#"{
1522"@context": [
1523    "https://firstperson.network/credentials/dtg/v1",
1524    "https://w3id.org/security/suites/ed25519-2020/v1"
1525  ],
1526  "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1527  "issuer": "did:web:chess-club.example",
1528  "issuanceDate": "2026-01-06T10:00:00Z",
1529  "expirationDate": "2027-01-06T10:00:00Z",
1530  "credentialSubject": {
1531    "id": "did:key:z6MkpTHR8VNs..."
1532  }
1533            }"#,
1534            )
1535            .is_err()
1536        );
1537    }
1538
1539    #[test]
1540    fn test_mutable_credential() {
1541        let mut vmc = DTGCredential::new_vmc(
1542            "did:example:issuer".to_string(),
1543            "did:example:subject".to_string(),
1544            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1545                .unwrap()
1546                .with_timezone(&Utc),
1547            None,
1548            false,
1549        );
1550
1551        let cred = vmc.credential_mut();
1552        cred.type_.push("PersonhoodCredential".to_string());
1553        assert!(vmc.is_personhood_credential());
1554    }
1555
1556    #[test]
1557    fn test_vmc_deserialize() {
1558        let vmc: DTGCredential = match serde_json::from_str(
1559            r#"{
1560                "@context": ["https://www.w3.org/ns/credentials/v2"],
1561                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1562                "issuer": "did:example:community",
1563                "validFrom": "2024-06-18T10:00:00Z",
1564                "credentialSubject": { "id": "did:example:rDid" }
1565            }"#,
1566        ) {
1567            Ok(vmc) => vmc,
1568            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1569        };
1570
1571        assert!(!vmc.is_personhood_credential());
1572        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1573        assert!(matches!(
1574            vmc.credential().credential_subject,
1575            CredentialSubject::Membership(_)
1576        ));
1577        assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V2_0));
1578    }
1579
1580    #[test]
1581    fn test_vmc_phc_deserialize() {
1582        let vmc: DTGCredential = match serde_json::from_str(
1583            r#"{
1584                "@context": ["https://www.w3.org/ns/credentials/v2"],
1585                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential", "PersonhoodCredential"],
1586                "issuer": "did:example:community",
1587                "validFrom": "2024-06-18T10:00:00Z",
1588                "credentialSubject": { "id": "did:example:rDid" }
1589            }"#,
1590        ) {
1591            Ok(vmc) => vmc,
1592            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1593        };
1594
1595        assert!(vmc.is_personhood_credential());
1596        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1597        assert!(matches!(
1598            vmc.credential().credential_subject,
1599            CredentialSubject::Membership(_)
1600        ));
1601    }
1602
1603    #[test]
1604    fn test_vrc_deserialize() {
1605        let vrc: DTGCredential = match serde_json::from_str(
1606            r#"{
1607                "@context": ["https://www.w3.org/ns/credentials/v2"],
1608                "type": ["VerifiableCredential", "DTGCredential",  "RelationshipCredential"],
1609                "issuer": "did:example:governmentAgencyDid",
1610                "validFrom": "2024-06-18T10:00:00Z",
1611                "credentialSubject": { "id": "did:example:citizenRDid" }
1612            }"#,
1613        ) {
1614            Ok(vrc) => vrc,
1615            Err(e) => panic!("Couldn't deserialize VRC: {}", e),
1616        };
1617
1618        assert!(matches!(vrc.type_, DTGCredentialType::Relationship));
1619        assert!(matches!(
1620            vrc.credential().credential_subject,
1621            CredentialSubject::Basic(_)
1622        ));
1623    }
1624
1625    #[test]
1626    fn test_vic_deserialize() {
1627        let vic: DTGCredential = match serde_json::from_str(
1628            r#"{
1629                "@context": ["https://www.w3.org/ns/credentials/v2"],
1630                "type": ["VerifiableCredential", "DTGCredential",  "InvitationCredential"],
1631                "issuer": "did:example:governmentAgencyVicDid",
1632                "validFrom": "2024-06-18T10:00:00Z",
1633                "credentialSubject": { "id": "did:example:citizenRDid" }
1634            }"#,
1635        ) {
1636            Ok(vic) => vic,
1637            Err(e) => panic!("Couldn't deserialize VIC: {}", e),
1638        };
1639
1640        assert!(!vic.is_personhood_credential());
1641        assert!(matches!(vic.type_, DTGCredentialType::Invitation));
1642        assert!(matches!(
1643            vic.credential().credential_subject,
1644            CredentialSubject::Basic(_)
1645        ));
1646    }
1647
1648    #[test]
1649    fn test_vpc_deserialize() {
1650        let vpc: DTGCredential = match serde_json::from_str(
1651            r#"{
1652                "@context": ["https://www.w3.org/ns/credentials/v2"],
1653                "type": ["VerifiableCredential", "DTGCredential",  "PersonaCredential"],
1654                "issuer": "did:example:governmentAgencyDid",
1655                "validFrom": "2024-06-18T10:00:00Z",
1656                "credentialSubject": { "id": "did:example:citizenRDid" }
1657            }"#,
1658        ) {
1659            Ok(vpc) => vpc,
1660            Err(e) => panic!("Couldn't deserialize VPC: {}", e),
1661        };
1662
1663        assert!(matches!(vpc.type_, DTGCredentialType::Persona));
1664        assert!(matches!(
1665            vpc.credential().credential_subject,
1666            CredentialSubject::Basic(_)
1667        ));
1668    }
1669
1670    #[test]
1671    fn test_vec_deserialize() {
1672        let vec: DTGCredential = match serde_json::from_str(
1673            r#"{
1674                "@context": ["https://www.w3.org/ns/credentials/v2"],
1675                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
1676                "issuer": "did:example:governmentAgencyDid",
1677                "validFrom": "2024-06-18T10:00:00Z",
1678                "credentialSubject": { "id": "did:example:citizenRDid", "endorsement": {} }
1679            }"#,
1680        ) {
1681            Ok(vec) => vec,
1682            Err(e) => panic!("Couldn't deserialize VEC: {}", e),
1683        };
1684
1685        assert!(matches!(vec.type_, DTGCredentialType::Endorsement));
1686        assert!(matches!(vec.subject(), "did:example:citizenRDid"));
1687        assert!(matches!(
1688            vec.credential().credential_subject,
1689            CredentialSubject::Endorsement(_)
1690        ));
1691    }
1692
1693    #[test]
1694    fn test_vec_bad_deserialize() {
1695        match serde_json::from_str::<DTGCredential>(
1696            r#"{
1697                "@context": ["https://www.w3.org/ns/credentials/v2"],
1698                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
1699                "issuer": "did:example:governmentAgencyDid",
1700                "validFrom": "2024-06-18T10:00:00Z",
1701                "credentialSubject": { "id": "did:example:citizenRDid", "other": [] }
1702            }"#,
1703        ) {
1704            Ok(_) => panic!("Expected Unknown Credential type"),
1705            Err(_) => {
1706                // Good
1707            }
1708        };
1709    }
1710
1711    #[test]
1712    fn test_vwc_simple_deserialize() {
1713        let vwc: DTGCredential = match serde_json::from_str(
1714            r#"{
1715                "@context": ["https://www.w3.org/ns/credentials/v2"],
1716                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1717                "issuer": "did:example:governmentAgencyDid",
1718                "validFrom": "2024-06-18T10:00:00Z",
1719                "taskContext": "thread-abc-123",
1720                "credentialSubject": { "id": "did:example:citizenRDid" }
1721            }"#,
1722        ) {
1723            Ok(vwc) => vwc,
1724            Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1725        };
1726
1727        assert!(matches!(vwc.type_, DTGCredentialType::Witness));
1728        assert!(matches!(vwc.subject(), "did:example:citizenRDid"));
1729        assert_eq!(vwc.task_context(), Some("thread-abc-123"));
1730        assert!(matches!(
1731            vwc.credential().credential_subject,
1732            CredentialSubject::Witness(_)
1733        ));
1734    }
1735
1736    #[test]
1737    fn test_vwc_full_deserialize() {
1738        let vwc: DTGCredential = match serde_json::from_str(
1739            r#"{
1740                "@context": ["https://www.w3.org/ns/credentials/v2"],
1741                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1742                "issuer": "did:example:governmentAgencyDid",
1743                "validFrom": "2024-06-18T10:00:00Z",
1744                "taskContext": "thread-abc-123",
1745                "credentialSubject": { "id": "did:example:citizenRDid", "digestMultibase": "abcdf", "witnessContext": {} }
1746            }"#,
1747        ) {
1748            Ok(vwc) => vwc,
1749            Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1750        };
1751
1752        assert!(matches!(vwc.type_(), DTGCredentialType::Witness));
1753        assert!(matches!(
1754            vwc.credential().credential_subject,
1755            CredentialSubject::Witness(_)
1756        ));
1757    }
1758
1759    #[test]
1760    fn test_vwc_bad_deserialize() {
1761        if serde_json::from_str::<DTGCredential>(
1762            r#"{
1763                "@context": ["https://www.w3.org/ns/credentials/v2"],
1764                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1765                "issuer": "did:example:governmentAgencyDid",
1766                "validFrom": "2024-06-18T10:00:00Z",
1767                "taskContext": "thread-abc-123",
1768                "credentialSubject": { "id": "did:example:citizenRDid", "digestMultibase": "abcdf", "wrongContext": {}  }
1769            }"#,
1770        ).is_ok() {
1771            panic!("Should have failed due to wrong CredentialSubject!");
1772        }
1773    }
1774
1775    #[test]
1776    fn test_rcard_simple_deserialize() {
1777        let rcard: DTGCredential = match serde_json::from_str(
1778            r#"{
1779                "@context": ["https://www.w3.org/ns/credentials/v2"],
1780                "type": ["VerifiableCredential", "DTGCredential",  "RCardCredential"],
1781                "issuer": "did:example:governmentAgencyDid",
1782                "validFrom": "2024-06-18T10:00:00Z",
1783                "credentialSubject": { "id": "did:example:citizenRDid", "card": [] }
1784            }"#,
1785        ) {
1786            Ok(rcard) => rcard,
1787            Err(e) => panic!("Couldn't deserialize R-Card: {}", e),
1788        };
1789
1790        assert!(matches!(rcard.type_(), DTGCredentialType::RCard));
1791        assert!(matches!(rcard.subject(), "did:example:citizenRDid"));
1792        assert!(matches!(
1793            rcard.credential().credential_subject,
1794            CredentialSubject::RCard(_)
1795        ));
1796    }
1797
1798    #[test]
1799    fn test_rcard_bad_deserialize() {
1800        if serde_json::from_str::<DTGCredential>(
1801            r#"{
1802                "@context": ["https://www.w3.org/ns/credentials/v2"],
1803                "type": ["VerifiableCredential", "DTGCredential",  "RCardCredential"],
1804                "issuer": "did:example:governmentAgencyDid",
1805                "validFrom": "2024-06-18T10:00:00Z",
1806                "credentialSubject": { "id": "did:example:citizenRDid"  }
1807            }"#,
1808        )
1809        .is_ok()
1810        {
1811            panic!("Should have failed due to wrong CredentialSubject!");
1812        }
1813    }
1814    #[test]
1815    fn test_deserialize_unknown() {
1816        match serde_json::from_str::<DTGCredential>(
1817            r#"{
1818                "@context": ["https://www.w3.org/ns/credentials/v2"],
1819                "type": ["VerifiableCredential", "DTGCredential",  "UnknownCredential"],
1820                "issuer": "did:example:governmentAgencyDid",
1821                "validFrom": "2024-06-18T10:00:00Z",
1822                "credentialSubject": { "id": "did:example:citizenRDid" }
1823            }"#,
1824        ) {
1825            Ok(_) => panic!("Expected Unknown Credential type"),
1826            Err(e) => {
1827                if e.to_string() == "Unknown credential type" {
1828                    // test passed
1829                } else {
1830                    panic!("Wrong error type returned");
1831                }
1832            }
1833        };
1834    }
1835
1836    #[test]
1837    fn test_deserialize_mismatched_credential_subject() {
1838        match serde_json::from_str::<DTGCredential>(
1839            r#"{
1840                "@context": ["https://www.w3.org/ns/credentials/v2"],
1841                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
1842                "issuer": "did:example:governmentAgencyDid",
1843                "validFrom": "2024-06-18T10:00:00Z",
1844                "credentialSubject": { "id": "did:example:citizenRDid" }
1845            }"#,
1846        ) {
1847            Ok(_) => panic!("Expected Unknown Credential type"),
1848            Err(e) => {
1849                if e.to_string() == "Unknown credential type" {
1850                    // test passed
1851                } else {
1852                    panic!("Wrong error type returned");
1853                }
1854            }
1855        };
1856    }
1857
1858    #[test]
1859    fn test_proof_signed() {
1860        let cred: DTGCredential = match serde_json::from_str(
1861            r#"{
1862                "@context": ["https://www.w3.org/ns/credentials/v2"],
1863                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1864                "issuer": "did:example:community",
1865                "validFrom": "2024-06-18T10:00:00Z",
1866                "credentialSubject": { "id": "did:example:rDid" },
1867                "proof": {
1868                    "type": "DataIntegrityProof",
1869                    "cryptosuite": "eddsa-jcs-2022",
1870                    "created": "2025-12-04T00:00:00",
1871                    "verificationMethod": "did:example:test#key-1",
1872                    "proofPurpose": "assertionMethod",
1873                    "proofValue": "abcd"
1874                }
1875            }"#,
1876        ) {
1877            Ok(vmc) => vmc,
1878            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1879        };
1880
1881        assert!(cred.signed());
1882        assert!(cred.proof_value().is_some());
1883    }
1884
1885    #[test]
1886    fn test_proof_not_signed() {
1887        let cred: DTGCredential = match serde_json::from_str(
1888            r#"{
1889                "@context": ["https://www.w3.org/ns/credentials/v2"],
1890                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1891                "issuer": "did:example:community",
1892                "validFrom": "2024-06-18T10:00:00Z",
1893                "credentialSubject": { "id": "did:example:rDid" }
1894            }"#,
1895        ) {
1896            Ok(vmc) => vmc,
1897            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1898        };
1899
1900        assert!(!cred.signed());
1901        assert!(cred.proof_value().is_none());
1902    }
1903
1904    #[test]
1905    fn test_helpers() {
1906        let cred: DTGCredential = match serde_json::from_str(
1907            r#"{
1908                "@context": ["https://www.w3.org/ns/credentials/v2"],
1909                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1910                "issuer": "did:example:issuer",
1911                "validFrom": "2024-06-18T00:00:00Z",
1912                "credentialSubject": { "id": "did:example:subject" }
1913            }"#,
1914        ) {
1915            Ok(vmc) => vmc,
1916            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1917        };
1918
1919        assert_eq!(cred.issuer(), "did:example:issuer");
1920        assert_eq!(cred.subject(), "did:example:subject");
1921        assert_eq!(
1922            cred.valid_from()
1923                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1924            "2024-06-18T00:00:00Z"
1925        );
1926        assert_eq!(cred.valid_until(), None);
1927    }
1928
1929    #[test]
1930    fn test_valid_until() {
1931        let cred: DTGCredential = match serde_json::from_str(
1932            r#"{
1933                "@context": ["https://www.w3.org/ns/credentials/v2"],
1934                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1935                "issuer": "did:example:issuer",
1936                "validFrom": "2024-06-18T00:00:00Z",
1937                "validUntil": "2030-01-01T00:00:00Z",
1938                "credentialSubject": { "id": "did:example:subject" }
1939            }"#,
1940        ) {
1941            Ok(vmc) => vmc,
1942            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1943        };
1944
1945        assert_eq!(
1946            cred.valid_until()
1947                .unwrap()
1948                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1949            "2030-01-01T00:00:00Z"
1950        );
1951    }
1952
1953    #[test]
1954    fn test_bad_type() {
1955        assert!(
1956            std::convert::TryInto::<DTGCredentialType>::try_into(
1957                vec!["bad_type".to_string()].as_slice(),
1958            )
1959            .is_err()
1960        );
1961    }
1962
1963    #[test]
1964    fn test_badly_constructed_vwc() {
1965        let mut cred = DTGCommon::default();
1966        cred.type_.push("WitnessCredential".to_string());
1967        // taskContext is set so this exercises the credentialSubject mismatch, not the
1968        // missing-taskContext path covered by test_vwc_missing_task_context()
1969        cred.task_context = Some("thread-abc-123".to_string());
1970        cred.credential_subject = CredentialSubject::RCard(CredentialSubjectRCard {
1971            id: "did:example:bad".to_string(),
1972            card: Value::Null,
1973        });
1974
1975        assert!(std::convert::TryInto::<DTGCredential>::try_into(cred).is_err());
1976    }
1977
1978    #[test]
1979    fn test_vwc_missing_task_context() {
1980        // taskContext is REQUIRED on a VWC
1981        match serde_json::from_str::<DTGCredential>(
1982            r#"{
1983                "@context": ["https://www.w3.org/ns/credentials/v2"],
1984                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1985                "issuer": "did:example:witness",
1986                "validFrom": "2024-06-18T10:00:00Z",
1987                "credentialSubject": { "id": "did:example:observed" }
1988            }"#,
1989        ) {
1990            Ok(_) => panic!("Expected a VWC without taskContext to be rejected"),
1991            Err(e) => assert_eq!(
1992                e.to_string(),
1993                "WitnessCredential is missing the required taskContext property"
1994            ),
1995        }
1996    }
1997
1998    #[test]
1999    fn test_task_context_round_trip() {
2000        // taskContext must survive deserialize -> serialize, otherwise a credential signed
2001        // elsewhere would fail verification here (and vice versa)
2002        let raw = r#"{
2003                "@context": ["https://www.w3.org/ns/credentials/v2"],
2004                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
2005                "issuer": "did:example:witness",
2006                "validFrom": "2024-06-18T10:00:00Z",
2007                "taskContext": "thread-abc-123",
2008                "credentialSubject": { "id": "did:example:observed" }
2009            }"#;
2010
2011        let cred: DTGCredential = serde_json::from_str(raw).unwrap();
2012        let out = serde_json::to_string(&cred).unwrap();
2013
2014        assert!(out.contains(r#""taskContext":"thread-abc-123""#));
2015    }
2016
2017    #[test]
2018    fn test_task_context_optional_on_other_types() {
2019        // taskContext is OPTIONAL everywhere except the VWC
2020        let vrc: DTGCredential = serde_json::from_str(
2021            r#"{
2022                "@context": ["https://www.w3.org/ns/credentials/v2"],
2023                "type": ["VerifiableCredential", "DTGCredential",  "RelationshipCredential"],
2024                "issuer": "did:example:issuer",
2025                "validFrom": "2024-06-18T10:00:00Z",
2026                "credentialSubject": { "id": "did:example:subject" }
2027            }"#,
2028        )
2029        .unwrap();
2030
2031        assert_eq!(vrc.task_context(), None);
2032        // and it is omitted from the serialization entirely when absent
2033        assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
2034    }
2035
2036    #[test]
2037    fn test_digest_multibase() {
2038        let vrc = DTGCredential::new_vrc(
2039            "did:example:issuer".to_string(),
2040            "did:example:subject".to_string(),
2041            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2042                .unwrap()
2043                .with_timezone(&Utc),
2044            None,
2045        );
2046
2047        let digest = vrc.digest_multibase().unwrap();
2048
2049        // base58btc multibase prefix
2050        assert!(digest.starts_with('z'));
2051
2052        // decodes to a sha2-256 multihash: 0x12 0x20 followed by 32 digest bytes
2053        let (base, bytes) = multibase::decode(&digest).unwrap();
2054        assert_eq!(base, multibase::Base::Base58Btc);
2055        assert_eq!(bytes.len(), 34);
2056        assert_eq!(&bytes[..2], &[0x12, 0x20]);
2057
2058        // stable across calls
2059        assert_eq!(digest, vrc.digest_multibase().unwrap());
2060
2061        // and distinct for a different credential
2062        let other = DTGCredential::new_vrc(
2063            "did:example:issuer".to_string(),
2064            "did:example:someone-else".to_string(),
2065            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2066                .unwrap()
2067                .with_timezone(&Utc),
2068            None,
2069        );
2070        assert_ne!(digest, other.digest_multibase().unwrap());
2071    }
2072
2073    #[test]
2074    fn test_verify_digest() {
2075        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2076            .unwrap()
2077            .with_timezone(&Utc);
2078
2079        let vrc = DTGCredential::new_vrc(
2080            "did:example:issuer".to_string(),
2081            "did:example:subject".to_string(),
2082            valid_from,
2083            None,
2084        );
2085
2086        let vwc = DTGCredential::new_vwc(
2087            "did:example:witness".to_string(),
2088            // the DID of the issuer of the VRC being attested
2089            "did:example:issuer".to_string(),
2090            valid_from,
2091            None,
2092            "thread-abc-123".to_string(),
2093            Some(vrc.digest_multibase().unwrap()),
2094            None,
2095        );
2096
2097        assert!(vwc.verify_digest(&vrc).unwrap());
2098
2099        // a different VRC must not match
2100        let other = DTGCredential::new_vrc(
2101            "did:example:issuer".to_string(),
2102            "did:example:someone-else".to_string(),
2103            valid_from,
2104            None,
2105        );
2106        assert!(!vwc.verify_digest(&other).unwrap());
2107    }
2108
2109    #[test]
2110    fn test_verify_digest_without_digest() {
2111        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2112            .unwrap()
2113            .with_timezone(&Utc);
2114
2115        let vrc = DTGCredential::new_vrc(
2116            "did:example:issuer".to_string(),
2117            "did:example:subject".to_string(),
2118            valid_from,
2119            None,
2120        );
2121
2122        // digest is OPTIONAL - with none present there is nothing to rely on
2123        let vwc = DTGCredential::new_vwc(
2124            "did:example:witness".to_string(),
2125            "did:example:issuer".to_string(),
2126            valid_from,
2127            None,
2128            "thread-abc-123".to_string(),
2129            None,
2130            None,
2131        );
2132
2133        assert!(!vwc.verify_digest(&vrc).unwrap());
2134    }
2135
2136    /// The digest encoding is the interoperability surface: a credential referencing another
2137    /// is compared against a value some other implementation produced. Pinned against a
2138    /// literal rather than a recomputation, because a test that recomputes agrees with
2139    /// whatever the code does and would follow the encoding silently if it drifted.
2140    #[test]
2141    fn test_digest_is_a_base58btc_multihash_over_the_proofless_jcs_form() {
2142        let vmc = DTGCredential::new_vmc(
2143            "did:example:community".to_string(),
2144            "did:example:member".to_string(),
2145            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2146                .unwrap()
2147                .with_timezone(&Utc),
2148            None,
2149            false,
2150        )
2151        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
2152
2153        let digest = vmc.digest_multibase().unwrap();
2154
2155        // Multibase base58btc.
2156        assert!(digest.starts_with('z'), "multibase base58btc prefix");
2157
2158        // Decodes to a sha2-256 multihash: 0x12 0x20 followed by 32 digest bytes.
2159        let (base, bytes) = multibase::decode(&digest).unwrap();
2160        assert_eq!(base, Base::Base58Btc);
2161        assert_eq!(bytes.len(), 34);
2162        assert_eq!(&bytes[..2], &[0x12, 0x20]);
2163
2164        // Computed outside this crate over the JCS canonical form of the document below,
2165        // then wrapped per CID v1.0 §2.4-2.5:
2166        //   {"@context":[...],"credentialSubject":{"id":"did:example:member"},
2167        //    "id":"urn:uuid:2a4e...","issuer":"did:example:community",
2168        //    "type":[...],"validFrom":"2025-12-11T00:00:00Z"}
2169        // whose SHA-256 is 49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2.
2170        assert_eq!(digest, "zQmTJgyPT2ShMQ2AvCHGDoPGjEWyRC7ZNT3MBpe5PP6Vpvu");
2171
2172        // Stable across calls.
2173        assert_eq!(digest, vmc.digest_multibase().unwrap());
2174    }
2175
2176    /// The superseded encoding still produces what it always did, so a caller migrating can
2177    /// recompute a Working Draft 01 digest to compare against one they stored.
2178    #[test]
2179    #[allow(deprecated)]
2180    fn the_superseded_hex_digest_is_unchanged() {
2181        let vmc = DTGCredential::new_vmc(
2182            "did:example:community".to_string(),
2183            "did:example:member".to_string(),
2184            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2185                .unwrap()
2186                .with_timezone(&Utc),
2187            None,
2188            false,
2189        )
2190        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
2191
2192        assert_eq!(
2193            vmc.digest().unwrap(),
2194            "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2"
2195        );
2196    }
2197
2198    /// A Working Draft 01 digest reaching a Working Draft 02 verifier is *reported*, not
2199    /// silently treated as a mismatch. The two say different things: one is a credential
2200    /// that disagrees, the other a credential that cannot be read at all.
2201    #[test]
2202    fn a_superseded_digest_value_is_rejected_as_malformed() {
2203        let err = decode_digest_multibase(
2204            "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2",
2205        )
2206        .unwrap_err();
2207
2208        assert!(
2209            matches!(err, DTGCredentialError::InvalidDigest(_)),
2210            "expected InvalidDigest, got {err:?}"
2211        );
2212    }
2213
2214    /// Digests are compared as decoded bytes, never as strings — the specification requires
2215    /// it, because one digest has more than one spelling.
2216    #[test]
2217    fn digests_are_compared_by_bytes_not_by_string() {
2218        // The same sha2-256 multihash, encoded base58btc and base16. Identical bytes,
2219        // different strings.
2220        let multihash = {
2221            let mut v = vec![0x12u8, 0x20];
2222            v.extend_from_slice(&Sha256::digest(b"an edge credential"));
2223            v
2224        };
2225        let b58 = multibase::encode(Base::Base58Btc, &multihash);
2226        let b16 = multibase::encode(Base::Base16Lower, &multihash);
2227
2228        assert_ne!(b58, b16, "the two spellings differ as strings");
2229        assert!(
2230            digests_match(&b58, &b16).unwrap(),
2231            "but name the same digest"
2232        );
2233    }
2234
2235    /// An algorithm the library does not implement is *rejected*, not reported as a
2236    /// mismatch. A verifier that conflated the two would silently downgrade a governing
2237    /// party's choice of a stronger hash into a failed comparison.
2238    #[test]
2239    fn an_unaccepted_hash_algorithm_is_rejected_rather_than_mismatched() {
2240        // 0x13 is sha2-512 in the multicodec table.
2241        let mut multihash = vec![0x13u8, 0x40];
2242        multihash.extend_from_slice(&[0u8; 64]);
2243        let encoded = multibase::encode(Base::Base58Btc, &multihash);
2244
2245        assert!(matches!(
2246            decode_digest_multibase(&encoded),
2247            Err(DTGCredentialError::UnsupportedDigestAlgorithm(0x13))
2248        ));
2249    }
2250
2251    /// The digest binds to what a credential says, not to a signature over it, so a
2252    /// re-proofed credential still satisfies a reference made against the earlier one. This
2253    /// is what lets a member's acknowledgement survive the community re-signing its grant.
2254    #[cfg(feature = "affinidi-signing")]
2255    #[tokio::test]
2256    async fn test_digest_is_unchanged_by_signing() {
2257        use affinidi_secrets_resolver::secrets::Secret;
2258
2259        let secret = Secret::generate_ed25519(None, None);
2260
2261        let mut vmc = DTGCredential::new_vmc(
2262            "did:example:community".to_string(),
2263            "did:example:member".to_string(),
2264            Utc::now(),
2265            None,
2266            false,
2267        );
2268
2269        let before = vmc.digest_multibase().unwrap();
2270        vmc.sign(&secret, None).await.expect("signs");
2271        assert!(vmc.signed());
2272        assert_eq!(before, vmc.digest_multibase().unwrap());
2273    }
2274
2275    /// A grant in the wire form a member actually receives.
2276    fn wire(c: &DTGCredential) -> Value {
2277        serde_json::to_value(c.credential()).expect("credential serialises")
2278    }
2279
2280    /// The whole point of the pair: a grant and the acknowledgement built from it form a
2281    /// complete membership edge, and the parties are mirrored across the two halves.
2282    #[test]
2283    fn test_member_vmc_acknowledges_its_grant() {
2284        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2285            .unwrap()
2286            .with_timezone(&Utc);
2287
2288        let grant = DTGCredential::new_vmc(
2289            "did:example:community".to_string(),
2290            "did:example:member".to_string(),
2291            valid_from,
2292            None,
2293            false,
2294        );
2295
2296        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2297
2298        // Roles reversed.
2299        assert_eq!(ack.issuer(), "did:example:member");
2300        assert_eq!(ack.subject(), "did:example:community");
2301
2302        // The grant MUST omit the digest; the acknowledgement MUST carry it.
2303        assert_eq!(grant.subject_digest(), None);
2304        assert_eq!(
2305            ack.subject_digest(),
2306            Some(grant.digest_multibase().unwrap().as_str())
2307        );
2308
2309        assert!(ack.acknowledges(&grant).unwrap());
2310    }
2311
2312    /// An acknowledgement completes the edge it names and no other. Each case below verifies
2313    /// as a credential in its own right; what fails is the binding.
2314    #[test]
2315    fn test_acknowledges_rejects_a_mismatched_pair() {
2316        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2317            .unwrap()
2318            .with_timezone(&Utc);
2319
2320        let grant = DTGCredential::new_vmc(
2321            "did:example:community".to_string(),
2322            "did:example:member".to_string(),
2323            valid_from,
2324            None,
2325            false,
2326        );
2327        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2328
2329        // A grant to a different member: right community, wrong edge.
2330        let other_member = DTGCredential::new_vmc(
2331            "did:example:community".to_string(),
2332            "did:example:someone-else".to_string(),
2333            valid_from,
2334            None,
2335            false,
2336        );
2337        assert!(!ack.acknowledges(&other_member).unwrap());
2338
2339        // A grant from a different community.
2340        let other_community = DTGCredential::new_vmc(
2341            "did:example:other-community".to_string(),
2342            "did:example:member".to_string(),
2343            valid_from,
2344            None,
2345            false,
2346        );
2347        assert!(!ack.acknowledges(&other_community).unwrap());
2348
2349        // A re-issued grant to the same member — different claims, so a different digest.
2350        // This is what forces re-acknowledgement on renewal rather than letting a stale
2351        // consent carry over to a membership the member never agreed to.
2352        let renewed = DTGCredential::new_vmc(
2353            "did:example:community".to_string(),
2354            "did:example:member".to_string(),
2355            valid_from + chrono::Duration::days(365),
2356            None,
2357            false,
2358        );
2359        assert!(!ack.acknowledges(&renewed).unwrap());
2360
2361        // The acknowledgement is not itself a grant: acknowledging one forms no edge.
2362        let ack_of_ack =
2363            DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2364        assert!(!ack_of_ack.acknowledges(&ack).unwrap());
2365
2366        // A grant on its own does not complete anything — it carries no digest to check.
2367        assert!(!grant.acknowledges(&grant).unwrap());
2368    }
2369
2370    /// A VDC's `credentialStatus` is CONDITIONAL, not required: a delegation whose validity
2371    /// exceeds the freshness window the governing VTC or VTN defines MUST carry one, and
2372    /// one short enough to be bounded by expiry alone MAY omit it. This library does not
2373    /// know that window, so the entry is attached rather than demanded — and once attached,
2374    /// it must reach the wire.
2375    #[test]
2376    fn a_vdc_carries_the_credential_status_it_is_given() {
2377        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2378            .unwrap()
2379            .with_timezone(&Utc);
2380        let valid_until = DateTime::parse_from_rfc3339("2026-12-11T00:00:00Z")
2381            .unwrap()
2382            .with_timezone(&Utc);
2383
2384        let status = serde_json::json!({
2385            "id": "https://delegator.example/status#12",
2386            "type": "BitstringStatusListEntry",
2387            "statusPurpose": "revocation",
2388            "statusListIndex": "12"
2389        });
2390
2391        let vdc = DTGCredential::new_vdc(
2392            "did:example:delegator".to_string(),
2393            "did:example:delegate".to_string(),
2394            valid_from,
2395            valid_until,
2396            vec!["sign:invoices".to_string()],
2397            None,
2398        )
2399        .expect("a bounded grant is well formed");
2400
2401        // Omitting it is legitimate, so the constructor must not invent one.
2402        assert!(
2403            vdc.credential().credential_status.is_none(),
2404            "a VDC MAY omit `credentialStatus`, so the constructor must not supply one"
2405        );
2406
2407        let vdc = vdc.with_credential_status(status.clone());
2408        assert_eq!(vdc.credential().credential_status.as_ref(), Some(&status));
2409        assert_eq!(wire(&vdc).get("credentialStatus"), Some(&status));
2410
2411        // And it must survive the trip back, or a verifier reading the wire form loses the
2412        // only thing that lets it check revocation.
2413        let parsed: DTGCredential = serde_json::from_value(wire(&vdc)).expect("parses");
2414        assert_eq!(
2415            parsed.credential().credential_status.as_ref(),
2416            Some(&status)
2417        );
2418    }
2419
2420    /// The non-consuming form sets the same field.
2421    #[test]
2422    fn set_credential_status_matches_the_builder() {
2423        let status = serde_json::json!({ "type": "BitstringStatusListEntry" });
2424
2425        let mut vmc = DTGCredential::new_vmc(
2426            "did:example:community".to_string(),
2427            "did:example:member".to_string(),
2428            Utc::now(),
2429            None,
2430            false,
2431        );
2432        vmc.set_credential_status(status.clone());
2433
2434        assert_eq!(vmc.credential().credential_status.as_ref(), Some(&status));
2435    }
2436
2437    /// `DTGCredentialType` derives `PartialEq` so a consumer can assert by equality rather
2438    /// than by pattern, and get the actual variant reported on failure.
2439    #[test]
2440    fn credential_types_compare_by_equality() {
2441        let vdc = DTGCredential::new_vdc(
2442            "did:example:delegator".to_string(),
2443            "did:example:delegate".to_string(),
2444            Utc::now(),
2445            Utc::now() + chrono::Duration::days(1),
2446            vec!["sign:invoices".to_string()],
2447            None,
2448        )
2449        .expect("a bounded grant is well formed");
2450
2451        assert_eq!(vdc.type_(), DTGCredentialType::Delegation);
2452        assert_ne!(vdc.type_(), DTGCredentialType::Membership);
2453    }
2454
2455    /// `credentialStatus` used to be dropped by a parse-then-re-serialise round trip, which
2456    /// silently changed a credential's digest. [`DTGCommon::credential_status`] models it,
2457    /// and this pins that it survives.
2458    #[test]
2459    fn credential_status_survives_a_round_trip() {
2460        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2461            .unwrap()
2462            .with_timezone(&Utc);
2463
2464        let mut grant = wire(&DTGCredential::new_vmc(
2465            "did:example:community".to_string(),
2466            "did:example:member".to_string(),
2467            valid_from,
2468            None,
2469            false,
2470        ));
2471        let status = serde_json::json!({
2472            "id": "https://community.example/status#7",
2473            "type": "BitstringStatusListEntry",
2474            "statusPurpose": "revocation",
2475            "statusListIndex": "7"
2476        });
2477        grant["credentialStatus"] = status.clone();
2478
2479        let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
2480        assert_eq!(
2481            parsed.credential().credential_status.as_ref(),
2482            Some(&status)
2483        );
2484        assert_eq!(wire(&parsed).get("credentialStatus"), Some(&status));
2485        assert_eq!(
2486            parsed.digest_multibase().unwrap(),
2487            digest_multibase_json(&grant).unwrap(),
2488            "the digest must not change under a round trip that preserves every member"
2489        );
2490    }
2491
2492    /// Top-level members this library does not model at all are preserved too, by
2493    /// [`DTGCommon::extra`]. `credentialSchema` stands in for the open set of them.
2494    #[test]
2495    fn unmodelled_top_level_members_survive_a_round_trip() {
2496        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2497            .unwrap()
2498            .with_timezone(&Utc);
2499
2500        let mut grant = wire(&DTGCredential::new_vmc(
2501            "did:example:community".to_string(),
2502            "did:example:member".to_string(),
2503            valid_from,
2504            None,
2505            false,
2506        ));
2507        let schema = serde_json::json!({
2508            "id": "https://community.example/schemas/vmc",
2509            "type": "JsonSchema"
2510        });
2511        grant["credentialSchema"] = schema.clone();
2512
2513        let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
2514        assert_eq!(
2515            parsed.credential().extra.get("credentialSchema"),
2516            Some(&schema)
2517        );
2518        assert_eq!(
2519            parsed.digest_multibase().unwrap(),
2520            digest_multibase_json(&grant).unwrap()
2521        );
2522    }
2523
2524    /// # Why the wire form is still what gets digested
2525    ///
2526    /// [`DTGCommon::extra`] closed the dropped-member hazard, but not the whole of it. A
2527    /// timestamp is *normalized* on the way out — `2025-12-11T00:00:00.000+00:00` and
2528    /// `2025-12-11T00:00:00Z` are the same instant and parse to the same
2529    /// [`chrono::DateTime`], and this library re-serializes both as the latter. The
2530    /// document that comes back out is therefore equivalent to the one that went in, and
2531    /// hashes differently.
2532    ///
2533    /// An acknowledgement built by digesting the *parsed* grant would carry a digest over a
2534    /// document the community never issued, and the community would rightly refuse it.
2535    /// Silently: both credentials verify, and only the digest comparison fails, with
2536    /// nothing to say why.
2537    ///
2538    /// So `new_member_vmc` takes the wire form, and this pins that it digests what it was
2539    /// handed rather than what it could parse.
2540    #[test]
2541    fn the_acknowledgement_digests_the_grant_as_it_arrived() {
2542        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2543            .unwrap()
2544            .with_timezone(&Utc);
2545
2546        let mut grant = wire(&DTGCredential::new_vmc(
2547            "did:example:community".to_string(),
2548            "did:example:member".to_string(),
2549            valid_from,
2550            None,
2551            false,
2552        ));
2553        // The same instant, spelled the way another implementation might.
2554        grant["validFrom"] = Value::String("2025-12-11T00:00:00.000+00:00".to_string());
2555
2556        // The parse normalizes it — this is the hazard, asserted rather than assumed.
2557        let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
2558        assert_ne!(
2559            wire(&parsed).get("validFrom"),
2560            grant.get("validFrom"),
2561            "the model is expected to normalize the timestamp; if it now round-trips \
2562             verbatim, this test has stopped guarding anything"
2563        );
2564
2565        let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
2566
2567        assert_eq!(
2568            ack.subject_digest(),
2569            Some(digest_multibase_json(&grant).unwrap().as_str()),
2570            "the acknowledgement must digest the grant as received"
2571        );
2572        assert_ne!(
2573            ack.subject_digest(),
2574            Some(parsed.digest_multibase().unwrap().as_str()),
2575            "digesting the parsed model would produce a digest the community cannot match"
2576        );
2577    }
2578
2579    #[test]
2580    fn digest_multibase_json_agrees_with_digest_where_the_model_is_complete() {
2581        let vmc = DTGCredential::new_vmc(
2582            "did:example:community".to_string(),
2583            "did:example:member".to_string(),
2584            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2585                .unwrap()
2586                .with_timezone(&Utc),
2587            None,
2588            false,
2589        )
2590        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
2591
2592        assert_eq!(
2593            vmc.digest_multibase().unwrap(),
2594            digest_multibase_json(&wire(&vmc)).unwrap()
2595        );
2596    }
2597
2598    /// `acknowledges` answers only about VMC pairs. A VRC edge is completed by its own
2599    /// reciprocal, not by this.
2600    #[test]
2601    fn test_acknowledges_is_membership_only() {
2602        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2603            .unwrap()
2604            .with_timezone(&Utc);
2605
2606        let grant = DTGCredential::new_vmc(
2607            "did:example:community".to_string(),
2608            "did:example:member".to_string(),
2609            valid_from,
2610            None,
2611            false,
2612        );
2613        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2614
2615        let vrc = DTGCredential::new_vrc(
2616            "did:example:member".to_string(),
2617            "did:example:community".to_string(),
2618            valid_from,
2619            None,
2620        );
2621        assert!(!ack.acknowledges(&vrc).unwrap());
2622
2623        // And a VWC bound to the grant is a witness attestation, not a member's consent.
2624        let vwc = DTGCredential::new_vwc(
2625            "did:example:witness".to_string(),
2626            "did:example:community".to_string(),
2627            valid_from,
2628            None,
2629            "thread-abc-123".to_string(),
2630            Some(grant.digest_multibase().unwrap()),
2631            None,
2632        );
2633        assert!(vwc.verify_digest(&grant).unwrap(), "the digest does match");
2634        assert!(
2635            !vwc.acknowledges(&grant).unwrap(),
2636            "but a VWC is not the member's acknowledgement"
2637        );
2638    }
2639
2640    /// A grant built against something that cannot be one is refused at construction, where
2641    /// the caller can still do something about it — rather than producing an acknowledgement
2642    /// that verifies as a credential and completes no edge.
2643    #[test]
2644    fn test_new_member_vmc_refuses_a_non_grant() {
2645        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2646            .unwrap()
2647            .with_timezone(&Utc);
2648
2649        let vrc = DTGCredential::new_vrc(
2650            "did:example:a".to_string(),
2651            "did:example:b".to_string(),
2652            valid_from,
2653            None,
2654        );
2655        assert!(matches!(
2656            DTGCredential::new_member_vmc(&wire(&vrc), valid_from, None),
2657            Err(DTGCredentialError::NotAMembershipGrant(_))
2658        ));
2659
2660        let grant = DTGCredential::new_vmc(
2661            "did:example:community".to_string(),
2662            "did:example:member".to_string(),
2663            valid_from,
2664            None,
2665            false,
2666        );
2667        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2668        assert!(matches!(
2669            DTGCredential::new_member_vmc(&wire(&ack), valid_from, None),
2670            Err(DTGCredentialError::NotAMembershipGrant(_))
2671        ));
2672    }
2673
2674    /// `{ id, digest }` is shape-identical to a VWC subject, and the untagged enum matches
2675    /// `Witness` first. On a MembershipCredential the credential's `type` is the only thing
2676    /// that says otherwise, so the normalization in `TryFrom<DTGCommon>` is what makes this
2677    /// deserialize as the member-issued half rather than as a witness attestation.
2678    #[test]
2679    fn test_member_issued_vmc_deserializes_as_membership_not_witness() {
2680        let vmc: DTGCredential = serde_json::from_str(
2681            r#"{
2682                "@context": ["https://www.w3.org/ns/credentials/v2"],
2683                "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2684                "issuer": "did:example:member",
2685                "validFrom": "2024-06-18T10:00:00Z",
2686                "credentialSubject": {
2687                    "id": "did:example:community",
2688                    "digestMultibase": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2689                }
2690            }"#,
2691        )
2692        .expect("deserializes");
2693
2694        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
2695        assert!(matches!(
2696            vmc.credential().credential_subject,
2697            CredentialSubject::Membership(_)
2698        ));
2699        assert_eq!(
2700            vmc.subject_digest(),
2701            Some("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
2702        );
2703        assert_eq!(vmc.subject(), "did:example:community");
2704    }
2705
2706    /// `witnessContext` belongs to a VWC. A VMC carrying one is malformed rather than
2707    /// merely surprising, and is refused instead of being silently read as a grant.
2708    #[test]
2709    fn test_membership_credential_rejects_a_witness_context() {
2710        let result: Result<DTGCredential, _> = serde_json::from_str(
2711            r#"{
2712                "@context": ["https://www.w3.org/ns/credentials/v2"],
2713                "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2714                "issuer": "did:example:member",
2715                "validFrom": "2024-06-18T10:00:00Z",
2716                "credentialSubject": {
2717                    "id": "did:example:community",
2718                    "digestMultibase": "sha256:e3b0c4",
2719                    "witnessContext": { "event": "not a membership property" }
2720                }
2721            }"#,
2722        );
2723        assert!(result.is_err());
2724    }
2725
2726    /// The two halves must be distinguishable on the wire by `digestMultibase` alone — that is the
2727    /// only discriminator where both endpoints are C-DIDs, as in VTN membership.
2728    #[test]
2729    fn test_the_two_halves_round_trip_over_the_wire() {
2730        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2731            .unwrap()
2732            .with_timezone(&Utc);
2733
2734        let grant = DTGCredential::new_vmc(
2735            "did:example:community".to_string(),
2736            "did:example:member".to_string(),
2737            valid_from,
2738            None,
2739            false,
2740        );
2741        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2742
2743        let grant_json = serde_json::to_value(&grant).unwrap();
2744        assert!(
2745            grant_json["credentialSubject"]
2746                .get("digestMultibase")
2747                .is_none(),
2748            "the grant MUST omit `digestMultibase`: {grant_json}"
2749        );
2750
2751        let ack_json = serde_json::to_value(&ack).unwrap();
2752        assert_eq!(
2753            ack_json["credentialSubject"]["digestMultibase"],
2754            Value::String(grant.digest_multibase().unwrap()),
2755        );
2756
2757        // And the pair still binds after a round trip through JSON, which is how each side
2758        // actually receives the other's half.
2759        let grant: DTGCredential = serde_json::from_value(grant_json).expect("grant round trips");
2760        let ack: DTGCredential = serde_json::from_value(ack_json).expect("ack round trips");
2761        assert!(ack.acknowledges(&grant).unwrap());
2762    }
2763
2764    #[test]
2765    fn test_iso8601_format_option() {
2766        let now: DateTime<Utc> = DateTime::parse_from_rfc3339(
2767            &Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2768        )
2769        .unwrap()
2770        .to_utc();
2771        let cred = DTGCommon {
2772            valid_until: Some(now),
2773            ..Default::default()
2774        };
2775
2776        let value = serde_json::to_value(&cred).unwrap();
2777        let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
2778        assert_eq!(cred2.valid_until, Some(now));
2779
2780        let cred = DTGCommon::default();
2781        let value = serde_json::to_value(&cred).unwrap();
2782        let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
2783        assert_eq!(cred2.valid_until, None);
2784    }
2785
2786    #[cfg(feature = "affinidi-signing")]
2787    #[tokio::test]
2788    async fn test_signing() {
2789        use affinidi_secrets_resolver::secrets::Secret;
2790
2791        let secret = Secret::generate_ed25519(None, None);
2792
2793        let mut cred = DTGCredential::new_vrc(
2794            "did:example:issuer".to_string(),
2795            "did:example:subject".to_string(),
2796            Utc::now(),
2797            None,
2798        );
2799
2800        assert!(cred.sign(&secret, Some(Utc::now())).await.is_ok());
2801
2802        assert!(
2803            cred.verify_proof_with_public_key(secret.get_public_bytes())
2804                .is_ok()
2805        );
2806
2807        let secret2 = Secret::generate_ed25519(None, None);
2808        assert!(
2809            cred.verify_proof_with_public_key(secret2.get_public_bytes())
2810                .is_err()
2811        );
2812    }
2813
2814    /// The proof covers `id`, so it must be set *before* signing.
2815    ///
2816    /// This is the property that makes [DTGCredential::with_id]'s "set it before signing"
2817    /// caveat load-bearing rather than advisory: a credential signed without an identifier
2818    /// cannot be given one afterwards to satisfy a verifier that requires it, because the
2819    /// document that was signed did not contain it. Tampering with `id` after the fact is
2820    /// the same operation, and must fail the same way.
2821    #[cfg(feature = "affinidi-signing")]
2822    #[tokio::test]
2823    async fn test_id_is_covered_by_the_proof() {
2824        use affinidi_secrets_resolver::secrets::Secret;
2825
2826        let secret = Secret::generate_ed25519(None, None);
2827
2828        let mut cred = DTGCredential::new_vrc(
2829            "did:example:issuer".to_string(),
2830            "did:example:subject".to_string(),
2831            Utc::now(),
2832            None,
2833        )
2834        .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
2835
2836        cred.sign(&secret, Some(Utc::now()))
2837            .await
2838            .expect("signing a credential that carries an id");
2839        assert!(
2840            cred.verify_proof_with_public_key(secret.get_public_bytes())
2841                .is_ok(),
2842            "an id set before signing verifies"
2843        );
2844
2845        // Changing the id after signing — which is what "splice an id into the JSON on the
2846        // way out" amounts to — invalidates the proof.
2847        cred.set_id("urn:uuid:00000000-0000-0000-0000-000000000000");
2848        assert!(
2849            cred.verify_proof_with_public_key(secret.get_public_bytes())
2850                .is_err(),
2851            "an id changed after signing must break the proof"
2852        );
2853    }
2854
2855    #[cfg(feature = "affinidi-signing")]
2856    #[tokio::test]
2857    async fn test_signing_error() {
2858        use affinidi_secrets_resolver::secrets::Secret;
2859
2860        let secret = Secret::generate_x25519(None, None).unwrap();
2861
2862        let mut cred = DTGCredential::new_vrc(
2863            "did:example:issuer".to_string(),
2864            "did:example:subject".to_string(),
2865            Utc::now(),
2866            None,
2867        );
2868
2869        assert!(cred.sign(&secret, Some(Utc::now())).await.is_err());
2870    }
2871
2872    #[cfg(feature = "affinidi-signing")]
2873    #[test]
2874    fn test_signing_no_proof() {
2875        use crate::DTGCredentialError;
2876        use affinidi_secrets_resolver::secrets::Secret;
2877
2878        let cred = DTGCredential::new_vrc(
2879            "did:example:issuer".to_string(),
2880            "did:example:subject".to_string(),
2881            Utc::now(),
2882            None,
2883        );
2884
2885        let secret = Secret::generate_ed25519(None, None);
2886        match cred.verify_proof_with_public_key(secret.get_public_bytes()) {
2887            Err(DTGCredentialError::NotSigned) => {
2888                // Good
2889            }
2890            _ => panic!("Expected NotSigned error!"),
2891        }
2892    }
2893}