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;
19
20/// What W3C VC Format is the credential using?
21#[derive(Clone, Copy, Debug)]
22pub enum W3CVCVersion {
23    /// <https://www.w3.org/2018/credentials/v1>
24    V1_1,
25
26    /// <https://www.w3.org/ns/credentials/v2>
27    V2_0,
28}
29
30impl TryFrom<&[String]> for W3CVCVersion {
31    type Error = DTGCredentialError;
32
33    /// Will return the W3C Version from the context array
34    fn try_from(types: &[String]) -> Result<Self, Self::Error> {
35        if types.contains(&"https://www.w3.org/2018/credentials/v1".to_string()) {
36            Ok(W3CVCVersion::V1_1)
37        } else if types.contains(&"https://www.w3.org/ns/credentials/v2".to_string()) {
38            Ok(W3CVCVersion::V2_0)
39        } else {
40            Err(DTGCredentialError::UnknownVCVersion)
41        }
42    }
43}
44
45/// Errors related to DTG Credentials
46#[derive(Error, Debug)]
47pub enum DTGCredentialError {
48    #[error("Unknown credential type")]
49    UnknownCredential,
50
51    #[cfg(feature = "affinidi-signing")]
52    #[error("Data Integrity Error: {0}")]
53    DataIntegrity(#[from] DataIntegrityError),
54
55    #[error("Credential is not signed")]
56    NotSigned,
57
58    #[error("Unknown W3C VC Version")]
59    UnknownVCVersion,
60
61    /// An AuthorityCredential (VAC) carried an empty `actions` list.
62    ///
63    /// Emptiness is never a wildcard: a VAC conferring no actions confers nothing, and is
64    /// rejected rather than treated as unrestricted.
65    #[error("AuthorityCredential carries an empty actions list, which confers nothing")]
66    EmptyAuthorityActions,
67
68    /// [DTGCredential::attenuate] was called on a credential that is not a VAC.
69    #[error("not an AuthorityCredential, so there is no authority to attenuate")]
70    NotAnAuthorityCredential,
71
72    /// [DTGCredential::attenuate] was called on a VAC with no `id`.
73    ///
74    /// A derived credential points at its parent by `id`; a parent without one cannot be
75    /// pointed at, so the chain could never be verified. Set one with
76    /// [DTGCredential::with_id] before attenuating.
77    #[error("cannot attenuate a credential with no id — the derived VAC could not name it")]
78    AttenuationParentHasNoId,
79
80    /// An attenuation attempted to confer more than its parent held.
81    #[error("attenuation would widen the parent grant: {0}")]
82    AttenuationWidens(String),
83
84    /// A WitnessCredential (VWC) was missing the REQUIRED `taskContext` property
85    #[error("WitnessCredential is missing the required taskContext property")]
86    MissingTaskContext,
87
88    /// The credential could not be canonicalized (JCS, RFC 8785) for digesting
89    #[error("Could not canonicalize credential: {0}")]
90    Canonicalization(String),
91
92    /// A credential was not of the type an operation requires
93    #[error("Expected a {expected}, got a {got}")]
94    WrongCredentialType { expected: String, got: String },
95
96    /// A membership acknowledgement was built against something that is not a
97    /// community-issued membership grant
98    #[error("Not a community-issued membership grant: {0}")]
99    NotAMembershipGrant(String),
100}
101
102/// Defined DTG Credentials
103#[derive(Serialize, Deserialize, Debug, Clone)]
104#[serde(try_from = "DTGCommon")]
105pub struct DTGCredential {
106    /// The DTG Credential inner struct
107    #[serde(flatten)]
108    credential: DTGCommon,
109
110    /// Type of the credential
111    #[serde(skip)]
112    type_: DTGCredentialType,
113
114    /// W3C VC Version
115    #[serde(skip)]
116    version: W3CVCVersion,
117}
118
119impl DTGCredential {
120    /// get the raw credential
121    pub fn credential(&self) -> &DTGCommon {
122        &self.credential
123    }
124
125    /// Get the raw credential as mutable
126    pub fn credential_mut(&mut self) -> &mut DTGCommon {
127        &mut self.credential
128    }
129
130    /// Has this credential been signed?
131    pub fn signed(&self) -> bool {
132        self.credential.signed()
133    }
134
135    /// get the credential type
136    pub fn type_(&self) -> DTGCredentialType {
137        self.type_.clone()
138    }
139
140    /// This credential's own identifier, if it has one.
141    ///
142    /// `None` for a credential built by one of the `new_*` constructors and never given one
143    /// with [DTGCredential::with_id]. See [DTGCommon::id] for why a counterparty may require
144    /// it.
145    pub fn id(&self) -> Option<&str> {
146        self.credential.id()
147    }
148
149    /// Returns the Issuer DID
150    pub fn issuer(&self) -> &str {
151        self.credential.issuer()
152    }
153
154    /// Returns the Subject DID
155    pub fn subject(&self) -> &str {
156        self.credential.subject()
157    }
158
159    /// Returns the valid_from timestamp
160    pub fn valid_from(&self) -> DateTime<Utc> {
161        self.credential.valid_from()
162    }
163
164    /// Returns the valid until timestamp
165    pub fn valid_until(&self) -> Option<DateTime<Utc>> {
166        self.credential.valid_until()
167    }
168
169    /// The `threadId` of the trust task exchange this credential was issued in, if set
170    ///
171    /// This is always `Some` for [DTGCredentialType::Witness] credentials, where the spec
172    /// makes `taskContext` REQUIRED.
173    pub fn task_context(&self) -> Option<&str> {
174        self.credential.task_context()
175    }
176
177    /// This credential's digest, as the `digest` property of a credential that references
178    /// it — a member-issued VMC acknowledging a membership grant, or a VWC attesting an
179    /// edge credential.
180    ///
181    /// Per DTG Core Credentials, the digest is the SHA-256 hash of the credential's JSON
182    /// representation **excluding its top-level `proof` member**, canonicalized with the
183    /// JSON Canonicalization Scheme ([JCS, RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)),
184    /// encoded as `sha256:` followed by the lowercase hexadecimal digest.
185    ///
186    /// # Why `proof` is excluded
187    ///
188    /// The digest binds to what the credential *says*, not to a particular signature over
189    /// it. A referencing credential therefore survives a re-proofing of its referent: a
190    /// re-signed grant carrying identical claims still satisfies an acknowledgement made
191    /// against the earlier signature. It also means the digest can be computed before the
192    /// referent is signed, and is stable whichever of its proofs a holder happens to have.
193    /// # ⚠️ Only for a credential this library built
194    ///
195    /// This digests the *model*, and the model does not carry every member a
196    /// credential may have — `credentialStatus`, for one, which every VMC issued
197    /// against a status list carries and which `DTGCommon` does not model. Digesting a
198    /// credential that was **received** rather than built here therefore hashes a
199    /// document with those members missing, producing a digest the sender will not
200    /// recognise.
201    ///
202    /// For a credential that arrived from somewhere else, digest the JSON you received
203    /// with [`digest_json`] — never a re-serialisation of a parse of it.
204    pub fn digest(&self) -> Result<String, DTGCredentialError> {
205        let unsigned = DTGCommon {
206            proof: None,
207            ..self.credential.clone()
208        };
209        let value = serde_json::to_value(&unsigned)
210            .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
211        digest_json(&value)
212    }
213
214    /// The digest this credential carries of the credential it references, if it carries one.
215    ///
216    /// `Some` for a member-issued VMC (which MUST carry one) and for a VWC bound to the edge
217    /// credential it attests; `None` for a community-issued VMC, which MUST omit it, and for
218    /// every credential type that has no `digest` property.
219    pub fn subject_digest(&self) -> Option<&str> {
220        match &self.credential.credential_subject {
221            CredentialSubject::Membership(subject) => subject.digest.as_deref(),
222            CredentialSubject::Witness(subject) => subject.digest.as_deref(),
223            _ => None,
224        }
225    }
226
227    /// Computes the digest of this credential in the multibase multihash encoding.
228    ///
229    /// The underlying hash differs from [DTGCredential::digest] in two ways: it is encoded as
230    /// a base58btc multibase multihash rather than `sha256:<hex>`, and it covers the
231    /// credential *including* its `proof`.
232    #[deprecated(
233        since = "0.4.0",
234        note = "This encoding is not what DTG Core Credentials specifies, so digests \
235                produced by it do not interoperate. Use DTGCredential::digest, which \
236                returns the conformant `sha256:<lowercase hex>` over the proofless JCS \
237                canonical form. This method will be removed in a future release."
238    )]
239    pub fn digest_multibase(&self) -> Result<String, DTGCredentialError> {
240        let canonical = serde_json_canonicalizer::to_vec(&self.credential)
241            .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
242
243        // multihash prefix: 0x12 = sha2-256, 0x20 = 32 byte digest length
244        let mut multihash = Vec::with_capacity(34);
245        multihash.extend_from_slice(&[0x12, 0x20]);
246        multihash.extend_from_slice(&Sha256::digest(&canonical));
247
248        Ok(multibase::encode(Base::Base58Btc, &multihash))
249    }
250
251    /// Checks that this credential's `digest` matches the credential it claims to reference.
252    ///
253    /// Answers one question only — whether the hashes agree. It does not check that the two
254    /// credentials are of the types the reference requires, nor that their issuers and
255    /// subjects line up. For a membership acknowledgement, [DTGCredential::acknowledges]
256    /// checks all of that together and is what a verifier completing an edge should call.
257    ///
258    /// Returns `Ok(false)` if the digests do not match, or if this credential carries no
259    /// `digest`, in which case there is nothing to rely on.
260    pub fn verify_digest(&self, referenced: &DTGCredential) -> Result<bool, DTGCredentialError> {
261        let Some(digest) = self.subject_digest() else {
262            return Ok(false);
263        };
264
265        Ok(digest == referenced.digest()?)
266    }
267
268    /// Does this member-issued VMC acknowledge `grant`, completing that membership edge?
269    ///
270    /// A membership edge is complete only when both VMCs of the pair exist and are valid:
271    /// the community-issued VMC that grants membership, and the member-issued VMC that
272    /// acknowledges it. This checks everything that binds the two together:
273    ///
274    /// 1. `grant` is a `MembershipCredential` carrying no `digest` — a community-issued grant
275    /// 2. `self` is a `MembershipCredential` carrying one — a member-issued acknowledgement
276    /// 3. the two name the same pair of parties, in mirrored roles: this credential's issuer
277    ///    is the grant's subject, and its subject is the grant's issuer
278    /// 4. the `digest` matches the grant
279    ///
280    /// Returns `Ok(false)` where any of those does not hold, rather than distinguishing
281    /// them: a caller deciding whether an edge is complete has one decision to make, and
282    /// every failing case answers it the same way.
283    ///
284    /// # What this does not check
285    ///
286    /// Neither credential's proof, and neither validity window. Both are the caller's to
287    /// verify — proof verification needs a resolver this crate does not hold, and whether a
288    /// window is current is a question about an instant the caller chooses. An edge is
289    /// complete when both VMCs are *valid* as well as bound, and this covers only the
290    /// binding.
291    pub fn acknowledges(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
292        if !matches!(self.type_, DTGCredentialType::Membership)
293            || !matches!(grant.type_, DTGCredentialType::Membership)
294        {
295            return Ok(false);
296        }
297
298        // The grant is the half that MUST omit `digest`; a credential carrying one is an
299        // acknowledgement, and an acknowledgement of an acknowledgement is not an edge.
300        if grant.subject_digest().is_some() {
301            return Ok(false);
302        }
303
304        if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
305            return Ok(false);
306        }
307
308        self.verify_digest(grant)
309    }
310
311    /// Returns the proof value if signed else None
312    pub fn proof_value(&self) -> Option<&str> {
313        if let Some(proof) = &self.credential.proof {
314            proof.proof_value.as_deref()
315        } else {
316            None
317        }
318    }
319
320    #[cfg(feature = "affinidi-signing")]
321    /// Sign the credential using W3C Data Integrity Proof with JCS EdDSA 2022
322    /// signing_secret: The secret key to use to sign the credential
323    /// create_time: Optional creation time for the proof, defaults to now if None
324    pub async fn sign(
325        &mut self,
326        signing_secret: &Secret,
327        create_time: Option<DateTime<Utc>>,
328    ) -> Result<DataIntegrityProof, DTGCredentialError> {
329        let mut options = SignOptions::new();
330        if let Some(ts) = create_time {
331            options = options.with_created(ts);
332        }
333
334        let proof = DataIntegrityProof::sign(self, signing_secret, options).await?;
335
336        self.credential.proof = Some(proof.clone());
337        Ok(proof)
338    }
339
340    #[cfg(feature = "affinidi-signing")]
341    /// Verify the credential if you already know the public key bytes
342    /// otherwise use the affinidi_tdk:verify_data() method
343    /// public_key_bytes: The public key bytes to use to verify the credential
344    pub fn verify_proof_with_public_key(
345        &self,
346        public_key_bytes: &[u8],
347    ) -> Result<(), DTGCredentialError> {
348        let proof = if let Some(proof) = &self.credential.proof {
349            proof.clone()
350        } else {
351            use tracing::warn;
352
353            warn!("Trying to verify a DTG Credential that has no proof");
354            return Err(DTGCredentialError::NotSigned);
355        };
356
357        let unsigned = DTGCommon {
358            proof: None,
359            ..self.credential.clone()
360        };
361
362        proof.verify_with_public_key(&unsigned, public_key_bytes, VerifyOptions::new())?;
363        Ok(())
364    }
365
366    /// Is this credential a W3C VC Version 1.1 or 2.0 credential?
367    pub fn get_w3c_vc_version(&self) -> W3CVCVersion {
368        self.version
369    }
370
371    /// returns true if this credential a personhood credential (PHC)
372    pub fn is_personhood_credential(&self) -> bool {
373        if let DTGCredentialType::Membership = self.type_ {
374            self.credential
375                .type_
376                .contains(&"PersonhoodCredential".to_string())
377        } else {
378            false
379        }
380    }
381}
382
383/// The digest a DTG credential carries of another credential, computed over a
384/// credential in its **wire form**.
385///
386/// SHA-256 over the RFC 8785 (JCS) canonicalization of `doc` with its top-level `proof`
387/// member removed, encoded as `sha256:` followed by the lowercase hexadecimal digest.
388/// This is what a member-issued VMC carries of the grant it acknowledges, and what a VWC
389/// carries of the edge credential it attests.
390///
391/// # Digest what you received, not what you parsed
392///
393/// Take the document as it arrived. A credential may carry members this library does not
394/// model — `credentialStatus` is the common one — and a parse-then-re-serialise round
395/// trip drops them silently, so the digest would not match the one its issuer computed.
396/// [`DTGCredential::digest`] is safe only for a credential built in-process; anything
397/// received goes through this.
398///
399/// # Why `proof` is excluded
400///
401/// The digest binds to what the credential says, not to a signature over it, so a
402/// reference survives its referent being re-signed. A re-issued credential carries
403/// different claims and therefore a different digest, which is what makes renewal force
404/// re-acknowledgement.
405pub fn digest_json(doc: &Value) -> Result<String, DTGCredentialError> {
406    let proofless = match doc {
407        Value::Object(members) => {
408            let mut members = members.clone();
409            members.remove("proof");
410            Value::Object(members)
411        }
412        // Not an object: canonicalize as-is. A shape check belongs to the caller, which
413        // has a better error to give than this would.
414        other => other.clone(),
415    };
416
417    let canonical = serde_json_canonicalizer::to_vec(&proofless)
418        .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
419
420    const HEX: &[u8; 16] = b"0123456789abcdef";
421    let mut out = String::with_capacity("sha256:".len() + 64);
422    out.push_str("sha256:");
423    for byte in Sha256::digest(&canonical) {
424        out.push(HEX[(byte >> 4) as usize] as char);
425        out.push(HEX[(byte & 0x0f) as usize] as char);
426    }
427    Ok(out)
428}
429
430/// TDG VC Type Identifiers
431#[derive(Debug, Clone)]
432#[non_exhaustive]
433pub enum DTGCredentialType {
434    Membership,
435    Relationship,
436    Invitation,
437    Persona,
438    Endorsement,
439    Witness,
440
441    /// Verifiable Authority Credential (VAC) — confers authority on a party to perform
442    /// specified actions within a named scope governed by the issuer.
443    ///
444    /// Tracks a draft: `trustoverip/dtgwg-cred-spec` PR #29. The shape may move before the
445    /// specification is approved.
446    Authority,
447
448    /// Verifiable Delegation Credential (VDC) — establishes that one entity may act in
449    /// another's name.
450    ///
451    /// Tracks a draft: `trustoverip/dtgwg-cred-spec` PR #19. The shape may move before the
452    /// specification is approved.
453    Delegation,
454
455    /// R-Card is no longer a DTG credential type.
456    #[deprecated(
457        since = "0.2.0",
458        note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
459                It was removed from the DTG Core Credentials specification in Working Draft 01 \
460                and will be defined by the planned DTG Verifiable Data Structures specification. \
461                This variant will be removed in a future release."
462    )]
463    RCard,
464}
465
466impl Display for DTGCredentialType {
467    #[allow(deprecated)]
468    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
469        match self {
470            DTGCredentialType::Membership => write!(f, "MembershipCredential"),
471            DTGCredentialType::Relationship => write!(f, "RelationshipCredential"),
472            DTGCredentialType::Invitation => write!(f, "InvitationCredential"),
473            DTGCredentialType::Persona => write!(f, "PersonaCredential"),
474            DTGCredentialType::Endorsement => write!(f, "EndorsementCredential"),
475            DTGCredentialType::Witness => write!(f, "WitnessCredential"),
476            DTGCredentialType::Authority => write!(f, "AuthorityCredential"),
477            DTGCredentialType::Delegation => write!(f, "DelegationCredential"),
478            DTGCredentialType::RCard => write!(f, "RCardCredential"),
479        }
480    }
481}
482
483/// This helps with matching the right credential type to the [DTGCredentialType]
484const DTG_TYPES: [&str; 9] = [
485    "MembershipCredential",
486    "RelationshipCredential",
487    "InvitationCredential",
488    "PersonaCredential",
489    "EndorsementCredential",
490    "WitnessCredential",
491    "AuthorityCredential",
492    "DelegationCredential",
493    "RCardCredential",
494];
495
496impl TryFrom<&[String]> for DTGCredentialType {
497    type Error = DTGCredentialError;
498
499    #[allow(deprecated)]
500    fn try_from(types: &[String]) -> Result<Self, Self::Error> {
501        if let Some(type_) = DTG_TYPES.iter().find(|t| types.contains(&t.to_string())) {
502            match *type_ {
503                "MembershipCredential" => Ok(DTGCredentialType::Membership),
504                "RelationshipCredential" => Ok(DTGCredentialType::Relationship),
505                "InvitationCredential" => Ok(DTGCredentialType::Invitation),
506                "PersonaCredential" => Ok(DTGCredentialType::Persona),
507                "EndorsementCredential" => Ok(DTGCredentialType::Endorsement),
508                "WitnessCredential" => Ok(DTGCredentialType::Witness),
509                "AuthorityCredential" => Ok(DTGCredentialType::Authority),
510                "DelegationCredential" => Ok(DTGCredentialType::Delegation),
511                "RCardCredential" => Ok(DTGCredentialType::RCard),
512                _ => Err(DTGCredentialError::UnknownCredential),
513            }
514        } else {
515            Err(DTGCredentialError::UnknownCredential)
516        }
517    }
518}
519
520/// All DTG Credentials follow a common structure.
521#[derive(Serialize, Deserialize, Debug, Clone)]
522#[serde(rename_all = "camelCase")]
523pub struct DTGCommon {
524    /// JSON-LD links to contexts
525    /// Must contain at least:
526    /// - <https://www.w3.org/ns/credentials/v2>
527    /// - <https://firstperson.network/credentials/dtg/v1>
528    #[serde(rename = "@context")]
529    pub context: Vec<String>,
530
531    /// Credential type identifiers
532    /// Must contain at least:
533    /// DTGCredential
534    /// VerifiableCredential
535    #[serde(rename = "type")]
536    pub type_: Vec<String>,
537
538    /// OPTIONAL identifier for this specific credential, per the W3C VC Data Model.
539    ///
540    /// When present it MUST be a single URL. A `urn:uuid:` URN is the usual choice for a
541    /// credential with no dereferenceable home.
542    ///
543    /// This is the handle a holder or verifier stores the credential *under*, so it is what
544    /// makes re-delivery of the same credential idempotent and re-issuance of a different one
545    /// recognisable as a renewal rather than a duplicate. A counterparty that keys credentials
546    /// by `id` cannot accept one that has none — so issue with an `id` unless you know nobody
547    /// on the other side needs it.
548    ///
549    /// # Set it before signing
550    ///
551    /// A Data Integrity proof covers the credential minus its `proof`, which includes this
552    /// property. Set it while building — [DTGCredential::with_id] — never after
553    /// [DTGCredential::sign], which would leave a document whose proof no longer verifies.
554    #[serde(skip_serializing_if = "Option::is_none", default)]
555    pub id: Option<String>,
556
557    /// DID of the entity issuing this credential
558    pub issuer: String,
559
560    /// ISO 8601 format of when this credentials become valid from
561    #[serde(serialize_with = "iso8601_format", alias = "issuanceDate")]
562    pub valid_from: DateTime<Utc>,
563
564    /// ISO 8601 format of when these credentials are valid to
565    #[serde(serialize_with = "iso8601_format_option")]
566    #[serde(
567        skip_serializing_if = "Option::is_none",
568        alias = "expirationDate",
569        default
570    )]
571    pub valid_until: Option<DateTime<Utc>>,
572
573    /// Identifier (`threadId`) of the trust task exchange in which this credential was issued.
574    ///
575    /// REQUIRED for [DTGCredentialType::Witness] credentials, OPTIONAL for all other DTG
576    /// credential types. A DTG credential without a `taskContext` MUST be interpretable
577    /// standing alone, independent of any exchange.
578    ///
579    /// NOTE: A verifier MUST NOT interpret a `taskContext`-bearing credential as proof that
580    /// the associated trust task completed unless the matching trust task outcome evidence is
581    /// also present and verified.
582    #[serde(skip_serializing_if = "Option::is_none", default)]
583    pub task_context: Option<String>,
584
585    /// The assertion between the entities involved
586    pub credential_subject: CredentialSubject,
587
588    /// Cryptographic proof of credential authenticity
589    #[serde(skip_serializing_if = "Option::is_none", default)]
590    pub proof: Option<DataIntegrityProof>,
591}
592
593impl DTGCommon {
594    /// Has this credential been signed?
595    /// Returns true if a proof exists
596    /// NOTE: This does NOT validate the proof itself
597    pub fn signed(&self) -> bool {
598        self.proof.is_some()
599    }
600
601    /// This credential's own identifier, if it has one. See [DTGCommon::id].
602    pub fn id(&self) -> Option<&str> {
603        self.id.as_deref()
604    }
605
606    /// Returns the issuer DID
607    pub fn issuer(&self) -> &str {
608        &self.issuer
609    }
610
611    /// Returns the subject DID
612    #[allow(deprecated)]
613    pub fn subject(&self) -> &str {
614        match &self.credential_subject {
615            CredentialSubject::Basic(subject) => &subject.id,
616            CredentialSubject::Endorsement(subject) => &subject.id,
617            CredentialSubject::Witness(subject) => &subject.id,
618            CredentialSubject::Membership(subject) => &subject.id,
619            CredentialSubject::Authority(subject) => &subject.id,
620            CredentialSubject::RCard(subject) => &subject.id,
621        }
622    }
623
624    /// The `authority` grant, when this credential is a VAC.
625    ///
626    /// `None` for every other credential type — the accessor is deliberately fallible
627    /// rather than panicking, so a caller handed a credential of unknown type can ask
628    /// without first matching on `type_`.
629    pub fn authority(&self) -> Option<&AuthorityGrant> {
630        match &self.credential_subject {
631            CredentialSubject::Authority(subject) => Some(&subject.authority),
632            _ => None,
633        }
634    }
635
636    /// Mutable access to the `authority` grant, when this credential is a VAC.
637    ///
638    /// Present so that a caller can construct chains this library's own
639    /// [DTGCredential::attenuate] would refuse — which is exactly what a verifier must be
640    /// tested against, since nothing stops another implementation emitting such JSON.
641    pub fn authority_mut(&mut self) -> Option<&mut AuthorityGrant> {
642        match &mut self.credential_subject {
643            CredentialSubject::Authority(subject) => Some(&mut subject.authority),
644            _ => None,
645        }
646    }
647
648    /// The credential is valid from this timestamp
649    pub fn valid_from(&self) -> DateTime<Utc> {
650        self.valid_from
651    }
652
653    /// The credential is valid until this timestamp, if set
654    pub fn valid_until(&self) -> Option<DateTime<Utc>> {
655        self.valid_until
656    }
657
658    /// The `threadId` of the trust task exchange this credential was issued in, if set
659    pub fn task_context(&self) -> Option<&str> {
660        self.task_context.as_deref()
661    }
662}
663
664/// Helps ensure default starting point is correct
665impl Default for DTGCommon {
666    fn default() -> Self {
667        DTGCommon {
668            context: vec![
669                "https://www.w3.org/ns/credentials/v2".to_string(),
670                "https://firstperson.network/credentials/dtg/v1".to_string(),
671            ],
672            type_: vec![
673                "VerifiableCredential".to_string(),
674                "DTGCredential".to_string(),
675            ],
676            id: None,
677            issuer: String::new(),
678            valid_from: Utc::now(),
679            valid_until: None,
680            task_context: None,
681            credential_subject: CredentialSubject::Basic(CredentialSubjectBasic {
682                id: String::new(),
683            }),
684            proof: None,
685        }
686    }
687}
688
689/// Post deserialize setup of a CredentialSubject and CredntialType
690impl TryFrom<DTGCommon> for DTGCredential {
691    type Error = DTGCredentialError;
692
693    #[allow(deprecated)]
694    fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
695        match &value.type_.as_slice().try_into()? {
696            DTGCredentialType::Membership => {
697                // Normalize whichever variant the untagged subject match landed on into
698                // `Membership`, so a caller matching on the subject of a VMC sees one shape
699                // rather than two. See [CredentialSubject::Membership] for why the untagged
700                // match cannot make this decision itself.
701                let subject = match &value.credential_subject {
702                    // Already normalized — a credential built by `new_vmc` /
703                    // `new_member_vmc` rather than deserialized.
704                    CredentialSubject::Membership(subject) => subject.clone(),
705
706                    // `{ id }` — the community-issued grant, which MUST omit `digest`.
707                    CredentialSubject::Basic(subject) => CredentialSubjectMembership {
708                        id: subject.id.clone(),
709                        digest: None,
710                    },
711
712                    // `{ id, digest }` — the member-issued acknowledgement. Shape-identical
713                    // to a VWC subject, which wins the untagged match; on a
714                    // MembershipCredential it is this. A `witnessContext` alongside it is
715                    // not: that property belongs to a VWC and has no meaning here, so a VMC
716                    // carrying one is malformed rather than merely surprising.
717                    CredentialSubject::Witness(subject) if subject.witness_context.is_none() => {
718                        CredentialSubjectMembership {
719                            id: subject.id.clone(),
720                            digest: subject.digest.clone(),
721                        }
722                    }
723
724                    _ => return Err(DTGCredentialError::UnknownCredential),
725                };
726
727                Ok(DTGCredential {
728                    type_: DTGCredentialType::Membership,
729                    version: value.context.as_slice().try_into()?,
730                    credential: DTGCommon {
731                        credential_subject: CredentialSubject::Membership(subject),
732                        ..value
733                    },
734                })
735            }
736            DTGCredentialType::Relationship => Ok(DTGCredential {
737                type_: DTGCredentialType::Relationship,
738                version: value.context.as_slice().try_into()?,
739                credential: value,
740            }),
741            DTGCredentialType::Invitation => Ok(DTGCredential {
742                type_: DTGCredentialType::Invitation,
743                version: value.context.as_slice().try_into()?,
744                credential: value,
745            }),
746            DTGCredentialType::Persona => Ok(DTGCredential {
747                type_: DTGCredentialType::Persona,
748                version: value.context.as_slice().try_into()?,
749                credential: value,
750            }),
751            DTGCredentialType::Endorsement => {
752                if let CredentialSubject::Endorsement { .. } = &value.credential_subject {
753                    Ok(DTGCredential {
754                        type_: DTGCredentialType::Endorsement,
755                        version: value.context.as_slice().try_into()?,
756                        credential: value,
757                    })
758                } else {
759                    Err(DTGCredentialError::UnknownCredential)
760                }
761            }
762            DTGCredentialType::Witness => {
763                // taskContext is REQUIRED on a VWC: the meaning of a witness attestation
764                // depends on the conditions it was made under, which live in the trust task
765                // exchange it is bound to.
766                if value.task_context.is_none() {
767                    return Err(DTGCredentialError::MissingTaskContext);
768                }
769
770                match &value.credential_subject {
771                    CredentialSubject::Witness(_) => Ok(DTGCredential {
772                        type_: DTGCredentialType::Witness,
773                        version: value.context.as_slice().try_into()?,
774                        credential: value,
775                    }),
776                    CredentialSubject::Basic(subject) => {
777                        // If Witness CredentialSubject only contains id, it is still valid
778                        Ok(DTGCredential {
779                            type_: DTGCredentialType::Witness,
780                            version: value.context.as_slice().try_into()?,
781                            credential: DTGCommon {
782                                credential_subject: CredentialSubject::Witness(
783                                    CredentialSubjectWitness {
784                                        id: subject.id.clone(),
785                                        digest: None,
786                                        witness_context: None,
787                                    },
788                                ),
789                                ..value
790                            },
791                        })
792                    }
793                    _ => Err(DTGCredentialError::UnknownCredential),
794                }
795            }
796            DTGCredentialType::Authority => {
797                // A VAC's subject must actually carry the grant. `Basic` — a bare `{ id }` —
798                // is the shape a caller lands on when the `authority` member is missing
799                // entirely, and a credential that confers nothing is malformed rather than
800                // merely empty. There is no normalization to do here (unlike VMC/VWC, whose
801                // shapes collide): `authority` is unique to this subject.
802                match &value.credential_subject {
803                    CredentialSubject::Authority(subject) => {
804                        if subject.authority.actions.is_empty() {
805                            // Emptiness is never a wildcard. Refusing here means a caller
806                            // cannot construct one by deserialization either.
807                            return Err(DTGCredentialError::EmptyAuthorityActions);
808                        }
809                        Ok(DTGCredential {
810                            type_: DTGCredentialType::Authority,
811                            version: value.context.as_slice().try_into()?,
812                            credential: value,
813                        })
814                    }
815                    _ => Err(DTGCredentialError::UnknownCredential),
816                }
817            }
818            DTGCredentialType::Delegation => Ok(DTGCredential {
819                type_: DTGCredentialType::Delegation,
820                version: value.context.as_slice().try_into()?,
821                credential: value,
822            }),
823            DTGCredentialType::RCard => match &value.credential_subject {
824                CredentialSubject::RCard { .. } => Ok(DTGCredential {
825                    type_: DTGCredentialType::RCard,
826                    version: value.context.as_slice().try_into()?,
827                    credential: value,
828                }),
829                _ => Err(DTGCredentialError::UnknownCredential),
830            },
831        }
832    }
833}
834
835/// This correctly formats timestamps into the correct iso8601 specification for W3C Verifiable
836/// Credentials
837fn iso8601_format<S>(timestamp: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
838where
839    S: Serializer,
840{
841    s.serialize_str(
842        timestamp
843            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
844            .as_str(),
845    )
846}
847
848fn iso8601_format_option<S>(timestamp: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
849where
850    S: Serializer,
851{
852    if let Some(timestamp) = timestamp {
853        s.serialize_str(
854            timestamp
855                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
856                .as_str(),
857        )
858    } else {
859        s.serialize_none()
860    }
861}
862
863// ****************************************************************************
864// Credential Subject types
865// ****************************************************************************
866// NOTE: The DTG credential spec overloads the JSON attributes for different credential payloads.
867// The following enum will map the credential subject schema to correct Struct type
868
869/// This represents all possible credential subjects
870/// The order of the enum is important as it will match on first match
871#[allow(deprecated)]
872#[derive(Serialize, Deserialize, Debug, Clone)]
873#[serde(untagged)]
874pub enum CredentialSubject {
875    /// Verifiable Endorsement Credential subject
876    Endorsement(CredentialSubjectEndorsement),
877
878    /// R-Card Credential subject
879    #[deprecated(
880        since = "0.2.0",
881        note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
882                See DTGCredentialType::RCard. This variant will be removed in a future release."
883    )]
884    RCard(CredentialSubjectRCard),
885
886    /// Credential Subject of just `id`
887    /// Used by a community-issued VMC, and by VRC, VIC and VPC
888    Basic(CredentialSubjectBasic),
889
890    /// Verifiable Witness Credential subject
891    Witness(CredentialSubjectWitness),
892
893    /// Verifiable Authority Credential subject.
894    ///
895    /// Unambiguous under the untagged match: no other DTG subject carries an `authority`
896    /// member, and `deny_unknown_fields` keeps a subject that does not have one from
897    /// landing here.
898    Authority(CredentialSubjectAuthority),
899
900    /// Membership Credential subject, carrying the OPTIONAL `digest` that a member-issued
901    /// VMC MUST set.
902    ///
903    /// # Never selected by the untagged match, deliberately
904    ///
905    /// This variant sits last because its two shapes are already claimed above: `{ id }` is
906    /// [CredentialSubject::Basic], and `{ id, digest }` is indistinguishable from a VWC
907    /// subject with no `witnessContext`, which [CredentialSubject::Witness] takes first.
908    /// Nothing in the subject object itself separates a membership acknowledgement from a
909    /// witness attestation — only the credential's `type` does.
910    ///
911    /// So the shape is not decided here. `TryFrom<DTGCommon> for DTGCredential` normalizes
912    /// whichever variant the untagged match landed on into this one when `type` includes
913    /// `MembershipCredential`, the same way it already re-wraps a `Basic` subject as
914    /// `Witness` on a VWC. Deserialization is therefore deterministic rather than
915    /// order-dependent, and a `Membership` subject reaching a matcher has been through that
916    /// normalization.
917    Membership(CredentialSubjectMembership),
918}
919
920/// id of the credential subject only
921#[derive(Serialize, Deserialize, Debug, Clone)]
922#[serde(deny_unknown_fields)]
923pub struct CredentialSubjectBasic {
924    pub id: String,
925}
926
927/// The `authority` object a [CredentialSubject::Authority] carries.
928///
929/// # Attenuation
930///
931/// A holder may derive a narrower VAC from one they hold without involving the issuer. An
932/// attenuated VAC sets [AuthorityGrant::parent] to the `id` of the credential it derives
933/// from, and MUST NOT widen `actions`, `scope`, or the validity window. Verification walks
934/// the chain to a VAC issued by the party governing the scope — see
935/// [crate::authority::verify_chain], which is where the security of this credential
936/// actually lives. Issuing one is a struct and a signature; refusing a widening link is the
937/// part that matters.
938#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
939#[serde(rename_all = "camelCase", deny_unknown_fields)]
940pub struct AuthorityGrant {
941    /// The DID or URI the authority applies to.
942    ///
943    /// Matched exactly. A verifier rejects a VAC whose `scope` is not the resource being
944    /// accessed; nothing here implies containment between scopes.
945    pub scope: String,
946
947    /// The permitted actions, from a vocabulary the governing party defines.
948    ///
949    /// MUST NOT be empty. An empty list confers nothing — emptiness is never a wildcard,
950    /// which is the failure mode this rule exists to prevent. Action strings are compared
951    /// exactly and case-sensitively, and no action implies another: `admin` does not grant
952    /// `write` unless both are listed.
953    pub actions: Vec<String>,
954
955    /// The `id` of the VAC this one was attenuated from.
956    ///
957    /// Absent means this VAC was issued directly by the party governing the scope, and is
958    /// therefore a chain root.
959    #[serde(skip_serializing_if = "Option::is_none")]
960    pub parent: Option<String>,
961
962    /// A DID that MUST be the presenter for this VAC to be accepted.
963    ///
964    /// Absent means any holder may present it. Setting it is what makes a leaked agent
965    /// credential useless to anyone but that agent.
966    #[serde(skip_serializing_if = "Option::is_none")]
967    pub audience: Option<String>,
968}
969
970/// Verifiable Authority Credential (VAC) subject.
971#[derive(Serialize, Deserialize, Debug, Clone)]
972#[serde(rename_all = "camelCase", deny_unknown_fields)]
973pub struct CredentialSubjectAuthority {
974    /// DID of the party receiving the authority.
975    pub id: String,
976
977    /// What the subject may do, and where.
978    pub authority: AuthorityGrant,
979}
980
981/// Membership Credential subject
982///
983/// The two directions of a membership edge share this shape and are told apart by
984/// `digest`: a community-issued VMC (the membership grant) MUST omit it, and a
985/// member-issued VMC (the membership acknowledgement) MUST carry it. Where both endpoints
986/// are C-DIDs, as in VTN membership, `digest` is the only discriminator — the issuer and
987/// subject rules cannot separate the directions.
988#[derive(Serialize, Deserialize, Debug, Clone)]
989#[serde(rename_all = "camelCase", deny_unknown_fields)]
990pub struct CredentialSubjectMembership {
991    pub id: String,
992
993    /// Digest of the community-issued VMC this acknowledges, as
994    /// [DTGCredential::digest] computes it.
995    ///
996    /// REQUIRED on the member-issued VMC, and MUST be omitted on the community-issued VMC.
997    /// `Option` rather than two structs because the same property distinguishes the two
998    /// directions: a type that could not represent both could not deserialize the pair.
999    #[serde(skip_serializing_if = "Option::is_none", default)]
1000    pub digest: Option<String>,
1001}
1002
1003/// Endorsement Credential subject
1004#[derive(Serialize, Deserialize, Debug, Clone)]
1005#[serde(deny_unknown_fields)]
1006pub struct CredentialSubjectEndorsement {
1007    pub id: String,
1008    /// There is no spec for the endorsement content, so we use a generic JSON value
1009    pub endorsement: Value,
1010}
1011
1012/// Witness Credential subject
1013#[derive(Serialize, Deserialize, Debug, Clone)]
1014#[serde(rename_all = "camelCase", deny_unknown_fields)]
1015pub struct CredentialSubjectWitness {
1016    pub id: String,
1017
1018    #[serde(skip_serializing_if = "Option::is_none")]
1019    pub digest: Option<String>,
1020
1021    /// There is no spec for the witness context content, so we use a generic JSON value
1022    #[serde(skip_serializing_if = "Option::is_none")]
1023    pub witness_context: Option<WitnessContext>,
1024}
1025
1026/// Witness Credential Context
1027#[derive(Serialize, Deserialize, Debug, Clone)]
1028#[serde(rename_all = "camelCase", deny_unknown_fields)]
1029pub struct WitnessContext {
1030    /// Human-readable event name
1031    pub event: Option<String>,
1032
1033    /// Session or nonce identifier
1034    pub session_id: Option<String>,
1035
1036    ///Verification method used
1037    pub method: Option<String>,
1038}
1039
1040/// R-Card Credential subject
1041#[deprecated(
1042    since = "0.2.0",
1043    note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
1044            See DTGCredentialType::RCard. This struct will be removed in a future release."
1045)]
1046#[derive(Serialize, Deserialize, Debug, Clone)]
1047#[serde(deny_unknown_fields)]
1048pub struct CredentialSubjectRCard {
1049    pub id: String,
1050
1051    /// JCard spec, generic JSON value
1052    pub card: Value,
1053}
1054
1055#[cfg(test)]
1056#[allow(deprecated)]
1057mod tests {
1058    use crate::{
1059        CredentialSubject, CredentialSubjectRCard, DTGCommon, DTGCredential, DTGCredentialError,
1060        DTGCredentialType, W3CVCVersion, digest_json,
1061    };
1062    use chrono::{DateTime, Utc};
1063    use serde_json::Value;
1064
1065    #[test]
1066    fn test_vmc_vc_1_deserialize() {
1067        // tests deserialize a W3C VC Version 1.1 credential
1068        let vmc: DTGCredential = match serde_json::from_str(
1069            r#"{
1070"@context": [
1071    "https://www.w3.org/2018/credentials/v1",
1072    "https://firstperson.network/credentials/dtg/v1",
1073    "https://w3id.org/security/suites/ed25519-2020/v1"
1074  ],
1075  "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1076  "issuer": "did:web:chess-club.example",
1077  "issuanceDate": "2026-01-06T10:00:00Z",
1078  "expirationDate": "2027-01-06T10:00:00Z",
1079  "credentialSubject": {
1080    "id": "did:key:z6MkpTHR8VNs..."
1081  }
1082            }"#,
1083        ) {
1084            Ok(vmc) => vmc,
1085            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1086        };
1087
1088        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1089        assert!(matches!(
1090            vmc.credential().credential_subject,
1091            CredentialSubject::Membership(_)
1092        ));
1093        assert!(matches!(vmc.version, W3CVCVersion::V1_1));
1094        assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V1_1));
1095    }
1096
1097    #[test]
1098    fn test_missing_w3c_context() {
1099        // tests deserialize a W3C VC Version 1.1 credential
1100        assert!(
1101            serde_json::from_str::<DTGCredential>(
1102                r#"{
1103"@context": [
1104    "https://firstperson.network/credentials/dtg/v1",
1105    "https://w3id.org/security/suites/ed25519-2020/v1"
1106  ],
1107  "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1108  "issuer": "did:web:chess-club.example",
1109  "issuanceDate": "2026-01-06T10:00:00Z",
1110  "expirationDate": "2027-01-06T10:00:00Z",
1111  "credentialSubject": {
1112    "id": "did:key:z6MkpTHR8VNs..."
1113  }
1114            }"#,
1115            )
1116            .is_err()
1117        );
1118    }
1119
1120    #[test]
1121    fn test_mutable_credential() {
1122        let mut vmc = DTGCredential::new_vmc(
1123            "did:example:issuer".to_string(),
1124            "did:example:subject".to_string(),
1125            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1126                .unwrap()
1127                .with_timezone(&Utc),
1128            None,
1129            false,
1130        );
1131
1132        let cred = vmc.credential_mut();
1133        cred.type_.push("PersonhoodCredential".to_string());
1134        assert!(vmc.is_personhood_credential());
1135    }
1136
1137    #[test]
1138    fn test_vmc_deserialize() {
1139        let vmc: DTGCredential = match serde_json::from_str(
1140            r#"{
1141                "@context": ["https://www.w3.org/ns/credentials/v2"],
1142                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1143                "issuer": "did:example:community",
1144                "validFrom": "2024-06-18T10:00:00Z",
1145                "credentialSubject": { "id": "did:example:rDid" }
1146            }"#,
1147        ) {
1148            Ok(vmc) => vmc,
1149            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1150        };
1151
1152        assert!(!vmc.is_personhood_credential());
1153        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1154        assert!(matches!(
1155            vmc.credential().credential_subject,
1156            CredentialSubject::Membership(_)
1157        ));
1158        assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V2_0));
1159    }
1160
1161    #[test]
1162    fn test_vmc_phc_deserialize() {
1163        let vmc: DTGCredential = match serde_json::from_str(
1164            r#"{
1165                "@context": ["https://www.w3.org/ns/credentials/v2"],
1166                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential", "PersonhoodCredential"],
1167                "issuer": "did:example:community",
1168                "validFrom": "2024-06-18T10:00:00Z",
1169                "credentialSubject": { "id": "did:example:rDid" }
1170            }"#,
1171        ) {
1172            Ok(vmc) => vmc,
1173            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
1174        };
1175
1176        assert!(vmc.is_personhood_credential());
1177        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
1178        assert!(matches!(
1179            vmc.credential().credential_subject,
1180            CredentialSubject::Membership(_)
1181        ));
1182    }
1183
1184    #[test]
1185    fn test_vrc_deserialize() {
1186        let vrc: DTGCredential = match serde_json::from_str(
1187            r#"{
1188                "@context": ["https://www.w3.org/ns/credentials/v2"],
1189                "type": ["VerifiableCredential", "DTGCredential",  "RelationshipCredential"],
1190                "issuer": "did:example:governmentAgencyDid",
1191                "validFrom": "2024-06-18T10:00:00Z",
1192                "credentialSubject": { "id": "did:example:citizenRDid" }
1193            }"#,
1194        ) {
1195            Ok(vrc) => vrc,
1196            Err(e) => panic!("Couldn't deserialize VRC: {}", e),
1197        };
1198
1199        assert!(matches!(vrc.type_, DTGCredentialType::Relationship));
1200        assert!(matches!(
1201            vrc.credential().credential_subject,
1202            CredentialSubject::Basic(_)
1203        ));
1204    }
1205
1206    #[test]
1207    fn test_vic_deserialize() {
1208        let vic: DTGCredential = match serde_json::from_str(
1209            r#"{
1210                "@context": ["https://www.w3.org/ns/credentials/v2"],
1211                "type": ["VerifiableCredential", "DTGCredential",  "InvitationCredential"],
1212                "issuer": "did:example:governmentAgencyVicDid",
1213                "validFrom": "2024-06-18T10:00:00Z",
1214                "credentialSubject": { "id": "did:example:citizenRDid" }
1215            }"#,
1216        ) {
1217            Ok(vic) => vic,
1218            Err(e) => panic!("Couldn't deserialize VIC: {}", e),
1219        };
1220
1221        assert!(!vic.is_personhood_credential());
1222        assert!(matches!(vic.type_, DTGCredentialType::Invitation));
1223        assert!(matches!(
1224            vic.credential().credential_subject,
1225            CredentialSubject::Basic(_)
1226        ));
1227    }
1228
1229    #[test]
1230    fn test_vpc_deserialize() {
1231        let vpc: DTGCredential = match serde_json::from_str(
1232            r#"{
1233                "@context": ["https://www.w3.org/ns/credentials/v2"],
1234                "type": ["VerifiableCredential", "DTGCredential",  "PersonaCredential"],
1235                "issuer": "did:example:governmentAgencyDid",
1236                "validFrom": "2024-06-18T10:00:00Z",
1237                "credentialSubject": { "id": "did:example:citizenRDid" }
1238            }"#,
1239        ) {
1240            Ok(vpc) => vpc,
1241            Err(e) => panic!("Couldn't deserialize VPC: {}", e),
1242        };
1243
1244        assert!(matches!(vpc.type_, DTGCredentialType::Persona));
1245        assert!(matches!(
1246            vpc.credential().credential_subject,
1247            CredentialSubject::Basic(_)
1248        ));
1249    }
1250
1251    #[test]
1252    fn test_vec_deserialize() {
1253        let vec: DTGCredential = match serde_json::from_str(
1254            r#"{
1255                "@context": ["https://www.w3.org/ns/credentials/v2"],
1256                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
1257                "issuer": "did:example:governmentAgencyDid",
1258                "validFrom": "2024-06-18T10:00:00Z",
1259                "credentialSubject": { "id": "did:example:citizenRDid", "endorsement": {} }
1260            }"#,
1261        ) {
1262            Ok(vec) => vec,
1263            Err(e) => panic!("Couldn't deserialize VEC: {}", e),
1264        };
1265
1266        assert!(matches!(vec.type_, DTGCredentialType::Endorsement));
1267        assert!(matches!(vec.subject(), "did:example:citizenRDid"));
1268        assert!(matches!(
1269            vec.credential().credential_subject,
1270            CredentialSubject::Endorsement(_)
1271        ));
1272    }
1273
1274    #[test]
1275    fn test_vec_bad_deserialize() {
1276        match serde_json::from_str::<DTGCredential>(
1277            r#"{
1278                "@context": ["https://www.w3.org/ns/credentials/v2"],
1279                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
1280                "issuer": "did:example:governmentAgencyDid",
1281                "validFrom": "2024-06-18T10:00:00Z",
1282                "credentialSubject": { "id": "did:example:citizenRDid", "other": [] }
1283            }"#,
1284        ) {
1285            Ok(_) => panic!("Expected Unknown Credential type"),
1286            Err(_) => {
1287                // Good
1288            }
1289        };
1290    }
1291
1292    #[test]
1293    fn test_vwc_simple_deserialize() {
1294        let vwc: DTGCredential = match serde_json::from_str(
1295            r#"{
1296                "@context": ["https://www.w3.org/ns/credentials/v2"],
1297                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1298                "issuer": "did:example:governmentAgencyDid",
1299                "validFrom": "2024-06-18T10:00:00Z",
1300                "taskContext": "thread-abc-123",
1301                "credentialSubject": { "id": "did:example:citizenRDid" }
1302            }"#,
1303        ) {
1304            Ok(vwc) => vwc,
1305            Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1306        };
1307
1308        assert!(matches!(vwc.type_, DTGCredentialType::Witness));
1309        assert!(matches!(vwc.subject(), "did:example:citizenRDid"));
1310        assert_eq!(vwc.task_context(), Some("thread-abc-123"));
1311        assert!(matches!(
1312            vwc.credential().credential_subject,
1313            CredentialSubject::Witness(_)
1314        ));
1315    }
1316
1317    #[test]
1318    fn test_vwc_full_deserialize() {
1319        let vwc: DTGCredential = match serde_json::from_str(
1320            r#"{
1321                "@context": ["https://www.w3.org/ns/credentials/v2"],
1322                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1323                "issuer": "did:example:governmentAgencyDid",
1324                "validFrom": "2024-06-18T10:00:00Z",
1325                "taskContext": "thread-abc-123",
1326                "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "witnessContext": {} }
1327            }"#,
1328        ) {
1329            Ok(vwc) => vwc,
1330            Err(e) => panic!("Couldn't deserialize VWC: {}", e),
1331        };
1332
1333        assert!(matches!(vwc.type_(), DTGCredentialType::Witness));
1334        assert!(matches!(
1335            vwc.credential().credential_subject,
1336            CredentialSubject::Witness(_)
1337        ));
1338    }
1339
1340    #[test]
1341    fn test_vwc_bad_deserialize() {
1342        if serde_json::from_str::<DTGCredential>(
1343            r#"{
1344                "@context": ["https://www.w3.org/ns/credentials/v2"],
1345                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1346                "issuer": "did:example:governmentAgencyDid",
1347                "validFrom": "2024-06-18T10:00:00Z",
1348                "taskContext": "thread-abc-123",
1349                "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "wrongContext": {}  }
1350            }"#,
1351        ).is_ok() {
1352            panic!("Should have failed due to wrong CredentialSubject!");
1353        }
1354    }
1355
1356    #[test]
1357    fn test_rcard_simple_deserialize() {
1358        let rcard: DTGCredential = match serde_json::from_str(
1359            r#"{
1360                "@context": ["https://www.w3.org/ns/credentials/v2"],
1361                "type": ["VerifiableCredential", "DTGCredential",  "RCardCredential"],
1362                "issuer": "did:example:governmentAgencyDid",
1363                "validFrom": "2024-06-18T10:00:00Z",
1364                "credentialSubject": { "id": "did:example:citizenRDid", "card": [] }
1365            }"#,
1366        ) {
1367            Ok(rcard) => rcard,
1368            Err(e) => panic!("Couldn't deserialize R-Card: {}", e),
1369        };
1370
1371        assert!(matches!(rcard.type_(), DTGCredentialType::RCard));
1372        assert!(matches!(rcard.subject(), "did:example:citizenRDid"));
1373        assert!(matches!(
1374            rcard.credential().credential_subject,
1375            CredentialSubject::RCard(_)
1376        ));
1377    }
1378
1379    #[test]
1380    fn test_rcard_bad_deserialize() {
1381        if serde_json::from_str::<DTGCredential>(
1382            r#"{
1383                "@context": ["https://www.w3.org/ns/credentials/v2"],
1384                "type": ["VerifiableCredential", "DTGCredential",  "RCardCredential"],
1385                "issuer": "did:example:governmentAgencyDid",
1386                "validFrom": "2024-06-18T10:00:00Z",
1387                "credentialSubject": { "id": "did:example:citizenRDid"  }
1388            }"#,
1389        )
1390        .is_ok()
1391        {
1392            panic!("Should have failed due to wrong CredentialSubject!");
1393        }
1394    }
1395    #[test]
1396    fn test_deserialize_unknown() {
1397        match serde_json::from_str::<DTGCredential>(
1398            r#"{
1399                "@context": ["https://www.w3.org/ns/credentials/v2"],
1400                "type": ["VerifiableCredential", "DTGCredential",  "UnknownCredential"],
1401                "issuer": "did:example:governmentAgencyDid",
1402                "validFrom": "2024-06-18T10:00:00Z",
1403                "credentialSubject": { "id": "did:example:citizenRDid" }
1404            }"#,
1405        ) {
1406            Ok(_) => panic!("Expected Unknown Credential type"),
1407            Err(e) => {
1408                if e.to_string() == "Unknown credential type" {
1409                    // test passed
1410                } else {
1411                    panic!("Wrong error type returned");
1412                }
1413            }
1414        };
1415    }
1416
1417    #[test]
1418    fn test_deserialize_mismatched_credential_subject() {
1419        match serde_json::from_str::<DTGCredential>(
1420            r#"{
1421                "@context": ["https://www.w3.org/ns/credentials/v2"],
1422                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
1423                "issuer": "did:example:governmentAgencyDid",
1424                "validFrom": "2024-06-18T10:00:00Z",
1425                "credentialSubject": { "id": "did:example:citizenRDid" }
1426            }"#,
1427        ) {
1428            Ok(_) => panic!("Expected Unknown Credential type"),
1429            Err(e) => {
1430                if e.to_string() == "Unknown credential type" {
1431                    // test passed
1432                } else {
1433                    panic!("Wrong error type returned");
1434                }
1435            }
1436        };
1437    }
1438
1439    #[test]
1440    fn test_proof_signed() {
1441        let cred: DTGCredential = match serde_json::from_str(
1442            r#"{
1443                "@context": ["https://www.w3.org/ns/credentials/v2"],
1444                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1445                "issuer": "did:example:community",
1446                "validFrom": "2024-06-18T10:00:00Z",
1447                "credentialSubject": { "id": "did:example:rDid" },
1448                "proof": {
1449                    "type": "DataIntegrityProof",
1450                    "cryptosuite": "eddsa-jcs-2022",
1451                    "created": "2025-12-04T00:00:00",
1452                    "verificationMethod": "did:example:test#key-1",
1453                    "proofPurpose": "assertionMethod",
1454                    "proofValue": "abcd"
1455                }
1456            }"#,
1457        ) {
1458            Ok(vmc) => vmc,
1459            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1460        };
1461
1462        assert!(cred.signed());
1463        assert!(cred.proof_value().is_some());
1464    }
1465
1466    #[test]
1467    fn test_proof_not_signed() {
1468        let cred: DTGCredential = match serde_json::from_str(
1469            r#"{
1470                "@context": ["https://www.w3.org/ns/credentials/v2"],
1471                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1472                "issuer": "did:example:community",
1473                "validFrom": "2024-06-18T10:00:00Z",
1474                "credentialSubject": { "id": "did:example:rDid" }
1475            }"#,
1476        ) {
1477            Ok(vmc) => vmc,
1478            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1479        };
1480
1481        assert!(!cred.signed());
1482        assert!(cred.proof_value().is_none());
1483    }
1484
1485    #[test]
1486    fn test_helpers() {
1487        let cred: DTGCredential = match serde_json::from_str(
1488            r#"{
1489                "@context": ["https://www.w3.org/ns/credentials/v2"],
1490                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1491                "issuer": "did:example:issuer",
1492                "validFrom": "2024-06-18T00:00:00Z",
1493                "credentialSubject": { "id": "did:example:subject" }
1494            }"#,
1495        ) {
1496            Ok(vmc) => vmc,
1497            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1498        };
1499
1500        assert_eq!(cred.issuer(), "did:example:issuer");
1501        assert_eq!(cred.subject(), "did:example:subject");
1502        assert_eq!(
1503            cred.valid_from()
1504                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1505            "2024-06-18T00:00:00Z"
1506        );
1507        assert_eq!(cred.valid_until(), None);
1508    }
1509
1510    #[test]
1511    fn test_valid_until() {
1512        let cred: DTGCredential = match serde_json::from_str(
1513            r#"{
1514                "@context": ["https://www.w3.org/ns/credentials/v2"],
1515                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1516                "issuer": "did:example:issuer",
1517                "validFrom": "2024-06-18T00:00:00Z",
1518                "validUntil": "2030-01-01T00:00:00Z",
1519                "credentialSubject": { "id": "did:example:subject" }
1520            }"#,
1521        ) {
1522            Ok(vmc) => vmc,
1523            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1524        };
1525
1526        assert_eq!(
1527            cred.valid_until()
1528                .unwrap()
1529                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1530            "2030-01-01T00:00:00Z"
1531        );
1532    }
1533
1534    #[test]
1535    fn test_bad_type() {
1536        assert!(
1537            std::convert::TryInto::<DTGCredentialType>::try_into(
1538                vec!["bad_type".to_string()].as_slice(),
1539            )
1540            .is_err()
1541        );
1542    }
1543
1544    #[test]
1545    fn test_badly_constructed_vwc() {
1546        let mut cred = DTGCommon::default();
1547        cred.type_.push("WitnessCredential".to_string());
1548        // taskContext is set so this exercises the credentialSubject mismatch, not the
1549        // missing-taskContext path covered by test_vwc_missing_task_context()
1550        cred.task_context = Some("thread-abc-123".to_string());
1551        cred.credential_subject = CredentialSubject::RCard(CredentialSubjectRCard {
1552            id: "did:example:bad".to_string(),
1553            card: Value::Null,
1554        });
1555
1556        assert!(std::convert::TryInto::<DTGCredential>::try_into(cred).is_err());
1557    }
1558
1559    #[test]
1560    fn test_vwc_missing_task_context() {
1561        // taskContext is REQUIRED on a VWC
1562        match serde_json::from_str::<DTGCredential>(
1563            r#"{
1564                "@context": ["https://www.w3.org/ns/credentials/v2"],
1565                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1566                "issuer": "did:example:witness",
1567                "validFrom": "2024-06-18T10:00:00Z",
1568                "credentialSubject": { "id": "did:example:observed" }
1569            }"#,
1570        ) {
1571            Ok(_) => panic!("Expected a VWC without taskContext to be rejected"),
1572            Err(e) => assert_eq!(
1573                e.to_string(),
1574                "WitnessCredential is missing the required taskContext property"
1575            ),
1576        }
1577    }
1578
1579    #[test]
1580    fn test_task_context_round_trip() {
1581        // taskContext must survive deserialize -> serialize, otherwise a credential signed
1582        // elsewhere would fail verification here (and vice versa)
1583        let raw = r#"{
1584                "@context": ["https://www.w3.org/ns/credentials/v2"],
1585                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1586                "issuer": "did:example:witness",
1587                "validFrom": "2024-06-18T10:00:00Z",
1588                "taskContext": "thread-abc-123",
1589                "credentialSubject": { "id": "did:example:observed" }
1590            }"#;
1591
1592        let cred: DTGCredential = serde_json::from_str(raw).unwrap();
1593        let out = serde_json::to_string(&cred).unwrap();
1594
1595        assert!(out.contains(r#""taskContext":"thread-abc-123""#));
1596    }
1597
1598    #[test]
1599    fn test_task_context_optional_on_other_types() {
1600        // taskContext is OPTIONAL everywhere except the VWC
1601        let vrc: DTGCredential = serde_json::from_str(
1602            r#"{
1603                "@context": ["https://www.w3.org/ns/credentials/v2"],
1604                "type": ["VerifiableCredential", "DTGCredential",  "RelationshipCredential"],
1605                "issuer": "did:example:issuer",
1606                "validFrom": "2024-06-18T10:00:00Z",
1607                "credentialSubject": { "id": "did:example:subject" }
1608            }"#,
1609        )
1610        .unwrap();
1611
1612        assert_eq!(vrc.task_context(), None);
1613        // and it is omitted from the serialization entirely when absent
1614        assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
1615    }
1616
1617    #[test]
1618    fn test_digest_multibase() {
1619        let vrc = DTGCredential::new_vrc(
1620            "did:example:issuer".to_string(),
1621            "did:example:subject".to_string(),
1622            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1623                .unwrap()
1624                .with_timezone(&Utc),
1625            None,
1626        );
1627
1628        let digest = vrc.digest_multibase().unwrap();
1629
1630        // base58btc multibase prefix
1631        assert!(digest.starts_with('z'));
1632
1633        // decodes to a sha2-256 multihash: 0x12 0x20 followed by 32 digest bytes
1634        let (base, bytes) = multibase::decode(&digest).unwrap();
1635        assert_eq!(base, multibase::Base::Base58Btc);
1636        assert_eq!(bytes.len(), 34);
1637        assert_eq!(&bytes[..2], &[0x12, 0x20]);
1638
1639        // stable across calls
1640        assert_eq!(digest, vrc.digest_multibase().unwrap());
1641
1642        // and distinct for a different credential
1643        let other = DTGCredential::new_vrc(
1644            "did:example:issuer".to_string(),
1645            "did:example:someone-else".to_string(),
1646            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1647                .unwrap()
1648                .with_timezone(&Utc),
1649            None,
1650        );
1651        assert_ne!(digest, other.digest_multibase().unwrap());
1652    }
1653
1654    #[test]
1655    fn test_verify_digest() {
1656        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1657            .unwrap()
1658            .with_timezone(&Utc);
1659
1660        let vrc = DTGCredential::new_vrc(
1661            "did:example:issuer".to_string(),
1662            "did:example:subject".to_string(),
1663            valid_from,
1664            None,
1665        );
1666
1667        let vwc = DTGCredential::new_vwc(
1668            "did:example:witness".to_string(),
1669            // the DID of the issuer of the VRC being attested
1670            "did:example:issuer".to_string(),
1671            valid_from,
1672            None,
1673            "thread-abc-123".to_string(),
1674            Some(vrc.digest().unwrap()),
1675            None,
1676        );
1677
1678        assert!(vwc.verify_digest(&vrc).unwrap());
1679
1680        // a different VRC must not match
1681        let other = DTGCredential::new_vrc(
1682            "did:example:issuer".to_string(),
1683            "did:example:someone-else".to_string(),
1684            valid_from,
1685            None,
1686        );
1687        assert!(!vwc.verify_digest(&other).unwrap());
1688    }
1689
1690    #[test]
1691    fn test_verify_digest_without_digest() {
1692        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1693            .unwrap()
1694            .with_timezone(&Utc);
1695
1696        let vrc = DTGCredential::new_vrc(
1697            "did:example:issuer".to_string(),
1698            "did:example:subject".to_string(),
1699            valid_from,
1700            None,
1701        );
1702
1703        // digest is OPTIONAL - with none present there is nothing to rely on
1704        let vwc = DTGCredential::new_vwc(
1705            "did:example:witness".to_string(),
1706            "did:example:issuer".to_string(),
1707            valid_from,
1708            None,
1709            "thread-abc-123".to_string(),
1710            None,
1711            None,
1712        );
1713
1714        assert!(!vwc.verify_digest(&vrc).unwrap());
1715    }
1716
1717    /// The digest encoding is the interoperability surface: a credential referencing another
1718    /// is compared byte-for-byte against a string some other implementation produced. Pinned
1719    /// against a literal rather than a recomputation, because a test that recomputes agrees
1720    /// with whatever the code does and would follow the encoding silently if it drifted.
1721    #[test]
1722    fn test_digest_is_sha256_hex_over_the_proofless_jcs_form() {
1723        let vmc = DTGCredential::new_vmc(
1724            "did:example:community".to_string(),
1725            "did:example:member".to_string(),
1726            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1727                .unwrap()
1728                .with_timezone(&Utc),
1729            None,
1730            false,
1731        )
1732        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
1733
1734        let digest = vmc.digest().unwrap();
1735
1736        let (scheme, hex) = digest.split_once(':').expect("`sha256:` prefixed");
1737        assert_eq!(scheme, "sha256");
1738        assert_eq!(hex.len(), 64, "32 bytes, hex encoded");
1739        assert!(
1740            hex.chars()
1741                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
1742            "lowercase hex only, got {hex}"
1743        );
1744
1745        // Independently computed over the JCS canonical form of the credential above.
1746        // Computed outside this crate over the JCS canonical form of the document above:
1747        //   {"@context":[...],"credentialSubject":{"id":"did:example:member"},
1748        //    "id":"urn:uuid:2a4e...","issuer":"did:example:community",
1749        //    "type":[...],"validFrom":"2025-12-11T00:00:00Z"}
1750        assert_eq!(
1751            digest,
1752            "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2"
1753        );
1754
1755        // Stable across calls.
1756        assert_eq!(digest, vmc.digest().unwrap());
1757    }
1758
1759    /// The digest binds to what a credential says, not to a signature over it, so a
1760    /// re-proofed credential still satisfies a reference made against the earlier one. This
1761    /// is what lets a member's acknowledgement survive the community re-signing its grant.
1762    #[cfg(feature = "affinidi-signing")]
1763    #[tokio::test]
1764    async fn test_digest_is_unchanged_by_signing() {
1765        use affinidi_secrets_resolver::secrets::Secret;
1766
1767        let secret = Secret::generate_ed25519(None, None);
1768
1769        let mut vmc = DTGCredential::new_vmc(
1770            "did:example:community".to_string(),
1771            "did:example:member".to_string(),
1772            Utc::now(),
1773            None,
1774            false,
1775        );
1776
1777        let before = vmc.digest().unwrap();
1778        vmc.sign(&secret, None).await.expect("signs");
1779        assert!(vmc.signed());
1780        assert_eq!(before, vmc.digest().unwrap());
1781    }
1782
1783    /// A grant in the wire form a member actually receives.
1784    fn wire(c: &DTGCredential) -> Value {
1785        serde_json::to_value(c.credential()).expect("credential serialises")
1786    }
1787
1788    /// The whole point of the pair: a grant and the acknowledgement built from it form a
1789    /// complete membership edge, and the parties are mirrored across the two halves.
1790    #[test]
1791    fn test_member_vmc_acknowledges_its_grant() {
1792        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1793            .unwrap()
1794            .with_timezone(&Utc);
1795
1796        let grant = DTGCredential::new_vmc(
1797            "did:example:community".to_string(),
1798            "did:example:member".to_string(),
1799            valid_from,
1800            None,
1801            false,
1802        );
1803
1804        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1805
1806        // Roles reversed.
1807        assert_eq!(ack.issuer(), "did:example:member");
1808        assert_eq!(ack.subject(), "did:example:community");
1809
1810        // The grant MUST omit the digest; the acknowledgement MUST carry it.
1811        assert_eq!(grant.subject_digest(), None);
1812        assert_eq!(ack.subject_digest(), Some(grant.digest().unwrap().as_str()));
1813
1814        assert!(ack.acknowledges(&grant).unwrap());
1815    }
1816
1817    /// An acknowledgement completes the edge it names and no other. Each case below verifies
1818    /// as a credential in its own right; what fails is the binding.
1819    #[test]
1820    fn test_acknowledges_rejects_a_mismatched_pair() {
1821        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1822            .unwrap()
1823            .with_timezone(&Utc);
1824
1825        let grant = DTGCredential::new_vmc(
1826            "did:example:community".to_string(),
1827            "did:example:member".to_string(),
1828            valid_from,
1829            None,
1830            false,
1831        );
1832        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1833
1834        // A grant to a different member: right community, wrong edge.
1835        let other_member = DTGCredential::new_vmc(
1836            "did:example:community".to_string(),
1837            "did:example:someone-else".to_string(),
1838            valid_from,
1839            None,
1840            false,
1841        );
1842        assert!(!ack.acknowledges(&other_member).unwrap());
1843
1844        // A grant from a different community.
1845        let other_community = DTGCredential::new_vmc(
1846            "did:example:other-community".to_string(),
1847            "did:example:member".to_string(),
1848            valid_from,
1849            None,
1850            false,
1851        );
1852        assert!(!ack.acknowledges(&other_community).unwrap());
1853
1854        // A re-issued grant to the same member — different claims, so a different digest.
1855        // This is what forces re-acknowledgement on renewal rather than letting a stale
1856        // consent carry over to a membership the member never agreed to.
1857        let renewed = DTGCredential::new_vmc(
1858            "did:example:community".to_string(),
1859            "did:example:member".to_string(),
1860            valid_from + chrono::Duration::days(365),
1861            None,
1862            false,
1863        );
1864        assert!(!ack.acknowledges(&renewed).unwrap());
1865
1866        // The acknowledgement is not itself a grant: acknowledging one forms no edge.
1867        let ack_of_ack =
1868            DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1869        assert!(!ack_of_ack.acknowledges(&ack).unwrap());
1870
1871        // A grant on its own does not complete anything — it carries no digest to check.
1872        assert!(!grant.acknowledges(&grant).unwrap());
1873    }
1874
1875    /// The bug this API shape exists to prevent.
1876    ///
1877    /// A real grant carries `credentialStatus` — every VMC issued against a status list
1878    /// does — and `DTGCommon` does not model it, so a parse-then-re-serialise round trip
1879    /// drops it. An acknowledgement built by digesting the *parsed* grant would carry a
1880    /// digest over a document the community never issued, and the community would
1881    /// rightly refuse it. Silently: both credentials verify, and only the digest
1882    /// comparison fails, with nothing to say why.
1883    ///
1884    /// So `new_member_vmc` takes the wire form, and this pins that it digests what it
1885    /// was handed rather than what it could parse.
1886    #[test]
1887    fn the_acknowledgement_digests_members_the_model_does_not_know() {
1888        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1889            .unwrap()
1890            .with_timezone(&Utc);
1891
1892        let mut grant = wire(&DTGCredential::new_vmc(
1893            "did:example:community".to_string(),
1894            "did:example:member".to_string(),
1895            valid_from,
1896            None,
1897            false,
1898        ));
1899        grant["credentialStatus"] = serde_json::json!({
1900            "id": "https://community.example/status#7",
1901            "type": "BitstringStatusListEntry",
1902            "statusPurpose": "revocation",
1903            "statusListIndex": "7"
1904        });
1905
1906        // The parse drops it — this is the hazard, asserted rather than assumed.
1907        let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
1908        assert!(
1909            wire(&parsed).get("credentialStatus").is_none(),
1910            "the model is expected NOT to carry credentialStatus; if it now does, this \
1911             test has stopped guarding anything and the API can be simplified"
1912        );
1913
1914        let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds");
1915
1916        assert_eq!(
1917            ack.subject_digest(),
1918            Some(digest_json(&grant).unwrap().as_str()),
1919            "the acknowledgement must digest the grant as received"
1920        );
1921        assert_ne!(
1922            ack.subject_digest(),
1923            Some(parsed.digest().unwrap().as_str()),
1924            "digesting the parsed model would produce a digest the community cannot match"
1925        );
1926    }
1927
1928    /// `digest_json` and `digest` must agree for a credential with nothing outside the
1929    /// model — otherwise the two entry points would quietly disagree for the easy case
1930    /// too, and no caller could tell which to trust.
1931    #[test]
1932    fn digest_json_agrees_with_digest_where_the_model_is_complete() {
1933        let vmc = DTGCredential::new_vmc(
1934            "did:example:community".to_string(),
1935            "did:example:member".to_string(),
1936            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1937                .unwrap()
1938                .with_timezone(&Utc),
1939            None,
1940            false,
1941        )
1942        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
1943
1944        assert_eq!(vmc.digest().unwrap(), digest_json(&wire(&vmc)).unwrap());
1945    }
1946
1947    /// `acknowledges` answers only about VMC pairs. A VRC edge is completed by its own
1948    /// reciprocal, not by this.
1949    #[test]
1950    fn test_acknowledges_is_membership_only() {
1951        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1952            .unwrap()
1953            .with_timezone(&Utc);
1954
1955        let grant = DTGCredential::new_vmc(
1956            "did:example:community".to_string(),
1957            "did:example:member".to_string(),
1958            valid_from,
1959            None,
1960            false,
1961        );
1962        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
1963
1964        let vrc = DTGCredential::new_vrc(
1965            "did:example:member".to_string(),
1966            "did:example:community".to_string(),
1967            valid_from,
1968            None,
1969        );
1970        assert!(!ack.acknowledges(&vrc).unwrap());
1971
1972        // And a VWC bound to the grant is a witness attestation, not a member's consent.
1973        let vwc = DTGCredential::new_vwc(
1974            "did:example:witness".to_string(),
1975            "did:example:community".to_string(),
1976            valid_from,
1977            None,
1978            "thread-abc-123".to_string(),
1979            Some(grant.digest().unwrap()),
1980            None,
1981        );
1982        assert!(vwc.verify_digest(&grant).unwrap(), "the digest does match");
1983        assert!(
1984            !vwc.acknowledges(&grant).unwrap(),
1985            "but a VWC is not the member's acknowledgement"
1986        );
1987    }
1988
1989    /// A grant built against something that cannot be one is refused at construction, where
1990    /// the caller can still do something about it — rather than producing an acknowledgement
1991    /// that verifies as a credential and completes no edge.
1992    #[test]
1993    fn test_new_member_vmc_refuses_a_non_grant() {
1994        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1995            .unwrap()
1996            .with_timezone(&Utc);
1997
1998        let vrc = DTGCredential::new_vrc(
1999            "did:example:a".to_string(),
2000            "did:example:b".to_string(),
2001            valid_from,
2002            None,
2003        );
2004        assert!(matches!(
2005            DTGCredential::new_member_vmc(&wire(&vrc), valid_from, None),
2006            Err(DTGCredentialError::NotAMembershipGrant(_))
2007        ));
2008
2009        let grant = DTGCredential::new_vmc(
2010            "did:example:community".to_string(),
2011            "did:example:member".to_string(),
2012            valid_from,
2013            None,
2014            false,
2015        );
2016        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2017        assert!(matches!(
2018            DTGCredential::new_member_vmc(&wire(&ack), valid_from, None),
2019            Err(DTGCredentialError::NotAMembershipGrant(_))
2020        ));
2021    }
2022
2023    /// `{ id, digest }` is shape-identical to a VWC subject, and the untagged enum matches
2024    /// `Witness` first. On a MembershipCredential the credential's `type` is the only thing
2025    /// that says otherwise, so the normalization in `TryFrom<DTGCommon>` is what makes this
2026    /// deserialize as the member-issued half rather than as a witness attestation.
2027    #[test]
2028    fn test_member_issued_vmc_deserializes_as_membership_not_witness() {
2029        let vmc: DTGCredential = serde_json::from_str(
2030            r#"{
2031                "@context": ["https://www.w3.org/ns/credentials/v2"],
2032                "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2033                "issuer": "did:example:member",
2034                "validFrom": "2024-06-18T10:00:00Z",
2035                "credentialSubject": {
2036                    "id": "did:example:community",
2037                    "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
2038                }
2039            }"#,
2040        )
2041        .expect("deserializes");
2042
2043        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
2044        assert!(matches!(
2045            vmc.credential().credential_subject,
2046            CredentialSubject::Membership(_)
2047        ));
2048        assert_eq!(
2049            vmc.subject_digest(),
2050            Some("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
2051        );
2052        assert_eq!(vmc.subject(), "did:example:community");
2053    }
2054
2055    /// `witnessContext` belongs to a VWC. A VMC carrying one is malformed rather than
2056    /// merely surprising, and is refused instead of being silently read as a grant.
2057    #[test]
2058    fn test_membership_credential_rejects_a_witness_context() {
2059        let result: Result<DTGCredential, _> = serde_json::from_str(
2060            r#"{
2061                "@context": ["https://www.w3.org/ns/credentials/v2"],
2062                "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2063                "issuer": "did:example:member",
2064                "validFrom": "2024-06-18T10:00:00Z",
2065                "credentialSubject": {
2066                    "id": "did:example:community",
2067                    "digest": "sha256:e3b0c4",
2068                    "witnessContext": { "event": "not a membership property" }
2069                }
2070            }"#,
2071        );
2072        assert!(result.is_err());
2073    }
2074
2075    /// The two halves must be distinguishable on the wire by `digest` alone — that is the
2076    /// only discriminator where both endpoints are C-DIDs, as in VTN membership.
2077    #[test]
2078    fn test_the_two_halves_round_trip_over_the_wire() {
2079        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2080            .unwrap()
2081            .with_timezone(&Utc);
2082
2083        let grant = DTGCredential::new_vmc(
2084            "did:example:community".to_string(),
2085            "did:example:member".to_string(),
2086            valid_from,
2087            None,
2088            false,
2089        );
2090        let ack = DTGCredential::new_member_vmc(&wire(&grant), valid_from, None).expect("builds");
2091
2092        let grant_json = serde_json::to_value(&grant).unwrap();
2093        assert!(
2094            grant_json["credentialSubject"].get("digest").is_none(),
2095            "the grant MUST omit `digest`: {grant_json}"
2096        );
2097
2098        let ack_json = serde_json::to_value(&ack).unwrap();
2099        assert_eq!(
2100            ack_json["credentialSubject"]["digest"],
2101            Value::String(grant.digest().unwrap()),
2102        );
2103
2104        // And the pair still binds after a round trip through JSON, which is how each side
2105        // actually receives the other's half.
2106        let grant: DTGCredential = serde_json::from_value(grant_json).expect("grant round trips");
2107        let ack: DTGCredential = serde_json::from_value(ack_json).expect("ack round trips");
2108        assert!(ack.acknowledges(&grant).unwrap());
2109    }
2110
2111    #[test]
2112    fn test_iso8601_format_option() {
2113        let now: DateTime<Utc> = DateTime::parse_from_rfc3339(
2114            &Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2115        )
2116        .unwrap()
2117        .to_utc();
2118        let cred = DTGCommon {
2119            valid_until: Some(now),
2120            ..Default::default()
2121        };
2122
2123        let value = serde_json::to_value(&cred).unwrap();
2124        let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
2125        assert_eq!(cred2.valid_until, Some(now));
2126
2127        let cred = DTGCommon::default();
2128        let value = serde_json::to_value(&cred).unwrap();
2129        let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
2130        assert_eq!(cred2.valid_until, None);
2131    }
2132
2133    #[cfg(feature = "affinidi-signing")]
2134    #[tokio::test]
2135    async fn test_signing() {
2136        use affinidi_secrets_resolver::secrets::Secret;
2137
2138        let secret = Secret::generate_ed25519(None, None);
2139
2140        let mut cred = DTGCredential::new_vrc(
2141            "did:example:issuer".to_string(),
2142            "did:example:subject".to_string(),
2143            Utc::now(),
2144            None,
2145        );
2146
2147        assert!(cred.sign(&secret, Some(Utc::now())).await.is_ok());
2148
2149        assert!(
2150            cred.verify_proof_with_public_key(secret.get_public_bytes())
2151                .is_ok()
2152        );
2153
2154        let secret2 = Secret::generate_ed25519(None, None);
2155        assert!(
2156            cred.verify_proof_with_public_key(secret2.get_public_bytes())
2157                .is_err()
2158        );
2159    }
2160
2161    /// The proof covers `id`, so it must be set *before* signing.
2162    ///
2163    /// This is the property that makes [DTGCredential::with_id]'s "set it before signing"
2164    /// caveat load-bearing rather than advisory: a credential signed without an identifier
2165    /// cannot be given one afterwards to satisfy a verifier that requires it, because the
2166    /// document that was signed did not contain it. Tampering with `id` after the fact is
2167    /// the same operation, and must fail the same way.
2168    #[cfg(feature = "affinidi-signing")]
2169    #[tokio::test]
2170    async fn test_id_is_covered_by_the_proof() {
2171        use affinidi_secrets_resolver::secrets::Secret;
2172
2173        let secret = Secret::generate_ed25519(None, None);
2174
2175        let mut cred = DTGCredential::new_vrc(
2176            "did:example:issuer".to_string(),
2177            "did:example:subject".to_string(),
2178            Utc::now(),
2179            None,
2180        )
2181        .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
2182
2183        cred.sign(&secret, Some(Utc::now()))
2184            .await
2185            .expect("signing a credential that carries an id");
2186        assert!(
2187            cred.verify_proof_with_public_key(secret.get_public_bytes())
2188                .is_ok(),
2189            "an id set before signing verifies"
2190        );
2191
2192        // Changing the id after signing — which is what "splice an id into the JSON on the
2193        // way out" amounts to — invalidates the proof.
2194        cred.set_id("urn:uuid:00000000-0000-0000-0000-000000000000");
2195        assert!(
2196            cred.verify_proof_with_public_key(secret.get_public_bytes())
2197                .is_err(),
2198            "an id changed after signing must break the proof"
2199        );
2200    }
2201
2202    #[cfg(feature = "affinidi-signing")]
2203    #[tokio::test]
2204    async fn test_signing_error() {
2205        use affinidi_secrets_resolver::secrets::Secret;
2206
2207        let secret = Secret::generate_x25519(None, None).unwrap();
2208
2209        let mut cred = DTGCredential::new_vrc(
2210            "did:example:issuer".to_string(),
2211            "did:example:subject".to_string(),
2212            Utc::now(),
2213            None,
2214        );
2215
2216        assert!(cred.sign(&secret, Some(Utc::now())).await.is_err());
2217    }
2218
2219    #[cfg(feature = "affinidi-signing")]
2220    #[test]
2221    fn test_signing_no_proof() {
2222        use crate::DTGCredentialError;
2223        use affinidi_secrets_resolver::secrets::Secret;
2224
2225        let cred = DTGCredential::new_vrc(
2226            "did:example:issuer".to_string(),
2227            "did:example:subject".to_string(),
2228            Utc::now(),
2229            None,
2230        );
2231
2232        let secret = Secret::generate_ed25519(None, None);
2233        match cred.verify_proof_with_public_key(secret.get_public_bytes()) {
2234            Err(DTGCredentialError::NotSigned) => {
2235                // Good
2236            }
2237            _ => panic!("Expected NotSigned error!"),
2238        }
2239    }
2240}