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#[derive(Debug, Clone)]
626#[non_exhaustive]
627pub enum DTGCredentialType {
628    Membership,
629    Relationship,
630    Invitation,
631    Persona,
632    Endorsement,
633    Witness,
634
635    /// Verifiable Authority Credential (VAC) — confers authority on a party to perform
636    /// specified actions within a named scope governed by the issuer.
637    ///
638    /// Merged into DTG Core Credentials at Working Draft 02
639    /// (`trustoverip/dtgwg-cred-spec` PR #29). Key control at invocation — a VAC is not a
640    /// bearer credential — is implemented in [crate::authority::verify_chain], ahead of
641    /// PR #41 which states it normatively and removes the `audience` property it made
642    /// redundant. Two further changes are in flight and not implemented here: revocation
643    /// (PR #39) and a `maxAttenuation` ceiling (PR #40).
644    Authority,
645
646    /// Verifiable Delegation Credential (VDC) — establishes that one entity may act in
647    /// another's name.
648    ///
649    /// Merged into DTG Core Credentials at Working Draft 02
650    /// (`trustoverip/dtgwg-cred-spec` PR #19).
651    Delegation,
652
653    /// R-Card is no longer a DTG credential type.
654    #[deprecated(
655        since = "0.2.0",
656        note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
657                It was removed from the DTG Core Credentials specification in Working Draft 01 \
658                and will be defined by the planned DTG Verifiable Data Structures specification. \
659                This variant will be removed in a future release."
660    )]
661    RCard,
662}
663
664impl Display for DTGCredentialType {
665    #[allow(deprecated)]
666    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
667        match self {
668            DTGCredentialType::Membership => write!(f, "MembershipCredential"),
669            DTGCredentialType::Relationship => write!(f, "RelationshipCredential"),
670            DTGCredentialType::Invitation => write!(f, "InvitationCredential"),
671            DTGCredentialType::Persona => write!(f, "PersonaCredential"),
672            DTGCredentialType::Endorsement => write!(f, "EndorsementCredential"),
673            DTGCredentialType::Witness => write!(f, "WitnessCredential"),
674            DTGCredentialType::Authority => write!(f, "AuthorityCredential"),
675            DTGCredentialType::Delegation => write!(f, "DelegationCredential"),
676            DTGCredentialType::RCard => write!(f, "RCardCredential"),
677        }
678    }
679}
680
681/// This helps with matching the right credential type to the [DTGCredentialType]
682const DTG_TYPES: [&str; 9] = [
683    "MembershipCredential",
684    "RelationshipCredential",
685    "InvitationCredential",
686    "PersonaCredential",
687    "EndorsementCredential",
688    "WitnessCredential",
689    "AuthorityCredential",
690    "DelegationCredential",
691    "RCardCredential",
692];
693
694impl TryFrom<&[String]> for DTGCredentialType {
695    type Error = DTGCredentialError;
696
697    #[allow(deprecated)]
698    fn try_from(types: &[String]) -> Result<Self, Self::Error> {
699        if let Some(type_) = DTG_TYPES.iter().find(|t| types.contains(&t.to_string())) {
700            match *type_ {
701                "MembershipCredential" => Ok(DTGCredentialType::Membership),
702                "RelationshipCredential" => Ok(DTGCredentialType::Relationship),
703                "InvitationCredential" => Ok(DTGCredentialType::Invitation),
704                "PersonaCredential" => Ok(DTGCredentialType::Persona),
705                "EndorsementCredential" => Ok(DTGCredentialType::Endorsement),
706                "WitnessCredential" => Ok(DTGCredentialType::Witness),
707                "AuthorityCredential" => Ok(DTGCredentialType::Authority),
708                "DelegationCredential" => Ok(DTGCredentialType::Delegation),
709                "RCardCredential" => Ok(DTGCredentialType::RCard),
710                _ => Err(DTGCredentialError::UnknownCredential),
711            }
712        } else {
713            Err(DTGCredentialError::UnknownCredential)
714        }
715    }
716}
717
718/// All DTG Credentials follow a common structure.
719#[derive(Serialize, Deserialize, Debug, Clone)]
720#[serde(rename_all = "camelCase")]
721pub struct DTGCommon {
722    /// JSON-LD links to contexts
723    /// Must contain at least:
724    /// - <https://www.w3.org/ns/credentials/v2>
725    /// - <https://firstperson.network/credentials/dtg/v1>
726    #[serde(rename = "@context")]
727    pub context: Vec<String>,
728
729    /// Credential type identifiers
730    /// Must contain at least:
731    /// DTGCredential
732    /// VerifiableCredential
733    #[serde(rename = "type")]
734    pub type_: Vec<String>,
735
736    /// OPTIONAL identifier for this specific credential, per the W3C VC Data Model.
737    ///
738    /// When present it MUST be a single URL. A `urn:uuid:` URN is the usual choice for a
739    /// credential with no dereferenceable home.
740    ///
741    /// This is the handle a holder or verifier stores the credential *under*, so it is what
742    /// makes re-delivery of the same credential idempotent and re-issuance of a different one
743    /// recognisable as a renewal rather than a duplicate. A counterparty that keys credentials
744    /// by `id` cannot accept one that has none — so issue with an `id` unless you know nobody
745    /// on the other side needs it.
746    ///
747    /// # Set it before signing
748    ///
749    /// A Data Integrity proof covers the credential minus its `proof`, which includes this
750    /// property. Set it while building — [DTGCredential::with_id] — never after
751    /// [DTGCredential::sign], which would leave a document whose proof no longer verifies.
752    #[serde(skip_serializing_if = "Option::is_none", default)]
753    pub id: Option<String>,
754
755    /// DID of the entity issuing this credential
756    pub issuer: String,
757
758    /// ISO 8601 format of when this credentials become valid from
759    #[serde(serialize_with = "iso8601_format", alias = "issuanceDate")]
760    pub valid_from: DateTime<Utc>,
761
762    /// ISO 8601 format of when these credentials are valid to
763    #[serde(serialize_with = "iso8601_format_option")]
764    #[serde(
765        skip_serializing_if = "Option::is_none",
766        alias = "expirationDate",
767        default
768    )]
769    pub valid_until: Option<DateTime<Utc>>,
770
771    /// Identifier (`threadId`) of the trust task exchange in which this credential was issued.
772    ///
773    /// REQUIRED for [DTGCredentialType::Witness] credentials, OPTIONAL for all other DTG
774    /// credential types. A DTG credential without a `taskContext` MUST be interpretable
775    /// standing alone, independent of any exchange.
776    ///
777    /// NOTE: A verifier MUST NOT interpret a `taskContext`-bearing credential as proof that
778    /// the associated trust task completed unless the matching trust task outcome evidence is
779    /// also present and verified.
780    #[serde(skip_serializing_if = "Option::is_none", default)]
781    pub task_context: Option<String>,
782
783    /// The assertion between the entities involved
784    pub credential_subject: CredentialSubject,
785
786    /// A W3C VC status mechanism through which a verifier determines whether this
787    /// credential has been revoked.
788    ///
789    /// Held as an opaque [`Value`]: the mechanism is chosen by the governing VTC or VTN,
790    /// and this library neither selects one nor resolves it. `BitstringStatusListEntry` is
791    /// the common choice.
792    ///
793    /// CONDITIONAL on a VDC — REQUIRED where the appointment outlives the freshness window
794    /// the governing party defines for delegations, and permitted to be absent otherwise,
795    /// with short validity and re-issuance preferred wherever the delegator is reachable.
796    /// A status check is a live lookup that reveals the verification event to whoever
797    /// hosts the status list.
798    ///
799    /// # Modelled so that digests survive a round trip
800    ///
801    /// Every VMC issued against a status list carries this, and before it was modelled a
802    /// parse-then-re-serialise dropped it silently — producing a digest its issuer would
803    /// not recognise. See [`DTGCommon::extra`], which closes the same gap for members this
804    /// library does not name at all.
805    #[serde(skip_serializing_if = "Option::is_none", default)]
806    pub credential_status: Option<Value>,
807
808    /// Cryptographic proof of credential authenticity
809    #[serde(skip_serializing_if = "Option::is_none", default)]
810    pub proof: Option<DataIntegrityProof>,
811
812    /// Top-level members this library does not model, preserved verbatim.
813    ///
814    /// A DTG credential may legitimately carry properties beyond the ones named here —
815    /// `credentialSchema`, `termsOfUse`, `evidence`, an extension a governing party
816    /// defines. Without somewhere to keep them, a parse-then-re-serialise round trip drops
817    /// them, and the digest computed over the result matches nothing the issuer signed.
818    ///
819    /// Capturing them makes [DTGCredential::digest_multibase] agree with
820    /// [`digest_multibase_json`] over the wire form for any credential whose extra members
821    /// are top-level. It is not a complete answer — the `credentialSubject` types still
822    /// reject members they do not model — so where you hold the bytes a counterparty sent,
823    /// hashing those remains the safe habit.
824    #[serde(flatten)]
825    pub extra: serde_json::Map<String, Value>,
826}
827
828impl DTGCommon {
829    /// Has this credential been signed?
830    /// Returns true if a proof exists
831    /// NOTE: This does NOT validate the proof itself
832    pub fn signed(&self) -> bool {
833        self.proof.is_some()
834    }
835
836    /// This credential's own identifier, if it has one. See [DTGCommon::id].
837    pub fn id(&self) -> Option<&str> {
838        self.id.as_deref()
839    }
840
841    /// Returns the issuer DID
842    pub fn issuer(&self) -> &str {
843        &self.issuer
844    }
845
846    /// Returns the subject DID
847    #[allow(deprecated)]
848    pub fn subject(&self) -> &str {
849        match &self.credential_subject {
850            CredentialSubject::Basic(subject) => &subject.id,
851            CredentialSubject::Endorsement(subject) => &subject.id,
852            CredentialSubject::Witness(subject) => &subject.id,
853            CredentialSubject::Membership(subject) => &subject.id,
854            CredentialSubject::Authority(subject) => &subject.id,
855            CredentialSubject::Delegation(subject) => &subject.id,
856            CredentialSubject::RCard(subject) => &subject.id,
857        }
858    }
859
860    /// The `authority` grant, when this credential is a VAC.
861    ///
862    /// `None` for every other credential type — the accessor is deliberately fallible
863    /// rather than panicking, so a caller handed a credential of unknown type can ask
864    /// without first matching on `type_`.
865    pub fn authority(&self) -> Option<&AuthorityGrant> {
866        match &self.credential_subject {
867            CredentialSubject::Authority(subject) => Some(&subject.authority),
868            _ => None,
869        }
870    }
871
872    /// Mutable access to the `authority` grant, when this credential is a VAC.
873    ///
874    /// Present so that a caller can construct chains this library's own
875    /// [DTGCredential::attenuate] would refuse — which is exactly what a verifier must be
876    /// tested against, since nothing stops another implementation emitting such JSON.
877    pub fn authority_mut(&mut self) -> Option<&mut AuthorityGrant> {
878        match &mut self.credential_subject {
879            CredentialSubject::Authority(subject) => Some(&mut subject.authority),
880            _ => None,
881        }
882    }
883
884    /// The `delegation` object, when this credential is a VDC.
885    ///
886    /// `None` for every other credential type, for the same reason [DTGCommon::authority]
887    /// is fallible: a caller handed a credential of unknown type can ask without first
888    /// matching on `type_`.
889    pub fn delegation(&self) -> Option<&DelegationGrant> {
890        match &self.credential_subject {
891            CredentialSubject::Delegation(subject) => Some(&subject.delegation),
892            _ => None,
893        }
894    }
895
896    /// Mutable access to the `delegation` object, when this credential is a VDC.
897    ///
898    /// Present for the same reason as [DTGCommon::authority_mut]: a verifier must be
899    /// testable against chains this library's own constructors would refuse to build,
900    /// since nothing stops another implementation emitting such JSON.
901    pub fn delegation_mut(&mut self) -> Option<&mut DelegationGrant> {
902        match &mut self.credential_subject {
903            CredentialSubject::Delegation(subject) => Some(&mut subject.delegation),
904            _ => None,
905        }
906    }
907
908    /// The credential is valid from this timestamp
909    pub fn valid_from(&self) -> DateTime<Utc> {
910        self.valid_from
911    }
912
913    /// The credential is valid until this timestamp, if set
914    pub fn valid_until(&self) -> Option<DateTime<Utc>> {
915        self.valid_until
916    }
917
918    /// The `threadId` of the trust task exchange this credential was issued in, if set
919    pub fn task_context(&self) -> Option<&str> {
920        self.task_context.as_deref()
921    }
922}
923
924/// Helps ensure default starting point is correct
925impl Default for DTGCommon {
926    fn default() -> Self {
927        DTGCommon {
928            context: vec![
929                "https://www.w3.org/ns/credentials/v2".to_string(),
930                "https://firstperson.network/credentials/dtg/v1".to_string(),
931            ],
932            type_: vec![
933                "VerifiableCredential".to_string(),
934                "DTGCredential".to_string(),
935            ],
936            id: None,
937            issuer: String::new(),
938            valid_from: Utc::now(),
939            valid_until: None,
940            task_context: None,
941            credential_subject: CredentialSubject::Basic(CredentialSubjectBasic {
942                id: String::new(),
943            }),
944            credential_status: None,
945            proof: None,
946            extra: serde_json::Map::new(),
947        }
948    }
949}
950
951/// Post deserialize setup of a CredentialSubject and CredntialType
952impl TryFrom<DTGCommon> for DTGCredential {
953    type Error = DTGCredentialError;
954
955    #[allow(deprecated)]
956    fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
957        match &value.type_.as_slice().try_into()? {
958            DTGCredentialType::Membership => {
959                // Normalize whichever variant the untagged subject match landed on into
960                // `Membership`, so a caller matching on the subject of a VMC sees one shape
961                // rather than two. See [CredentialSubject::Membership] for why the untagged
962                // match cannot make this decision itself.
963                let subject = match &value.credential_subject {
964                    // Already normalized — a credential built by `new_vmc` /
965                    // `new_member_vmc` rather than deserialized.
966                    CredentialSubject::Membership(subject) => subject.clone(),
967
968                    // `{ id }` — the community-issued grant, which MUST omit `digest`.
969                    CredentialSubject::Basic(subject) => CredentialSubjectMembership {
970                        id: subject.id.clone(),
971                        digest_multibase: None,
972                    },
973
974                    // `{ id, digest }` — the member-issued acknowledgement. Shape-identical
975                    // to a VWC subject, which wins the untagged match; on a
976                    // MembershipCredential it is this. A `witnessContext` alongside it is
977                    // not: that property belongs to a VWC and has no meaning here, so a VMC
978                    // carrying one is malformed rather than merely surprising.
979                    CredentialSubject::Witness(subject) if subject.witness_context.is_none() => {
980                        CredentialSubjectMembership {
981                            id: subject.id.clone(),
982                            digest_multibase: subject.digest_multibase.clone(),
983                        }
984                    }
985
986                    _ => return Err(DTGCredentialError::UnknownCredential),
987                };
988
989                Ok(DTGCredential {
990                    type_: DTGCredentialType::Membership,
991                    version: value.context.as_slice().try_into()?,
992                    credential: DTGCommon {
993                        credential_subject: CredentialSubject::Membership(subject),
994                        ..value
995                    },
996                })
997            }
998            DTGCredentialType::Relationship => Ok(DTGCredential {
999                type_: DTGCredentialType::Relationship,
1000                version: value.context.as_slice().try_into()?,
1001                credential: value,
1002            }),
1003            DTGCredentialType::Invitation => Ok(DTGCredential {
1004                type_: DTGCredentialType::Invitation,
1005                version: value.context.as_slice().try_into()?,
1006                credential: value,
1007            }),
1008            DTGCredentialType::Persona => Ok(DTGCredential {
1009                type_: DTGCredentialType::Persona,
1010                version: value.context.as_slice().try_into()?,
1011                credential: value,
1012            }),
1013            DTGCredentialType::Endorsement => {
1014                if let CredentialSubject::Endorsement { .. } = &value.credential_subject {
1015                    Ok(DTGCredential {
1016                        type_: DTGCredentialType::Endorsement,
1017                        version: value.context.as_slice().try_into()?,
1018                        credential: value,
1019                    })
1020                } else {
1021                    Err(DTGCredentialError::UnknownCredential)
1022                }
1023            }
1024            DTGCredentialType::Witness => {
1025                // taskContext is REQUIRED on a VWC: the meaning of a witness attestation
1026                // depends on the conditions it was made under, which live in the trust task
1027                // exchange it is bound to.
1028                if value.task_context.is_none() {
1029                    return Err(DTGCredentialError::MissingTaskContext);
1030                }
1031
1032                match &value.credential_subject {
1033                    CredentialSubject::Witness(_) => Ok(DTGCredential {
1034                        type_: DTGCredentialType::Witness,
1035                        version: value.context.as_slice().try_into()?,
1036                        credential: value,
1037                    }),
1038                    CredentialSubject::Basic(subject) => {
1039                        // If Witness CredentialSubject only contains id, it is still valid
1040                        Ok(DTGCredential {
1041                            type_: DTGCredentialType::Witness,
1042                            version: value.context.as_slice().try_into()?,
1043                            credential: DTGCommon {
1044                                credential_subject: CredentialSubject::Witness(
1045                                    CredentialSubjectWitness {
1046                                        id: subject.id.clone(),
1047                                        digest_multibase: None,
1048                                        witness_context: None,
1049                                    },
1050                                ),
1051                                ..value
1052                            },
1053                        })
1054                    }
1055                    _ => Err(DTGCredentialError::UnknownCredential),
1056                }
1057            }
1058            DTGCredentialType::Authority => {
1059                // A VAC's subject must actually carry the grant. `Basic` — a bare `{ id }` —
1060                // is the shape a caller lands on when the `authority` member is missing
1061                // entirely, and a credential that confers nothing is malformed rather than
1062                // merely empty. There is no normalization to do here (unlike VMC/VWC, whose
1063                // shapes collide): `authority` is unique to this subject.
1064                match &value.credential_subject {
1065                    CredentialSubject::Authority(subject) => {
1066                        if subject.authority.actions.is_empty() {
1067                            // Emptiness is never a wildcard. Refusing here means a caller
1068                            // cannot construct one by deserialization either.
1069                            return Err(DTGCredentialError::EmptyAuthorityActions);
1070                        }
1071                        Ok(DTGCredential {
1072                            type_: DTGCredentialType::Authority,
1073                            version: value.context.as_slice().try_into()?,
1074                            credential: value,
1075                        })
1076                    }
1077                    _ => Err(DTGCredentialError::UnknownCredential),
1078                }
1079            }
1080            DTGCredentialType::Delegation => {
1081                // A VDC's subject must carry the appointment. `Basic` — a bare `{ id }` —
1082                // is where a caller lands when `delegation` is missing entirely, and a
1083                // credential that appoints nobody to nothing is malformed rather than
1084                // merely empty.
1085                match &value.credential_subject {
1086                    CredentialSubject::Delegation(subject) => {
1087                        let d = &subject.delegation;
1088
1089                        // The two halves are distinguished by `accepts`, and each half has
1090                        // exactly one shape. Refusing the mixtures here means a caller
1091                        // cannot construct one by deserialization either.
1092                        match (&d.accepts, &d.scope) {
1093                            (Some(_), Some(_)) => {
1094                                return Err(DTGCredentialError::MalformedDelegation(
1095                                    "carries both `accepts` and `scope`: an acceptance \
1096                                     consents to the scope of the grant it names rather \
1097                                     than restating it"
1098                                        .into(),
1099                                ));
1100                            }
1101                            (Some(_), None) => {
1102                                if d.parent.is_some() || d.max_depth.is_some() {
1103                                    return Err(DTGCredentialError::MalformedDelegation(
1104                                        "an acceptance carries `accepts` and nothing else".into(),
1105                                    ));
1106                                }
1107                            }
1108                            (None, Some(scope)) => {
1109                                if scope.is_empty() {
1110                                    return Err(DTGCredentialError::MalformedDelegation(
1111                                        "a grant's `scope` MUST contain at least one \
1112                                         entry — emptying it is not how an unbounded \
1113                                         appointment is expressed, because there is no \
1114                                         way to express one"
1115                                            .into(),
1116                                    ));
1117                                }
1118                            }
1119                            (None, None) => {
1120                                return Err(DTGCredentialError::MalformedDelegation(
1121                                    "carries neither `scope` nor `accepts`, so it is \
1122                                     neither a grant nor an acceptance"
1123                                        .into(),
1124                                ));
1125                            }
1126                        }
1127
1128                        Ok(DTGCredential {
1129                            type_: DTGCredentialType::Delegation,
1130                            version: value.context.as_slice().try_into()?,
1131                            credential: value,
1132                        })
1133                    }
1134                    _ => Err(DTGCredentialError::UnknownCredential),
1135                }
1136            }
1137            DTGCredentialType::RCard => match &value.credential_subject {
1138                CredentialSubject::RCard { .. } => Ok(DTGCredential {
1139                    type_: DTGCredentialType::RCard,
1140                    version: value.context.as_slice().try_into()?,
1141                    credential: value,
1142                }),
1143                _ => Err(DTGCredentialError::UnknownCredential),
1144            },
1145        }
1146    }
1147}
1148
1149/// This correctly formats timestamps into the correct iso8601 specification for W3C Verifiable
1150/// Credentials
1151fn iso8601_format<S>(timestamp: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
1152where
1153    S: Serializer,
1154{
1155    s.serialize_str(
1156        timestamp
1157            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1158            .as_str(),
1159    )
1160}
1161
1162fn iso8601_format_option<S>(timestamp: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
1163where
1164    S: Serializer,
1165{
1166    if let Some(timestamp) = timestamp {
1167        s.serialize_str(
1168            timestamp
1169                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1170                .as_str(),
1171        )
1172    } else {
1173        s.serialize_none()
1174    }
1175}
1176
1177// ****************************************************************************
1178// Credential Subject types
1179// ****************************************************************************
1180// NOTE: The DTG credential spec overloads the JSON attributes for different credential payloads.
1181// The following enum will map the credential subject schema to correct Struct type
1182
1183/// This represents all possible credential subjects
1184/// The order of the enum is important as it will match on first match
1185#[allow(deprecated)]
1186#[derive(Serialize, Deserialize, Debug, Clone)]
1187#[serde(untagged)]
1188pub enum CredentialSubject {
1189    /// Verifiable Endorsement Credential subject
1190    Endorsement(CredentialSubjectEndorsement),
1191
1192    /// R-Card Credential subject
1193    #[deprecated(
1194        since = "0.2.0",
1195        note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
1196                See DTGCredentialType::RCard. This variant will be removed in a future release."
1197    )]
1198    RCard(CredentialSubjectRCard),
1199
1200    /// Credential Subject of just `id`
1201    /// Used by a community-issued VMC, and by VRC, VIC and VPC
1202    Basic(CredentialSubjectBasic),
1203
1204    /// Verifiable Witness Credential subject
1205    Witness(CredentialSubjectWitness),
1206
1207    /// Verifiable Authority Credential subject.
1208    ///
1209    /// Unambiguous under the untagged match: no other DTG subject carries an `authority`
1210    /// member, and `deny_unknown_fields` keeps a subject that does not have one from
1211    /// landing here.
1212    Authority(CredentialSubjectAuthority),
1213
1214    /// Verifiable Delegation Credential subject.
1215    ///
1216    /// Unambiguous for the same reason as [CredentialSubject::Authority]: `delegation` is
1217    /// carried by no other DTG subject.
1218    Delegation(CredentialSubjectDelegation),
1219
1220    /// Membership Credential subject, carrying the OPTIONAL `digest` that a member-issued
1221    /// VMC MUST set.
1222    ///
1223    /// # Never selected by the untagged match, deliberately
1224    ///
1225    /// This variant sits last because its two shapes are already claimed above: `{ id }` is
1226    /// [CredentialSubject::Basic], and `{ id, digest }` is indistinguishable from a VWC
1227    /// subject with no `witnessContext`, which [CredentialSubject::Witness] takes first.
1228    /// Nothing in the subject object itself separates a membership acknowledgement from a
1229    /// witness attestation — only the credential's `type` does.
1230    ///
1231    /// So the shape is not decided here. `TryFrom<DTGCommon> for DTGCredential` normalizes
1232    /// whichever variant the untagged match landed on into this one when `type` includes
1233    /// `MembershipCredential`, the same way it already re-wraps a `Basic` subject as
1234    /// `Witness` on a VWC. Deserialization is therefore deterministic rather than
1235    /// order-dependent, and a `Membership` subject reaching a matcher has been through that
1236    /// normalization.
1237    Membership(CredentialSubjectMembership),
1238}
1239
1240/// id of the credential subject only
1241#[derive(Serialize, Deserialize, Debug, Clone)]
1242#[serde(deny_unknown_fields)]
1243pub struct CredentialSubjectBasic {
1244    pub id: String,
1245}
1246
1247/// The `authority` object a [CredentialSubject::Authority] carries.
1248///
1249/// # Attenuation
1250///
1251/// A holder may derive a narrower VAC from one they hold without involving the issuer. An
1252/// attenuated VAC sets [AuthorityGrant::parent] to the **digest** of the credential it
1253/// derives from, and MUST NOT widen `actions`, `scope`, or the validity window. Verification walks
1254/// the chain to a VAC issued by the party governing the scope — see
1255/// [crate::authority::verify_chain], which is where the security of this credential
1256/// actually lives. Issuing one is a struct and a signature; refusing a widening link is the
1257/// part that matters.
1258#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1259#[serde(rename_all = "camelCase", deny_unknown_fields)]
1260pub struct AuthorityGrant {
1261    /// The DID or URI the authority applies to.
1262    ///
1263    /// Matched exactly. A verifier rejects a VAC whose `scope` is not the resource being
1264    /// accessed; nothing here implies containment between scopes.
1265    pub scope: String,
1266
1267    /// The permitted actions, from a vocabulary the governing party defines.
1268    ///
1269    /// MUST NOT be empty. An empty list confers nothing — emptiness is never a wildcard,
1270    /// which is the failure mode this rule exists to prevent. Action strings are compared
1271    /// exactly and case-sensitively, and no action implies another: `admin` does not grant
1272    /// `write` unless both are listed.
1273    pub actions: Vec<String>,
1274
1275    /// The **digest** of the VAC this one was attenuated from, as
1276    /// [DTGCredential::digest_multibase] computes it.
1277    ///
1278    /// Absent means this VAC was issued directly by the party governing the scope, and is
1279    /// therefore a chain root.
1280    ///
1281    /// # A digest, not an identifier
1282    ///
1283    /// Working Draft 02 made this deliberate rather than incidental. A digest names
1284    /// nothing that can be fetched, so verification cannot come to depend on network
1285    /// availability, a verifier cannot be induced to make a request against an address of
1286    /// the holder's choosing, and nobody hosting an identifier learns when a credential is
1287    /// used. It also binds an attenuated VAC to the exact claims its issuer narrowed from:
1288    /// re-issuing a parent with different claims does not re-parent the children of the
1289    /// old one, while re-proofing it with identical claims leaves them undisturbed,
1290    /// because the digest excludes `proof`.
1291    #[serde(skip_serializing_if = "Option::is_none")]
1292    pub parent: Option<String>,
1293}
1294
1295/// The `delegation` object a [CredentialSubject::Delegation] carries.
1296///
1297/// A VDC is one of a **pair**. The delegator issues a *grant* — carrying `scope`, and
1298/// optionally `parent` and `maxDepth` — and the delegate answers with an *acceptance*
1299/// carrying `accepts` and nothing else. The two together form a complete DTG edge, and a
1300/// verifier MUST have both: a grant alone establishes what the delegator appointed, not
1301/// what the delegate agreed to.
1302///
1303/// # A VDC is not authority
1304///
1305/// It never supplies permission the delegator did not itself hold. A verifier presented
1306/// with one substitutes the delegator for the delegate and then asks the permission
1307/// question it would have asked of the delegator directly — live, at the time of the act.
1308/// The reach of a delegated act is the *intersection* of what the delegator may do and
1309/// what the chain appoints the delegate for. See [AuthorityGrant] for the credential that
1310/// answers the permission question.
1311#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
1312#[serde(rename_all = "camelCase", deny_unknown_fields)]
1313pub struct DelegationGrant {
1314    /// The acts the delegate may perform in the delegator's name.
1315    ///
1316    /// REQUIRED on a grant and MUST contain at least one entry — a VDC MUST NOT express an
1317    /// unbounded appointment by omitting or emptying it. MUST be omitted on an acceptance,
1318    /// which consents to the scope of the grant it names rather than restating it.
1319    ///
1320    /// Entries are opaque strings compared for exact equality. The specification defines no
1321    /// wildcard, prefix or hierarchical semantics, so the subset test on a chain is set
1322    /// inclusion over exact matches; a governing vocabulary that wants structure must put
1323    /// it in the terms themselves.
1324    #[serde(skip_serializing_if = "Option::is_none", default)]
1325    pub scope: Option<Vec<String>>,
1326
1327    /// The digest of the VDC this delegation was derived from, when the delegator is
1328    /// itself acting under a delegation. A VDC with no `parent` is a **root delegation**.
1329    #[serde(skip_serializing_if = "Option::is_none", default)]
1330    pub parent: Option<String>,
1331
1332    /// The number of further re-delegations permitted below this one.
1333    ///
1334    /// `0` prohibits re-delegation, and so does **absence** — the default is a single hop.
1335    /// Setting it above `0` is the delegator's explicit authorisation to re-delegate;
1336    /// there is no other. Note that this is the opposite default from a VAC, where
1337    /// attenuation is permitted unless forbidden: a delegate speaks in the principal's
1338    /// name, so the principal keeps the register of who may do so.
1339    #[serde(skip_serializing_if = "Option::is_none", default)]
1340    pub max_depth: Option<u32>,
1341
1342    /// The digest of the grant being accepted.
1343    ///
1344    /// REQUIRED on an acceptance and MUST be omitted on a grant. Its presence is what
1345    /// distinguishes the two halves of a delegation edge.
1346    #[serde(skip_serializing_if = "Option::is_none", default)]
1347    pub accepts: Option<String>,
1348}
1349
1350/// Delegation Credential subject
1351#[derive(Serialize, Deserialize, Debug, Clone)]
1352#[serde(rename_all = "camelCase", deny_unknown_fields)]
1353pub struct CredentialSubjectDelegation {
1354    /// DID of the delegate on a grant; DID of the delegator on an acceptance.
1355    pub id: String,
1356
1357    /// The appointment itself.
1358    pub delegation: DelegationGrant,
1359}
1360
1361/// Verifiable Authority Credential (VAC) subject.
1362#[derive(Serialize, Deserialize, Debug, Clone)]
1363#[serde(rename_all = "camelCase", deny_unknown_fields)]
1364pub struct CredentialSubjectAuthority {
1365    /// DID of the party receiving the authority.
1366    pub id: String,
1367
1368    /// What the subject may do, and where.
1369    pub authority: AuthorityGrant,
1370}
1371
1372/// Membership Credential subject
1373///
1374/// The two directions of a membership edge share this shape and are told apart by
1375/// `digest`: a community-issued VMC (the membership grant) MUST omit it, and a
1376/// member-issued VMC (the membership acknowledgement) MUST carry it. Where both endpoints
1377/// are community identifiers, as in VTN membership, `digestMultibase` is the only
1378/// discriminator — the issuer and subject rules cannot separate the directions.
1379#[derive(Serialize, Deserialize, Debug, Clone)]
1380#[serde(rename_all = "camelCase", deny_unknown_fields)]
1381pub struct CredentialSubjectMembership {
1382    pub id: String,
1383
1384    /// Digest of the community-issued VMC this acknowledges, as
1385    /// [DTGCredential::digest_multibase] computes it.
1386    ///
1387    /// REQUIRED on the member-issued VMC, and MUST be omitted on the community-issued VMC.
1388    /// `Option` rather than two structs because the same property distinguishes the two
1389    /// directions: a type that could not represent both could not deserialize the pair.
1390    ///
1391    /// Serializes as `digestMultibase`. The Working Draft 01 name `digest` is accepted on
1392    /// the wire so that credentials issued against that draft still parse; the *value*
1393    /// encoding also changed, so such a credential parses and then fails to compare, with
1394    /// [DTGCredentialError::InvalidDigest] rather than a silent mismatch.
1395    #[serde(
1396        rename = "digestMultibase",
1397        alias = "digest",
1398        skip_serializing_if = "Option::is_none",
1399        default
1400    )]
1401    pub digest_multibase: Option<String>,
1402}
1403
1404/// Endorsement Credential subject
1405#[derive(Serialize, Deserialize, Debug, Clone)]
1406#[serde(deny_unknown_fields)]
1407pub struct CredentialSubjectEndorsement {
1408    pub id: String,
1409    /// There is no spec for the endorsement content, so we use a generic JSON value
1410    pub endorsement: Value,
1411}
1412
1413/// Witness Credential subject
1414#[derive(Serialize, Deserialize, Debug, Clone)]
1415#[serde(rename_all = "camelCase", deny_unknown_fields)]
1416pub struct CredentialSubjectWitness {
1417    pub id: String,
1418
1419    /// Digest of the witnessed edge credential, as [DTGCredential::digest_multibase]
1420    /// computes it. REQUIRED by the specification — a VWC without one names the observed
1421    /// party and the exchange, but not which edge was witnessed.
1422    ///
1423    /// Serializes as `digestMultibase`; the Working Draft 01 name `digest` is accepted on
1424    /// the wire.
1425    #[serde(
1426        rename = "digestMultibase",
1427        alias = "digest",
1428        skip_serializing_if = "Option::is_none",
1429        default
1430    )]
1431    pub digest_multibase: Option<String>,
1432
1433    /// There is no spec for the witness context content, so we use a generic JSON value
1434    #[serde(skip_serializing_if = "Option::is_none")]
1435    pub witness_context: Option<WitnessContext>,
1436}
1437
1438/// Witness Credential Context
1439#[derive(Serialize, Deserialize, Debug, Clone)]
1440#[serde(rename_all = "camelCase", deny_unknown_fields)]
1441pub struct WitnessContext {
1442    /// Human-readable event name
1443    pub event: Option<String>,
1444
1445    /// Session or nonce identifier
1446    pub session_id: Option<String>,
1447
1448    ///Verification method used
1449    pub method: Option<String>,
1450}
1451
1452/// R-Card Credential subject
1453#[deprecated(
1454    since = "0.2.0",
1455    note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
1456            See DTGCredentialType::RCard. This struct will be removed in a future release."
1457)]
1458#[derive(Serialize, Deserialize, Debug, Clone)]
1459#[serde(deny_unknown_fields)]
1460pub struct CredentialSubjectRCard {
1461    pub id: String,
1462
1463    /// JCard spec, generic JSON value
1464    pub card: Value,
1465}
1466
1467#[cfg(test)]
1468#[allow(deprecated)]
1469mod tests {
1470    use crate::{
1471        CredentialSubject, CredentialSubjectRCard, DTGCommon, DTGCredential, DTGCredentialError,
1472        DTGCredentialType, W3CVCVersion, decode_digest_multibase, digest_multibase_json,
1473        digests_match,
1474    };
1475    use chrono::{DateTime, Utc};
1476    use multibase::Base;
1477    use serde_json::Value;
1478    use sha2::{Digest, Sha256};
1479
1480    #[test]
1481    fn test_vmc_vc_1_deserialize() {
1482        // tests deserialize a W3C VC Version 1.1 credential
1483        let vmc: DTGCredential = match serde_json::from_str(
1484            r#"{
1485"@context": [
1486    "https://www.w3.org/2018/credentials/v1",
1487    "https://firstperson.network/credentials/dtg/v1",
1488    "https://w3id.org/security/suites/ed25519-2020/v1"
1489  ],
1490  "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1491  "issuer": "did:web:chess-club.example",
1492  "issuanceDate": "2026-01-06T10:00:00Z",
1493  "expirationDate": "2027-01-06T10:00:00Z",
1494  "credentialSubject": {
1495    "id": "did:key:z6MkpTHR8VNs..."
1496  }
1497            }"#,
1498        ) {
1499            Ok(vmc) => vmc,
1500            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1501        };
1502
1503        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1504        assert!(matches!(
1505            vmc.credential().credential_subject,
1506            CredentialSubject::Membership(_)
1507        ));
1508        assert!(matches!(vmc.version, W3CVCVersion::V1_1));
1509        assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V1_1));
1510    }
1511
1512    #[test]
1513    fn test_missing_w3c_context() {
1514        // tests deserialize a W3C VC Version 1.1 credential
1515        assert!(
1516            serde_json::from_str::<DTGCredential>(
1517                r#"{
1518"@context": [
1519    "https://firstperson.network/credentials/dtg/v1",
1520    "https://w3id.org/security/suites/ed25519-2020/v1"
1521  ],
1522  "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1523  "issuer": "did:web:chess-club.example",
1524  "issuanceDate": "2026-01-06T10:00:00Z",
1525  "expirationDate": "2027-01-06T10:00:00Z",
1526  "credentialSubject": {
1527    "id": "did:key:z6MkpTHR8VNs..."
1528  }
1529            }"#,
1530            )
1531            .is_err()
1532        );
1533    }
1534
1535    #[test]
1536    fn test_mutable_credential() {
1537        let mut vmc = DTGCredential::new_vmc(
1538            "did:example:issuer".to_string(),
1539            "did:example:subject".to_string(),
1540            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1541                .unwrap()
1542                .with_timezone(&Utc),
1543            None,
1544            false,
1545        );
1546
1547        let cred = vmc.credential_mut();
1548        cred.type_.push("PersonhoodCredential".to_string());
1549        assert!(vmc.is_personhood_credential());
1550    }
1551
1552    #[test]
1553    fn test_vmc_deserialize() {
1554        let vmc: DTGCredential = match serde_json::from_str(
1555            r#"{
1556                "@context": ["https://www.w3.org/ns/credentials/v2"],
1557                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1558                "issuer": "did:example:community",
1559                "validFrom": "2024-06-18T10:00:00Z",
1560                "credentialSubject": { "id": "did:example:rDid" }
1561            }"#,
1562        ) {
1563            Ok(vmc) => vmc,
1564            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1565        };
1566
1567        assert!(!vmc.is_personhood_credential());
1568        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1569        assert!(matches!(
1570            vmc.credential().credential_subject,
1571            CredentialSubject::Membership(_)
1572        ));
1573        assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V2_0));
1574    }
1575
1576    #[test]
1577    fn test_vmc_phc_deserialize() {
1578        let vmc: DTGCredential = match serde_json::from_str(
1579            r#"{
1580                "@context": ["https://www.w3.org/ns/credentials/v2"],
1581                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential", "PersonhoodCredential"],
1582                "issuer": "did:example:community",
1583                "validFrom": "2024-06-18T10:00:00Z",
1584                "credentialSubject": { "id": "did:example:rDid" }
1585            }"#,
1586        ) {
1587            Ok(vmc) => vmc,
1588            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1589        };
1590
1591        assert!(vmc.is_personhood_credential());
1592        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1593        assert!(matches!(
1594            vmc.credential().credential_subject,
1595            CredentialSubject::Membership(_)
1596        ));
1597    }
1598
1599    #[test]
1600    fn test_vrc_deserialize() {
1601        let vrc: DTGCredential = match serde_json::from_str(
1602            r#"{
1603                "@context": ["https://www.w3.org/ns/credentials/v2"],
1604                "type": ["VerifiableCredential", "DTGCredential",  "RelationshipCredential"],
1605                "issuer": "did:example:governmentAgencyDid",
1606                "validFrom": "2024-06-18T10:00:00Z",
1607                "credentialSubject": { "id": "did:example:citizenRDid" }
1608            }"#,
1609        ) {
1610            Ok(vrc) => vrc,
1611            Err(e) => panic!("Couldn't deserialize VRC: {}", e),
1612        };
1613
1614        assert!(matches!(vrc.type_, DTGCredentialType::Relationship));
1615        assert!(matches!(
1616            vrc.credential().credential_subject,
1617            CredentialSubject::Basic(_)
1618        ));
1619    }
1620
1621    #[test]
1622    fn test_vic_deserialize() {
1623        let vic: DTGCredential = match serde_json::from_str(
1624            r#"{
1625                "@context": ["https://www.w3.org/ns/credentials/v2"],
1626                "type": ["VerifiableCredential", "DTGCredential",  "InvitationCredential"],
1627                "issuer": "did:example:governmentAgencyVicDid",
1628                "validFrom": "2024-06-18T10:00:00Z",
1629                "credentialSubject": { "id": "did:example:citizenRDid" }
1630            }"#,
1631        ) {
1632            Ok(vic) => vic,
1633            Err(e) => panic!("Couldn't deserialize VIC: {}", e),
1634        };
1635
1636        assert!(!vic.is_personhood_credential());
1637        assert!(matches!(vic.type_, DTGCredentialType::Invitation));
1638        assert!(matches!(
1639            vic.credential().credential_subject,
1640            CredentialSubject::Basic(_)
1641        ));
1642    }
1643
1644    #[test]
1645    fn test_vpc_deserialize() {
1646        let vpc: DTGCredential = match serde_json::from_str(
1647            r#"{
1648                "@context": ["https://www.w3.org/ns/credentials/v2"],
1649                "type": ["VerifiableCredential", "DTGCredential",  "PersonaCredential"],
1650                "issuer": "did:example:governmentAgencyDid",
1651                "validFrom": "2024-06-18T10:00:00Z",
1652                "credentialSubject": { "id": "did:example:citizenRDid" }
1653            }"#,
1654        ) {
1655            Ok(vpc) => vpc,
1656            Err(e) => panic!("Couldn't deserialize VPC: {}", e),
1657        };
1658
1659        assert!(matches!(vpc.type_, DTGCredentialType::Persona));
1660        assert!(matches!(
1661            vpc.credential().credential_subject,
1662            CredentialSubject::Basic(_)
1663        ));
1664    }
1665
1666    #[test]
1667    fn test_vec_deserialize() {
1668        let vec: DTGCredential = match serde_json::from_str(
1669            r#"{
1670                "@context": ["https://www.w3.org/ns/credentials/v2"],
1671                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
1672                "issuer": "did:example:governmentAgencyDid",
1673                "validFrom": "2024-06-18T10:00:00Z",
1674                "credentialSubject": { "id": "did:example:citizenRDid", "endorsement": {} }
1675            }"#,
1676        ) {
1677            Ok(vec) => vec,
1678            Err(e) => panic!("Couldn't deserialize VEC: {}", e),
1679        };
1680
1681        assert!(matches!(vec.type_, DTGCredentialType::Endorsement));
1682        assert!(matches!(vec.subject(), "did:example:citizenRDid"));
1683        assert!(matches!(
1684            vec.credential().credential_subject,
1685            CredentialSubject::Endorsement(_)
1686        ));
1687    }
1688
1689    #[test]
1690    fn test_vec_bad_deserialize() {
1691        match serde_json::from_str::<DTGCredential>(
1692            r#"{
1693                "@context": ["https://www.w3.org/ns/credentials/v2"],
1694                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
1695                "issuer": "did:example:governmentAgencyDid",
1696                "validFrom": "2024-06-18T10:00:00Z",
1697                "credentialSubject": { "id": "did:example:citizenRDid", "other": [] }
1698            }"#,
1699        ) {
1700            Ok(_) => panic!("Expected Unknown Credential type"),
1701            Err(_) => {
1702                // Good
1703            }
1704        };
1705    }
1706
1707    #[test]
1708    fn test_vwc_simple_deserialize() {
1709        let vwc: DTGCredential = match serde_json::from_str(
1710            r#"{
1711                "@context": ["https://www.w3.org/ns/credentials/v2"],
1712                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1713                "issuer": "did:example:governmentAgencyDid",
1714                "validFrom": "2024-06-18T10:00:00Z",
1715                "taskContext": "thread-abc-123",
1716                "credentialSubject": { "id": "did:example:citizenRDid" }
1717            }"#,
1718        ) {
1719            Ok(vwc) => vwc,
1720            Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1721        };
1722
1723        assert!(matches!(vwc.type_, DTGCredentialType::Witness));
1724        assert!(matches!(vwc.subject(), "did:example:citizenRDid"));
1725        assert_eq!(vwc.task_context(), Some("thread-abc-123"));
1726        assert!(matches!(
1727            vwc.credential().credential_subject,
1728            CredentialSubject::Witness(_)
1729        ));
1730    }
1731
1732    #[test]
1733    fn test_vwc_full_deserialize() {
1734        let vwc: DTGCredential = match serde_json::from_str(
1735            r#"{
1736                "@context": ["https://www.w3.org/ns/credentials/v2"],
1737                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1738                "issuer": "did:example:governmentAgencyDid",
1739                "validFrom": "2024-06-18T10:00:00Z",
1740                "taskContext": "thread-abc-123",
1741                "credentialSubject": { "id": "did:example:citizenRDid", "digestMultibase": "abcdf", "witnessContext": {} }
1742            }"#,
1743        ) {
1744            Ok(vwc) => vwc,
1745            Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1746        };
1747
1748        assert!(matches!(vwc.type_(), DTGCredentialType::Witness));
1749        assert!(matches!(
1750            vwc.credential().credential_subject,
1751            CredentialSubject::Witness(_)
1752        ));
1753    }
1754
1755    #[test]
1756    fn test_vwc_bad_deserialize() {
1757        if serde_json::from_str::<DTGCredential>(
1758            r#"{
1759                "@context": ["https://www.w3.org/ns/credentials/v2"],
1760                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1761                "issuer": "did:example:governmentAgencyDid",
1762                "validFrom": "2024-06-18T10:00:00Z",
1763                "taskContext": "thread-abc-123",
1764                "credentialSubject": { "id": "did:example:citizenRDid", "digestMultibase": "abcdf", "wrongContext": {}  }
1765            }"#,
1766        ).is_ok() {
1767            panic!("Should have failed due to wrong CredentialSubject!");
1768        }
1769    }
1770
1771    #[test]
1772    fn test_rcard_simple_deserialize() {
1773        let rcard: DTGCredential = match serde_json::from_str(
1774            r#"{
1775                "@context": ["https://www.w3.org/ns/credentials/v2"],
1776                "type": ["VerifiableCredential", "DTGCredential",  "RCardCredential"],
1777                "issuer": "did:example:governmentAgencyDid",
1778                "validFrom": "2024-06-18T10:00:00Z",
1779                "credentialSubject": { "id": "did:example:citizenRDid", "card": [] }
1780            }"#,
1781        ) {
1782            Ok(rcard) => rcard,
1783            Err(e) => panic!("Couldn't deserialize R-Card: {}", e),
1784        };
1785
1786        assert!(matches!(rcard.type_(), DTGCredentialType::RCard));
1787        assert!(matches!(rcard.subject(), "did:example:citizenRDid"));
1788        assert!(matches!(
1789            rcard.credential().credential_subject,
1790            CredentialSubject::RCard(_)
1791        ));
1792    }
1793
1794    #[test]
1795    fn test_rcard_bad_deserialize() {
1796        if serde_json::from_str::<DTGCredential>(
1797            r#"{
1798                "@context": ["https://www.w3.org/ns/credentials/v2"],
1799                "type": ["VerifiableCredential", "DTGCredential",  "RCardCredential"],
1800                "issuer": "did:example:governmentAgencyDid",
1801                "validFrom": "2024-06-18T10:00:00Z",
1802                "credentialSubject": { "id": "did:example:citizenRDid"  }
1803            }"#,
1804        )
1805        .is_ok()
1806        {
1807            panic!("Should have failed due to wrong CredentialSubject!");
1808        }
1809    }
1810    #[test]
1811    fn test_deserialize_unknown() {
1812        match serde_json::from_str::<DTGCredential>(
1813            r#"{
1814                "@context": ["https://www.w3.org/ns/credentials/v2"],
1815                "type": ["VerifiableCredential", "DTGCredential",  "UnknownCredential"],
1816                "issuer": "did:example:governmentAgencyDid",
1817                "validFrom": "2024-06-18T10:00:00Z",
1818                "credentialSubject": { "id": "did:example:citizenRDid" }
1819            }"#,
1820        ) {
1821            Ok(_) => panic!("Expected Unknown Credential type"),
1822            Err(e) => {
1823                if e.to_string() == "Unknown credential type" {
1824                    // test passed
1825                } else {
1826                    panic!("Wrong error type returned");
1827                }
1828            }
1829        };
1830    }
1831
1832    #[test]
1833    fn test_deserialize_mismatched_credential_subject() {
1834        match serde_json::from_str::<DTGCredential>(
1835            r#"{
1836                "@context": ["https://www.w3.org/ns/credentials/v2"],
1837                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
1838                "issuer": "did:example:governmentAgencyDid",
1839                "validFrom": "2024-06-18T10:00:00Z",
1840                "credentialSubject": { "id": "did:example:citizenRDid" }
1841            }"#,
1842        ) {
1843            Ok(_) => panic!("Expected Unknown Credential type"),
1844            Err(e) => {
1845                if e.to_string() == "Unknown credential type" {
1846                    // test passed
1847                } else {
1848                    panic!("Wrong error type returned");
1849                }
1850            }
1851        };
1852    }
1853
1854    #[test]
1855    fn test_proof_signed() {
1856        let cred: DTGCredential = match serde_json::from_str(
1857            r#"{
1858                "@context": ["https://www.w3.org/ns/credentials/v2"],
1859                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1860                "issuer": "did:example:community",
1861                "validFrom": "2024-06-18T10:00:00Z",
1862                "credentialSubject": { "id": "did:example:rDid" },
1863                "proof": {
1864                    "type": "DataIntegrityProof",
1865                    "cryptosuite": "eddsa-jcs-2022",
1866                    "created": "2025-12-04T00:00:00",
1867                    "verificationMethod": "did:example:test#key-1",
1868                    "proofPurpose": "assertionMethod",
1869                    "proofValue": "abcd"
1870                }
1871            }"#,
1872        ) {
1873            Ok(vmc) => vmc,
1874            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1875        };
1876
1877        assert!(cred.signed());
1878        assert!(cred.proof_value().is_some());
1879    }
1880
1881    #[test]
1882    fn test_proof_not_signed() {
1883        let cred: DTGCredential = match serde_json::from_str(
1884            r#"{
1885                "@context": ["https://www.w3.org/ns/credentials/v2"],
1886                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1887                "issuer": "did:example:community",
1888                "validFrom": "2024-06-18T10:00:00Z",
1889                "credentialSubject": { "id": "did:example:rDid" }
1890            }"#,
1891        ) {
1892            Ok(vmc) => vmc,
1893            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1894        };
1895
1896        assert!(!cred.signed());
1897        assert!(cred.proof_value().is_none());
1898    }
1899
1900    #[test]
1901    fn test_helpers() {
1902        let cred: DTGCredential = match serde_json::from_str(
1903            r#"{
1904                "@context": ["https://www.w3.org/ns/credentials/v2"],
1905                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1906                "issuer": "did:example:issuer",
1907                "validFrom": "2024-06-18T00:00:00Z",
1908                "credentialSubject": { "id": "did:example:subject" }
1909            }"#,
1910        ) {
1911            Ok(vmc) => vmc,
1912            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1913        };
1914
1915        assert_eq!(cred.issuer(), "did:example:issuer");
1916        assert_eq!(cred.subject(), "did:example:subject");
1917        assert_eq!(
1918            cred.valid_from()
1919                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1920            "2024-06-18T00:00:00Z"
1921        );
1922        assert_eq!(cred.valid_until(), None);
1923    }
1924
1925    #[test]
1926    fn test_valid_until() {
1927        let cred: DTGCredential = match serde_json::from_str(
1928            r#"{
1929                "@context": ["https://www.w3.org/ns/credentials/v2"],
1930                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1931                "issuer": "did:example:issuer",
1932                "validFrom": "2024-06-18T00:00:00Z",
1933                "validUntil": "2030-01-01T00:00:00Z",
1934                "credentialSubject": { "id": "did:example:subject" }
1935            }"#,
1936        ) {
1937            Ok(vmc) => vmc,
1938            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1939        };
1940
1941        assert_eq!(
1942            cred.valid_until()
1943                .unwrap()
1944                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1945            "2030-01-01T00:00:00Z"
1946        );
1947    }
1948
1949    #[test]
1950    fn test_bad_type() {
1951        assert!(
1952            std::convert::TryInto::<DTGCredentialType>::try_into(
1953                vec!["bad_type".to_string()].as_slice(),
1954            )
1955            .is_err()
1956        );
1957    }
1958
1959    #[test]
1960    fn test_badly_constructed_vwc() {
1961        let mut cred = DTGCommon::default();
1962        cred.type_.push("WitnessCredential".to_string());
1963        // taskContext is set so this exercises the credentialSubject mismatch, not the
1964        // missing-taskContext path covered by test_vwc_missing_task_context()
1965        cred.task_context = Some("thread-abc-123".to_string());
1966        cred.credential_subject = CredentialSubject::RCard(CredentialSubjectRCard {
1967            id: "did:example:bad".to_string(),
1968            card: Value::Null,
1969        });
1970
1971        assert!(std::convert::TryInto::<DTGCredential>::try_into(cred).is_err());
1972    }
1973
1974    #[test]
1975    fn test_vwc_missing_task_context() {
1976        // taskContext is REQUIRED on a VWC
1977        match serde_json::from_str::<DTGCredential>(
1978            r#"{
1979                "@context": ["https://www.w3.org/ns/credentials/v2"],
1980                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1981                "issuer": "did:example:witness",
1982                "validFrom": "2024-06-18T10:00:00Z",
1983                "credentialSubject": { "id": "did:example:observed" }
1984            }"#,
1985        ) {
1986            Ok(_) => panic!("Expected a VWC without taskContext to be rejected"),
1987            Err(e) => assert_eq!(
1988                e.to_string(),
1989                "WitnessCredential is missing the required taskContext property"
1990            ),
1991        }
1992    }
1993
1994    #[test]
1995    fn test_task_context_round_trip() {
1996        // taskContext must survive deserialize -> serialize, otherwise a credential signed
1997        // elsewhere would fail verification here (and vice versa)
1998        let raw = r#"{
1999                "@context": ["https://www.w3.org/ns/credentials/v2"],
2000                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
2001                "issuer": "did:example:witness",
2002                "validFrom": "2024-06-18T10:00:00Z",
2003                "taskContext": "thread-abc-123",
2004                "credentialSubject": { "id": "did:example:observed" }
2005            }"#;
2006
2007        let cred: DTGCredential = serde_json::from_str(raw).unwrap();
2008        let out = serde_json::to_string(&cred).unwrap();
2009
2010        assert!(out.contains(r#""taskContext":"thread-abc-123""#));
2011    }
2012
2013    #[test]
2014    fn test_task_context_optional_on_other_types() {
2015        // taskContext is OPTIONAL everywhere except the VWC
2016        let vrc: DTGCredential = serde_json::from_str(
2017            r#"{
2018                "@context": ["https://www.w3.org/ns/credentials/v2"],
2019                "type": ["VerifiableCredential", "DTGCredential",  "RelationshipCredential"],
2020                "issuer": "did:example:issuer",
2021                "validFrom": "2024-06-18T10:00:00Z",
2022                "credentialSubject": { "id": "did:example:subject" }
2023            }"#,
2024        )
2025        .unwrap();
2026
2027        assert_eq!(vrc.task_context(), None);
2028        // and it is omitted from the serialization entirely when absent
2029        assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
2030    }
2031
2032    #[test]
2033    fn test_digest_multibase() {
2034        let vrc = DTGCredential::new_vrc(
2035            "did:example:issuer".to_string(),
2036            "did:example:subject".to_string(),
2037            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2038                .unwrap()
2039                .with_timezone(&Utc),
2040            None,
2041        );
2042
2043        let digest = vrc.digest_multibase().unwrap();
2044
2045        // base58btc multibase prefix
2046        assert!(digest.starts_with('z'));
2047
2048        // decodes to a sha2-256 multihash: 0x12 0x20 followed by 32 digest bytes
2049        let (base, bytes) = multibase::decode(&digest).unwrap();
2050        assert_eq!(base, multibase::Base::Base58Btc);
2051        assert_eq!(bytes.len(), 34);
2052        assert_eq!(&bytes[..2], &[0x12, 0x20]);
2053
2054        // stable across calls
2055        assert_eq!(digest, vrc.digest_multibase().unwrap());
2056
2057        // and distinct for a different credential
2058        let other = DTGCredential::new_vrc(
2059            "did:example:issuer".to_string(),
2060            "did:example:someone-else".to_string(),
2061            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2062                .unwrap()
2063                .with_timezone(&Utc),
2064            None,
2065        );
2066        assert_ne!(digest, other.digest_multibase().unwrap());
2067    }
2068
2069    #[test]
2070    fn test_verify_digest() {
2071        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2072            .unwrap()
2073            .with_timezone(&Utc);
2074
2075        let vrc = DTGCredential::new_vrc(
2076            "did:example:issuer".to_string(),
2077            "did:example:subject".to_string(),
2078            valid_from,
2079            None,
2080        );
2081
2082        let vwc = DTGCredential::new_vwc(
2083            "did:example:witness".to_string(),
2084            // the DID of the issuer of the VRC being attested
2085            "did:example:issuer".to_string(),
2086            valid_from,
2087            None,
2088            "thread-abc-123".to_string(),
2089            Some(vrc.digest_multibase().unwrap()),
2090            None,
2091        );
2092
2093        assert!(vwc.verify_digest(&vrc).unwrap());
2094
2095        // a different VRC must not match
2096        let other = DTGCredential::new_vrc(
2097            "did:example:issuer".to_string(),
2098            "did:example:someone-else".to_string(),
2099            valid_from,
2100            None,
2101        );
2102        assert!(!vwc.verify_digest(&other).unwrap());
2103    }
2104
2105    #[test]
2106    fn test_verify_digest_without_digest() {
2107        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2108            .unwrap()
2109            .with_timezone(&Utc);
2110
2111        let vrc = DTGCredential::new_vrc(
2112            "did:example:issuer".to_string(),
2113            "did:example:subject".to_string(),
2114            valid_from,
2115            None,
2116        );
2117
2118        // digest is OPTIONAL - with none present there is nothing to rely on
2119        let vwc = DTGCredential::new_vwc(
2120            "did:example:witness".to_string(),
2121            "did:example:issuer".to_string(),
2122            valid_from,
2123            None,
2124            "thread-abc-123".to_string(),
2125            None,
2126            None,
2127        );
2128
2129        assert!(!vwc.verify_digest(&vrc).unwrap());
2130    }
2131
2132    /// The digest encoding is the interoperability surface: a credential referencing another
2133    /// is compared against a value some other implementation produced. Pinned against a
2134    /// literal rather than a recomputation, because a test that recomputes agrees with
2135    /// whatever the code does and would follow the encoding silently if it drifted.
2136    #[test]
2137    fn test_digest_is_a_base58btc_multihash_over_the_proofless_jcs_form() {
2138        let vmc = DTGCredential::new_vmc(
2139            "did:example:community".to_string(),
2140            "did:example:member".to_string(),
2141            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2142                .unwrap()
2143                .with_timezone(&Utc),
2144            None,
2145            false,
2146        )
2147        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
2148
2149        let digest = vmc.digest_multibase().unwrap();
2150
2151        // Multibase base58btc.
2152        assert!(digest.starts_with('z'), "multibase base58btc prefix");
2153
2154        // Decodes to a sha2-256 multihash: 0x12 0x20 followed by 32 digest bytes.
2155        let (base, bytes) = multibase::decode(&digest).unwrap();
2156        assert_eq!(base, Base::Base58Btc);
2157        assert_eq!(bytes.len(), 34);
2158        assert_eq!(&bytes[..2], &[0x12, 0x20]);
2159
2160        // Computed outside this crate over the JCS canonical form of the document below,
2161        // then wrapped per CID v1.0 §2.4-2.5:
2162        //   {"@context":[...],"credentialSubject":{"id":"did:example:member"},
2163        //    "id":"urn:uuid:2a4e...","issuer":"did:example:community",
2164        //    "type":[...],"validFrom":"2025-12-11T00:00:00Z"}
2165        // whose SHA-256 is 49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2.
2166        assert_eq!(digest, "zQmTJgyPT2ShMQ2AvCHGDoPGjEWyRC7ZNT3MBpe5PP6Vpvu");
2167
2168        // Stable across calls.
2169        assert_eq!(digest, vmc.digest_multibase().unwrap());
2170    }
2171
2172    /// The superseded encoding still produces what it always did, so a caller migrating can
2173    /// recompute a Working Draft 01 digest to compare against one they stored.
2174    #[test]
2175    #[allow(deprecated)]
2176    fn the_superseded_hex_digest_is_unchanged() {
2177        let vmc = DTGCredential::new_vmc(
2178            "did:example:community".to_string(),
2179            "did:example:member".to_string(),
2180            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2181                .unwrap()
2182                .with_timezone(&Utc),
2183            None,
2184            false,
2185        )
2186        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
2187
2188        assert_eq!(
2189            vmc.digest().unwrap(),
2190            "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2"
2191        );
2192    }
2193
2194    /// A Working Draft 01 digest reaching a Working Draft 02 verifier is *reported*, not
2195    /// silently treated as a mismatch. The two say different things: one is a credential
2196    /// that disagrees, the other a credential that cannot be read at all.
2197    #[test]
2198    fn a_superseded_digest_value_is_rejected_as_malformed() {
2199        let err = decode_digest_multibase(
2200            "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2",
2201        )
2202        .unwrap_err();
2203
2204        assert!(
2205            matches!(err, DTGCredentialError::InvalidDigest(_)),
2206            "expected InvalidDigest, got {err:?}"
2207        );
2208    }
2209
2210    /// Digests are compared as decoded bytes, never as strings — the specification requires
2211    /// it, because one digest has more than one spelling.
2212    #[test]
2213    fn digests_are_compared_by_bytes_not_by_string() {
2214        // The same sha2-256 multihash, encoded base58btc and base16. Identical bytes,
2215        // different strings.
2216        let multihash = {
2217            let mut v = vec![0x12u8, 0x20];
2218            v.extend_from_slice(&Sha256::digest(b"an edge credential"));
2219            v
2220        };
2221        let b58 = multibase::encode(Base::Base58Btc, &multihash);
2222        let b16 = multibase::encode(Base::Base16Lower, &multihash);
2223
2224        assert_ne!(b58, b16, "the two spellings differ as strings");
2225        assert!(
2226            digests_match(&b58, &b16).unwrap(),
2227            "but name the same digest"
2228        );
2229    }
2230
2231    /// An algorithm the library does not implement is *rejected*, not reported as a
2232    /// mismatch. A verifier that conflated the two would silently downgrade a governing
2233    /// party's choice of a stronger hash into a failed comparison.
2234    #[test]
2235    fn an_unaccepted_hash_algorithm_is_rejected_rather_than_mismatched() {
2236        // 0x13 is sha2-512 in the multicodec table.
2237        let mut multihash = vec![0x13u8, 0x40];
2238        multihash.extend_from_slice(&[0u8; 64]);
2239        let encoded = multibase::encode(Base::Base58Btc, &multihash);
2240
2241        assert!(matches!(
2242            decode_digest_multibase(&encoded),
2243            Err(DTGCredentialError::UnsupportedDigestAlgorithm(0x13))
2244        ));
2245    }
2246
2247    /// The digest binds to what a credential says, not to a signature over it, so a
2248    /// re-proofed credential still satisfies a reference made against the earlier one. This
2249    /// is what lets a member's acknowledgement survive the community re-signing its grant.
2250    #[cfg(feature = "affinidi-signing")]
2251    #[tokio::test]
2252    async fn test_digest_is_unchanged_by_signing() {
2253        use affinidi_secrets_resolver::secrets::Secret;
2254
2255        let secret = Secret::generate_ed25519(None, None);
2256
2257        let mut vmc = DTGCredential::new_vmc(
2258            "did:example:community".to_string(),
2259            "did:example:member".to_string(),
2260            Utc::now(),
2261            None,
2262            false,
2263        );
2264
2265        let before = vmc.digest_multibase().unwrap();
2266        vmc.sign(&secret, None).await.expect("signs");
2267        assert!(vmc.signed());
2268        assert_eq!(before, vmc.digest_multibase().unwrap());
2269    }
2270
2271    /// A grant in the wire form a member actually receives.
2272    fn wire(c: &DTGCredential) -> Value {
2273        serde_json::to_value(c.credential()).expect("credential serialises")
2274    }
2275
2276    /// The whole point of the pair: a grant and the acknowledgement built from it form a
2277    /// complete membership edge, and the parties are mirrored across the two halves.
2278    #[test]
2279    fn test_member_vmc_acknowledges_its_grant() {
2280        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2281            .unwrap()
2282            .with_timezone(&Utc);
2283
2284        let grant = DTGCredential::new_vmc(
2285            "did:example:community".to_string(),
2286            "did:example:member".to_string(),
2287            valid_from,
2288            None,
2289            false,
2290        );
2291
2292        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2293
2294        // Roles reversed.
2295        assert_eq!(ack.issuer(), "did:example:member");
2296        assert_eq!(ack.subject(), "did:example:community");
2297
2298        // The grant MUST omit the digest; the acknowledgement MUST carry it.
2299        assert_eq!(grant.subject_digest(), None);
2300        assert_eq!(
2301            ack.subject_digest(),
2302            Some(grant.digest_multibase().unwrap().as_str())
2303        );
2304
2305        assert!(ack.acknowledges(&grant).unwrap());
2306    }
2307
2308    /// An acknowledgement completes the edge it names and no other. Each case below verifies
2309    /// as a credential in its own right; what fails is the binding.
2310    #[test]
2311    fn test_acknowledges_rejects_a_mismatched_pair() {
2312        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2313            .unwrap()
2314            .with_timezone(&Utc);
2315
2316        let grant = DTGCredential::new_vmc(
2317            "did:example:community".to_string(),
2318            "did:example:member".to_string(),
2319            valid_from,
2320            None,
2321            false,
2322        );
2323        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2324
2325        // A grant to a different member: right community, wrong edge.
2326        let other_member = DTGCredential::new_vmc(
2327            "did:example:community".to_string(),
2328            "did:example:someone-else".to_string(),
2329            valid_from,
2330            None,
2331            false,
2332        );
2333        assert!(!ack.acknowledges(&other_member).unwrap());
2334
2335        // A grant from a different community.
2336        let other_community = DTGCredential::new_vmc(
2337            "did:example:other-community".to_string(),
2338            "did:example:member".to_string(),
2339            valid_from,
2340            None,
2341            false,
2342        );
2343        assert!(!ack.acknowledges(&other_community).unwrap());
2344
2345        // A re-issued grant to the same member — different claims, so a different digest.
2346        // This is what forces re-acknowledgement on renewal rather than letting a stale
2347        // consent carry over to a membership the member never agreed to.
2348        let renewed = DTGCredential::new_vmc(
2349            "did:example:community".to_string(),
2350            "did:example:member".to_string(),
2351            valid_from + chrono::Duration::days(365),
2352            None,
2353            false,
2354        );
2355        assert!(!ack.acknowledges(&renewed).unwrap());
2356
2357        // The acknowledgement is not itself a grant: acknowledging one forms no edge.
2358        let ack_of_ack =
2359            DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2360        assert!(!ack_of_ack.acknowledges(&ack).unwrap());
2361
2362        // A grant on its own does not complete anything — it carries no digest to check.
2363        assert!(!grant.acknowledges(&grant).unwrap());
2364    }
2365
2366    /// `credentialStatus` used to be dropped by a parse-then-re-serialise round trip, which
2367    /// silently changed a credential's digest. [`DTGCommon::credential_status`] models it,
2368    /// and this pins that it survives.
2369    #[test]
2370    fn credential_status_survives_a_round_trip() {
2371        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2372            .unwrap()
2373            .with_timezone(&Utc);
2374
2375        let mut grant = wire(&DTGCredential::new_vmc(
2376            "did:example:community".to_string(),
2377            "did:example:member".to_string(),
2378            valid_from,
2379            None,
2380            false,
2381        ));
2382        let status = serde_json::json!({
2383            "id": "https://community.example/status#7",
2384            "type": "BitstringStatusListEntry",
2385            "statusPurpose": "revocation",
2386            "statusListIndex": "7"
2387        });
2388        grant["credentialStatus"] = status.clone();
2389
2390        let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
2391        assert_eq!(
2392            parsed.credential().credential_status.as_ref(),
2393            Some(&status)
2394        );
2395        assert_eq!(wire(&parsed).get("credentialStatus"), Some(&status));
2396        assert_eq!(
2397            parsed.digest_multibase().unwrap(),
2398            digest_multibase_json(&grant).unwrap(),
2399            "the digest must not change under a round trip that preserves every member"
2400        );
2401    }
2402
2403    /// Top-level members this library does not model at all are preserved too, by
2404    /// [`DTGCommon::extra`]. `credentialSchema` stands in for the open set of them.
2405    #[test]
2406    fn unmodelled_top_level_members_survive_a_round_trip() {
2407        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2408            .unwrap()
2409            .with_timezone(&Utc);
2410
2411        let mut grant = wire(&DTGCredential::new_vmc(
2412            "did:example:community".to_string(),
2413            "did:example:member".to_string(),
2414            valid_from,
2415            None,
2416            false,
2417        ));
2418        let schema = serde_json::json!({
2419            "id": "https://community.example/schemas/vmc",
2420            "type": "JsonSchema"
2421        });
2422        grant["credentialSchema"] = schema.clone();
2423
2424        let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
2425        assert_eq!(
2426            parsed.credential().extra.get("credentialSchema"),
2427            Some(&schema)
2428        );
2429        assert_eq!(
2430            parsed.digest_multibase().unwrap(),
2431            digest_multibase_json(&grant).unwrap()
2432        );
2433    }
2434
2435    /// # Why the wire form is still what gets digested
2436    ///
2437    /// [`DTGCommon::extra`] closed the dropped-member hazard, but not the whole of it. A
2438    /// timestamp is *normalized* on the way out — `2025-12-11T00:00:00.000+00:00` and
2439    /// `2025-12-11T00:00:00Z` are the same instant and parse to the same
2440    /// [`chrono::DateTime`], and this library re-serializes both as the latter. The
2441    /// document that comes back out is therefore equivalent to the one that went in, and
2442    /// hashes differently.
2443    ///
2444    /// An acknowledgement built by digesting the *parsed* grant would carry a digest over a
2445    /// document the community never issued, and the community would rightly refuse it.
2446    /// Silently: both credentials verify, and only the digest comparison fails, with
2447    /// nothing to say why.
2448    ///
2449    /// So `new_member_vmc` takes the wire form, and this pins that it digests what it was
2450    /// handed rather than what it could parse.
2451    #[test]
2452    fn the_acknowledgement_digests_the_grant_as_it_arrived() {
2453        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2454            .unwrap()
2455            .with_timezone(&Utc);
2456
2457        let mut grant = wire(&DTGCredential::new_vmc(
2458            "did:example:community".to_string(),
2459            "did:example:member".to_string(),
2460            valid_from,
2461            None,
2462            false,
2463        ));
2464        // The same instant, spelled the way another implementation might.
2465        grant["validFrom"] = Value::String("2025-12-11T00:00:00.000+00:00".to_string());
2466
2467        // The parse normalizes it — this is the hazard, asserted rather than assumed.
2468        let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
2469        assert_ne!(
2470            wire(&parsed).get("validFrom"),
2471            grant.get("validFrom"),
2472            "the model is expected to normalize the timestamp; if it now round-trips \
2473             verbatim, this test has stopped guarding anything"
2474        );
2475
2476        let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
2477
2478        assert_eq!(
2479            ack.subject_digest(),
2480            Some(digest_multibase_json(&grant).unwrap().as_str()),
2481            "the acknowledgement must digest the grant as received"
2482        );
2483        assert_ne!(
2484            ack.subject_digest(),
2485            Some(parsed.digest_multibase().unwrap().as_str()),
2486            "digesting the parsed model would produce a digest the community cannot match"
2487        );
2488    }
2489
2490    #[test]
2491    fn digest_multibase_json_agrees_with_digest_where_the_model_is_complete() {
2492        let vmc = DTGCredential::new_vmc(
2493            "did:example:community".to_string(),
2494            "did:example:member".to_string(),
2495            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2496                .unwrap()
2497                .with_timezone(&Utc),
2498            None,
2499            false,
2500        )
2501        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
2502
2503        assert_eq!(
2504            vmc.digest_multibase().unwrap(),
2505            digest_multibase_json(&wire(&vmc)).unwrap()
2506        );
2507    }
2508
2509    /// `acknowledges` answers only about VMC pairs. A VRC edge is completed by its own
2510    /// reciprocal, not by this.
2511    #[test]
2512    fn test_acknowledges_is_membership_only() {
2513        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2514            .unwrap()
2515            .with_timezone(&Utc);
2516
2517        let grant = DTGCredential::new_vmc(
2518            "did:example:community".to_string(),
2519            "did:example:member".to_string(),
2520            valid_from,
2521            None,
2522            false,
2523        );
2524        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2525
2526        let vrc = DTGCredential::new_vrc(
2527            "did:example:member".to_string(),
2528            "did:example:community".to_string(),
2529            valid_from,
2530            None,
2531        );
2532        assert!(!ack.acknowledges(&vrc).unwrap());
2533
2534        // And a VWC bound to the grant is a witness attestation, not a member's consent.
2535        let vwc = DTGCredential::new_vwc(
2536            "did:example:witness".to_string(),
2537            "did:example:community".to_string(),
2538            valid_from,
2539            None,
2540            "thread-abc-123".to_string(),
2541            Some(grant.digest_multibase().unwrap()),
2542            None,
2543        );
2544        assert!(vwc.verify_digest(&grant).unwrap(), "the digest does match");
2545        assert!(
2546            !vwc.acknowledges(&grant).unwrap(),
2547            "but a VWC is not the member's acknowledgement"
2548        );
2549    }
2550
2551    /// A grant built against something that cannot be one is refused at construction, where
2552    /// the caller can still do something about it — rather than producing an acknowledgement
2553    /// that verifies as a credential and completes no edge.
2554    #[test]
2555    fn test_new_member_vmc_refuses_a_non_grant() {
2556        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2557            .unwrap()
2558            .with_timezone(&Utc);
2559
2560        let vrc = DTGCredential::new_vrc(
2561            "did:example:a".to_string(),
2562            "did:example:b".to_string(),
2563            valid_from,
2564            None,
2565        );
2566        assert!(matches!(
2567            DTGCredential::new_member_vmc(&wire(&vrc), valid_from, None),
2568            Err(DTGCredentialError::NotAMembershipGrant(_))
2569        ));
2570
2571        let grant = DTGCredential::new_vmc(
2572            "did:example:community".to_string(),
2573            "did:example:member".to_string(),
2574            valid_from,
2575            None,
2576            false,
2577        );
2578        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2579        assert!(matches!(
2580            DTGCredential::new_member_vmc(&wire(&ack), valid_from, None),
2581            Err(DTGCredentialError::NotAMembershipGrant(_))
2582        ));
2583    }
2584
2585    /// `{ id, digest }` is shape-identical to a VWC subject, and the untagged enum matches
2586    /// `Witness` first. On a MembershipCredential the credential's `type` is the only thing
2587    /// that says otherwise, so the normalization in `TryFrom<DTGCommon>` is what makes this
2588    /// deserialize as the member-issued half rather than as a witness attestation.
2589    #[test]
2590    fn test_member_issued_vmc_deserializes_as_membership_not_witness() {
2591        let vmc: DTGCredential = serde_json::from_str(
2592            r#"{
2593                "@context": ["https://www.w3.org/ns/credentials/v2"],
2594                "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2595                "issuer": "did:example:member",
2596                "validFrom": "2024-06-18T10:00:00Z",
2597                "credentialSubject": {
2598                    "id": "did:example:community",
2599                    "digestMultibase": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2600                }
2601            }"#,
2602        )
2603        .expect("deserializes");
2604
2605        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
2606        assert!(matches!(
2607            vmc.credential().credential_subject,
2608            CredentialSubject::Membership(_)
2609        ));
2610        assert_eq!(
2611            vmc.subject_digest(),
2612            Some("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
2613        );
2614        assert_eq!(vmc.subject(), "did:example:community");
2615    }
2616
2617    /// `witnessContext` belongs to a VWC. A VMC carrying one is malformed rather than
2618    /// merely surprising, and is refused instead of being silently read as a grant.
2619    #[test]
2620    fn test_membership_credential_rejects_a_witness_context() {
2621        let result: Result<DTGCredential, _> = serde_json::from_str(
2622            r#"{
2623                "@context": ["https://www.w3.org/ns/credentials/v2"],
2624                "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2625                "issuer": "did:example:member",
2626                "validFrom": "2024-06-18T10:00:00Z",
2627                "credentialSubject": {
2628                    "id": "did:example:community",
2629                    "digestMultibase": "sha256:e3b0c4",
2630                    "witnessContext": { "event": "not a membership property" }
2631                }
2632            }"#,
2633        );
2634        assert!(result.is_err());
2635    }
2636
2637    /// The two halves must be distinguishable on the wire by `digestMultibase` alone — that is the
2638    /// only discriminator where both endpoints are C-DIDs, as in VTN membership.
2639    #[test]
2640    fn test_the_two_halves_round_trip_over_the_wire() {
2641        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2642            .unwrap()
2643            .with_timezone(&Utc);
2644
2645        let grant = DTGCredential::new_vmc(
2646            "did:example:community".to_string(),
2647            "did:example:member".to_string(),
2648            valid_from,
2649            None,
2650            false,
2651        );
2652        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2653
2654        let grant_json = serde_json::to_value(&grant).unwrap();
2655        assert!(
2656            grant_json["credentialSubject"]
2657                .get("digestMultibase")
2658                .is_none(),
2659            "the grant MUST omit `digestMultibase`: {grant_json}"
2660        );
2661
2662        let ack_json = serde_json::to_value(&ack).unwrap();
2663        assert_eq!(
2664            ack_json["credentialSubject"]["digestMultibase"],
2665            Value::String(grant.digest_multibase().unwrap()),
2666        );
2667
2668        // And the pair still binds after a round trip through JSON, which is how each side
2669        // actually receives the other's half.
2670        let grant: DTGCredential = serde_json::from_value(grant_json).expect("grant round trips");
2671        let ack: DTGCredential = serde_json::from_value(ack_json).expect("ack round trips");
2672        assert!(ack.acknowledges(&grant).unwrap());
2673    }
2674
2675    #[test]
2676    fn test_iso8601_format_option() {
2677        let now: DateTime<Utc> = DateTime::parse_from_rfc3339(
2678            &Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2679        )
2680        .unwrap()
2681        .to_utc();
2682        let cred = DTGCommon {
2683            valid_until: Some(now),
2684            ..Default::default()
2685        };
2686
2687        let value = serde_json::to_value(&cred).unwrap();
2688        let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
2689        assert_eq!(cred2.valid_until, Some(now));
2690
2691        let cred = DTGCommon::default();
2692        let value = serde_json::to_value(&cred).unwrap();
2693        let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
2694        assert_eq!(cred2.valid_until, None);
2695    }
2696
2697    #[cfg(feature = "affinidi-signing")]
2698    #[tokio::test]
2699    async fn test_signing() {
2700        use affinidi_secrets_resolver::secrets::Secret;
2701
2702        let secret = Secret::generate_ed25519(None, None);
2703
2704        let mut cred = DTGCredential::new_vrc(
2705            "did:example:issuer".to_string(),
2706            "did:example:subject".to_string(),
2707            Utc::now(),
2708            None,
2709        );
2710
2711        assert!(cred.sign(&secret, Some(Utc::now())).await.is_ok());
2712
2713        assert!(
2714            cred.verify_proof_with_public_key(secret.get_public_bytes())
2715                .is_ok()
2716        );
2717
2718        let secret2 = Secret::generate_ed25519(None, None);
2719        assert!(
2720            cred.verify_proof_with_public_key(secret2.get_public_bytes())
2721                .is_err()
2722        );
2723    }
2724
2725    /// The proof covers `id`, so it must be set *before* signing.
2726    ///
2727    /// This is the property that makes [DTGCredential::with_id]'s "set it before signing"
2728    /// caveat load-bearing rather than advisory: a credential signed without an identifier
2729    /// cannot be given one afterwards to satisfy a verifier that requires it, because the
2730    /// document that was signed did not contain it. Tampering with `id` after the fact is
2731    /// the same operation, and must fail the same way.
2732    #[cfg(feature = "affinidi-signing")]
2733    #[tokio::test]
2734    async fn test_id_is_covered_by_the_proof() {
2735        use affinidi_secrets_resolver::secrets::Secret;
2736
2737        let secret = Secret::generate_ed25519(None, None);
2738
2739        let mut cred = DTGCredential::new_vrc(
2740            "did:example:issuer".to_string(),
2741            "did:example:subject".to_string(),
2742            Utc::now(),
2743            None,
2744        )
2745        .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
2746
2747        cred.sign(&secret, Some(Utc::now()))
2748            .await
2749            .expect("signing a credential that carries an id");
2750        assert!(
2751            cred.verify_proof_with_public_key(secret.get_public_bytes())
2752                .is_ok(),
2753            "an id set before signing verifies"
2754        );
2755
2756        // Changing the id after signing — which is what "splice an id into the JSON on the
2757        // way out" amounts to — invalidates the proof.
2758        cred.set_id("urn:uuid:00000000-0000-0000-0000-000000000000");
2759        assert!(
2760            cred.verify_proof_with_public_key(secret.get_public_bytes())
2761                .is_err(),
2762            "an id changed after signing must break the proof"
2763        );
2764    }
2765
2766    #[cfg(feature = "affinidi-signing")]
2767    #[tokio::test]
2768    async fn test_signing_error() {
2769        use affinidi_secrets_resolver::secrets::Secret;
2770
2771        let secret = Secret::generate_x25519(None, None).unwrap();
2772
2773        let mut cred = DTGCredential::new_vrc(
2774            "did:example:issuer".to_string(),
2775            "did:example:subject".to_string(),
2776            Utc::now(),
2777            None,
2778        );
2779
2780        assert!(cred.sign(&secret, Some(Utc::now())).await.is_err());
2781    }
2782
2783    #[cfg(feature = "affinidi-signing")]
2784    #[test]
2785    fn test_signing_no_proof() {
2786        use crate::DTGCredentialError;
2787        use affinidi_secrets_resolver::secrets::Secret;
2788
2789        let cred = DTGCredential::new_vrc(
2790            "did:example:issuer".to_string(),
2791            "did:example:subject".to_string(),
2792            Utc::now(),
2793            None,
2794        );
2795
2796        let secret = Secret::generate_ed25519(None, None);
2797        match cred.verify_proof_with_public_key(secret.get_public_bytes()) {
2798            Err(DTGCredentialError::NotSigned) => {
2799                // Good
2800            }
2801            _ => panic!("Expected NotSigned error!"),
2802        }
2803    }
2804}