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