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