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
69/// Defined DTG Credentials
70#[derive(Serialize, Deserialize, Debug, Clone)]
71#[serde(try_from = "DTGCommon")]
72pub struct DTGCredential {
73    /// The DTG Credential inner struct
74    #[serde(flatten)]
75    credential: DTGCommon,
76
77    /// Type of the credential
78    #[serde(skip)]
79    type_: DTGCredentialType,
80
81    /// W3C VC Version
82    #[serde(skip)]
83    version: W3CVCVersion,
84}
85
86impl DTGCredential {
87    /// get the raw credential
88    pub fn credential(&self) -> &DTGCommon {
89        &self.credential
90    }
91
92    /// Get the raw credential as mutable
93    pub fn credential_mut(&mut self) -> &mut DTGCommon {
94        &mut self.credential
95    }
96
97    /// Has this credential been signed?
98    pub fn signed(&self) -> bool {
99        self.credential.signed()
100    }
101
102    /// get the credential type
103    pub fn type_(&self) -> DTGCredentialType {
104        self.type_.clone()
105    }
106
107    /// This credential's own identifier, if it has one.
108    ///
109    /// `None` for a credential built by one of the `new_*` constructors and never given one
110    /// with [DTGCredential::with_id]. See [DTGCommon::id] for why a counterparty may require
111    /// it.
112    pub fn id(&self) -> Option<&str> {
113        self.credential.id()
114    }
115
116    /// Returns the Issuer DID
117    pub fn issuer(&self) -> &str {
118        self.credential.issuer()
119    }
120
121    /// Returns the Subject DID
122    pub fn subject(&self) -> &str {
123        self.credential.subject()
124    }
125
126    /// Returns the valid_from timestamp
127    pub fn valid_from(&self) -> DateTime<Utc> {
128        self.credential.valid_from()
129    }
130
131    /// Returns the valid until timestamp
132    pub fn valid_until(&self) -> Option<DateTime<Utc>> {
133        self.credential.valid_until()
134    }
135
136    /// The `threadId` of the trust task exchange this credential was issued in, if set
137    ///
138    /// This is always `Some` for [DTGCredentialType::Witness] credentials, where the spec
139    /// makes `taskContext` REQUIRED.
140    pub fn task_context(&self) -> Option<&str> {
141        self.credential.task_context()
142    }
143
144    /// Computes the digest of this credential, for use as the `digest` property of a
145    /// Witness Credential (VWC) attesting it.
146    ///
147    /// The digest is the SHA-256 hash of this credential canonicalized with the JSON
148    /// Canonicalization Scheme ([JCS, RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)),
149    /// wrapped as a multihash and encoded as a base58btc multibase string (`z...`), matching
150    /// the W3C `digestMultibase` convention.
151    ///
152    /// # ⚠️ Known spec divergence
153    ///
154    /// This encoding is **not** what DTG Core Credentials Working Draft 01 specifies. WD-01
155    /// requires `sha256:` followed by a lowercase hex digest; this returns a multibase
156    /// multihash. The underlying hash is identical — only the encoding differs — but the
157    /// literal `digest` strings will not match, so **VWCs built with this value will not
158    /// interoperate with spec-conformant implementations**, and [DTGCredential::verify_digest]
159    /// will reject conformant VWCs from elsewhere.
160    ///
161    /// This is unresolved and should be raised with the DTGWG. Do not rely on this function
162    /// if you must interoperate with conformant implementations today. See README.md.
163    ///
164    /// The digest covers the credential exactly as it stands, including its `proof` if it has
165    /// been signed. A witness should therefore digest the VRC in the form it was witnessed in.
166    pub fn digest_multibase(&self) -> Result<String, DTGCredentialError> {
167        let canonical = serde_json_canonicalizer::to_vec(&self.credential)
168            .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
169
170        // multihash prefix: 0x12 = sha2-256, 0x20 = 32 byte digest length
171        let mut multihash = Vec::with_capacity(34);
172        multihash.extend_from_slice(&[0x12, 0x20]);
173        multihash.extend_from_slice(&Sha256::digest(&canonical));
174
175        Ok(multibase::encode(Base::Base58Btc, &multihash))
176    }
177
178    /// Checks that this credential's `digest` matches the credential it claims to witness.
179    ///
180    /// Returns `Ok(false)` if the digests do not match, or if this credential carries no
181    /// `digest` (it is OPTIONAL), in which case there is nothing to rely on.
182    ///
183    /// # ⚠️ Known spec divergence
184    ///
185    /// This compares against [DTGCredential::digest_multibase], whose encoding differs from
186    /// DTG Core Credentials Working Draft 01. A conformant VWC carrying a `sha256:<hex>`
187    /// digest will be **rejected** by this function even when the underlying credential is
188    /// the one it genuinely witnesses. See [DTGCredential::digest_multibase] and README.md.
189    pub fn verify_digest(&self, witnessed: &DTGCredential) -> Result<bool, DTGCredentialError> {
190        let CredentialSubject::Witness(subject) = &self.credential.credential_subject else {
191            return Ok(false);
192        };
193
194        let Some(digest) = &subject.digest else {
195            return Ok(false);
196        };
197
198        Ok(*digest == witnessed.digest_multibase()?)
199    }
200
201    /// Returns the proof value if signed else None
202    pub fn proof_value(&self) -> Option<&str> {
203        if let Some(proof) = &self.credential.proof {
204            proof.proof_value.as_deref()
205        } else {
206            None
207        }
208    }
209
210    #[cfg(feature = "affinidi-signing")]
211    /// Sign the credential using W3C Data Integrity Proof with JCS EdDSA 2022
212    /// signing_secret: The secret key to use to sign the credential
213    /// create_time: Optional creation time for the proof, defaults to now if None
214    pub async fn sign(
215        &mut self,
216        signing_secret: &Secret,
217        create_time: Option<DateTime<Utc>>,
218    ) -> Result<DataIntegrityProof, DTGCredentialError> {
219        let mut options = SignOptions::new();
220        if let Some(ts) = create_time {
221            options = options.with_created(ts);
222        }
223
224        let proof = DataIntegrityProof::sign(self, signing_secret, options).await?;
225
226        self.credential.proof = Some(proof.clone());
227        Ok(proof)
228    }
229
230    #[cfg(feature = "affinidi-signing")]
231    /// Verify the credential if you already know the public key bytes
232    /// otherwise use the affinidi_tdk:verify_data() method
233    /// public_key_bytes: The public key bytes to use to verify the credential
234    pub fn verify_proof_with_public_key(
235        &self,
236        public_key_bytes: &[u8],
237    ) -> Result<(), DTGCredentialError> {
238        let proof = if let Some(proof) = &self.credential.proof {
239            proof.clone()
240        } else {
241            use tracing::warn;
242
243            warn!("Trying to verify a DTG Credential that has no proof");
244            return Err(DTGCredentialError::NotSigned);
245        };
246
247        let unsigned = DTGCommon {
248            proof: None,
249            ..self.credential.clone()
250        };
251
252        proof.verify_with_public_key(&unsigned, public_key_bytes, VerifyOptions::new())?;
253        Ok(())
254    }
255
256    /// Is this credential a W3C VC Version 1.1 or 2.0 credential?
257    pub fn get_w3c_vc_version(&self) -> W3CVCVersion {
258        self.version
259    }
260
261    /// returns true if this credential a personhood credential (PHC)
262    pub fn is_personhood_credential(&self) -> bool {
263        if let DTGCredentialType::Membership = self.type_ {
264            self.credential
265                .type_
266                .contains(&"PersonhoodCredential".to_string())
267        } else {
268            false
269        }
270    }
271}
272
273/// TDG VC Type Identifiers
274#[derive(Debug, Clone)]
275#[non_exhaustive]
276pub enum DTGCredentialType {
277    Membership,
278    Relationship,
279    Invitation,
280    Persona,
281    Endorsement,
282    Witness,
283
284    /// R-Card is no longer a DTG credential type.
285    #[deprecated(
286        since = "0.2.0",
287        note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
288                It was removed from the DTG Core Credentials specification in Working Draft 01 \
289                and will be defined by the planned DTG Verifiable Data Structures specification. \
290                This variant will be removed in a future release."
291    )]
292    RCard,
293}
294
295impl Display for DTGCredentialType {
296    #[allow(deprecated)]
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        match self {
299            DTGCredentialType::Membership => write!(f, "MembershipCredential"),
300            DTGCredentialType::Relationship => write!(f, "RelationshipCredential"),
301            DTGCredentialType::Invitation => write!(f, "InvitationCredential"),
302            DTGCredentialType::Persona => write!(f, "PersonaCredential"),
303            DTGCredentialType::Endorsement => write!(f, "EndorsementCredential"),
304            DTGCredentialType::Witness => write!(f, "WitnessCredential"),
305            DTGCredentialType::RCard => write!(f, "RCardCredential"),
306        }
307    }
308}
309
310/// This helps with matching the right credential type to the [DTGCredentialType]
311const DTG_TYPES: [&str; 7] = [
312    "MembershipCredential",
313    "RelationshipCredential",
314    "InvitationCredential",
315    "PersonaCredential",
316    "EndorsementCredential",
317    "WitnessCredential",
318    "RCardCredential",
319];
320
321impl TryFrom<&[String]> for DTGCredentialType {
322    type Error = DTGCredentialError;
323
324    #[allow(deprecated)]
325    fn try_from(types: &[String]) -> Result<Self, Self::Error> {
326        if let Some(type_) = DTG_TYPES.iter().find(|t| types.contains(&t.to_string())) {
327            match *type_ {
328                "MembershipCredential" => Ok(DTGCredentialType::Membership),
329                "RelationshipCredential" => Ok(DTGCredentialType::Relationship),
330                "InvitationCredential" => Ok(DTGCredentialType::Invitation),
331                "PersonaCredential" => Ok(DTGCredentialType::Persona),
332                "EndorsementCredential" => Ok(DTGCredentialType::Endorsement),
333                "WitnessCredential" => Ok(DTGCredentialType::Witness),
334                "RCardCredential" => Ok(DTGCredentialType::RCard),
335                _ => Err(DTGCredentialError::UnknownCredential),
336            }
337        } else {
338            Err(DTGCredentialError::UnknownCredential)
339        }
340    }
341}
342
343/// All DTG Credentials follow a common structure.
344#[derive(Serialize, Deserialize, Debug, Clone)]
345#[serde(rename_all = "camelCase")]
346pub struct DTGCommon {
347    /// JSON-LD links to contexts
348    /// Must contain at least:
349    /// - <https://www.w3.org/ns/credentials/v2>
350    /// - <https://firstperson.network/credentials/dtg/v1>
351    #[serde(rename = "@context")]
352    pub context: Vec<String>,
353
354    /// Credential type identifiers
355    /// Must contain at least:
356    /// DTGCredential
357    /// VerifiableCredential
358    #[serde(rename = "type")]
359    pub type_: Vec<String>,
360
361    /// OPTIONAL identifier for this specific credential, per the W3C VC Data Model.
362    ///
363    /// When present it MUST be a single URL. A `urn:uuid:` URN is the usual choice for a
364    /// credential with no dereferenceable home.
365    ///
366    /// This is the handle a holder or verifier stores the credential *under*, so it is what
367    /// makes re-delivery of the same credential idempotent and re-issuance of a different one
368    /// recognisable as a renewal rather than a duplicate. A counterparty that keys credentials
369    /// by `id` cannot accept one that has none — so issue with an `id` unless you know nobody
370    /// on the other side needs it.
371    ///
372    /// # Set it before signing
373    ///
374    /// A Data Integrity proof covers the credential minus its `proof`, which includes this
375    /// property. Set it while building — [DTGCredential::with_id] — never after
376    /// [DTGCredential::sign], which would leave a document whose proof no longer verifies.
377    #[serde(skip_serializing_if = "Option::is_none", default)]
378    pub id: Option<String>,
379
380    /// DID of the entity issuing this credential
381    pub issuer: String,
382
383    /// ISO 8601 format of when this credentials become valid from
384    #[serde(serialize_with = "iso8601_format", alias = "issuanceDate")]
385    pub valid_from: DateTime<Utc>,
386
387    /// ISO 8601 format of when these credentials are valid to
388    #[serde(serialize_with = "iso8601_format_option")]
389    #[serde(
390        skip_serializing_if = "Option::is_none",
391        alias = "expirationDate",
392        default
393    )]
394    pub valid_until: Option<DateTime<Utc>>,
395
396    /// Identifier (`threadId`) of the trust task exchange in which this credential was issued.
397    ///
398    /// REQUIRED for [DTGCredentialType::Witness] credentials, OPTIONAL for all other DTG
399    /// credential types. A DTG credential without a `taskContext` MUST be interpretable
400    /// standing alone, independent of any exchange.
401    ///
402    /// NOTE: A verifier MUST NOT interpret a `taskContext`-bearing credential as proof that
403    /// the associated trust task completed unless the matching trust task outcome evidence is
404    /// also present and verified.
405    #[serde(skip_serializing_if = "Option::is_none", default)]
406    pub task_context: Option<String>,
407
408    /// The assertion between the entities involved
409    pub credential_subject: CredentialSubject,
410
411    /// Cryptographic proof of credential authenticity
412    #[serde(skip_serializing_if = "Option::is_none", default)]
413    pub proof: Option<DataIntegrityProof>,
414}
415
416impl DTGCommon {
417    /// Has this credential been signed?
418    /// Returns true if a proof exists
419    /// NOTE: This does NOT validate the proof itself
420    pub fn signed(&self) -> bool {
421        self.proof.is_some()
422    }
423
424    /// This credential's own identifier, if it has one. See [DTGCommon::id].
425    pub fn id(&self) -> Option<&str> {
426        self.id.as_deref()
427    }
428
429    /// Returns the issuer DID
430    pub fn issuer(&self) -> &str {
431        &self.issuer
432    }
433
434    /// Returns the subject DID
435    #[allow(deprecated)]
436    pub fn subject(&self) -> &str {
437        match &self.credential_subject {
438            CredentialSubject::Basic(subject) => &subject.id,
439            CredentialSubject::Endorsement(subject) => &subject.id,
440            CredentialSubject::Witness(subject) => &subject.id,
441            CredentialSubject::RCard(subject) => &subject.id,
442        }
443    }
444
445    /// The credential is valid from this timestamp
446    pub fn valid_from(&self) -> DateTime<Utc> {
447        self.valid_from
448    }
449
450    /// The credential is valid until this timestamp, if set
451    pub fn valid_until(&self) -> Option<DateTime<Utc>> {
452        self.valid_until
453    }
454
455    /// The `threadId` of the trust task exchange this credential was issued in, if set
456    pub fn task_context(&self) -> Option<&str> {
457        self.task_context.as_deref()
458    }
459}
460
461/// Helps ensure default starting point is correct
462impl Default for DTGCommon {
463    fn default() -> Self {
464        DTGCommon {
465            context: vec![
466                "https://www.w3.org/ns/credentials/v2".to_string(),
467                "https://firstperson.network/credentials/dtg/v1".to_string(),
468            ],
469            type_: vec![
470                "VerifiableCredential".to_string(),
471                "DTGCredential".to_string(),
472            ],
473            id: None,
474            issuer: String::new(),
475            valid_from: Utc::now(),
476            valid_until: None,
477            task_context: None,
478            credential_subject: CredentialSubject::Basic(CredentialSubjectBasic {
479                id: String::new(),
480            }),
481            proof: None,
482        }
483    }
484}
485
486/// Post deserialize setup of a CredentialSubject and CredntialType
487impl TryFrom<DTGCommon> for DTGCredential {
488    type Error = DTGCredentialError;
489
490    #[allow(deprecated)]
491    fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
492        match &value.type_.as_slice().try_into()? {
493            DTGCredentialType::Membership => Ok(DTGCredential {
494                type_: DTGCredentialType::Membership,
495                version: value.context.as_slice().try_into()?,
496                credential: value,
497            }),
498            DTGCredentialType::Relationship => Ok(DTGCredential {
499                type_: DTGCredentialType::Relationship,
500                version: value.context.as_slice().try_into()?,
501                credential: value,
502            }),
503            DTGCredentialType::Invitation => Ok(DTGCredential {
504                type_: DTGCredentialType::Invitation,
505                version: value.context.as_slice().try_into()?,
506                credential: value,
507            }),
508            DTGCredentialType::Persona => Ok(DTGCredential {
509                type_: DTGCredentialType::Persona,
510                version: value.context.as_slice().try_into()?,
511                credential: value,
512            }),
513            DTGCredentialType::Endorsement => {
514                if let CredentialSubject::Endorsement { .. } = &value.credential_subject {
515                    Ok(DTGCredential {
516                        type_: DTGCredentialType::Endorsement,
517                        version: value.context.as_slice().try_into()?,
518                        credential: value,
519                    })
520                } else {
521                    Err(DTGCredentialError::UnknownCredential)
522                }
523            }
524            DTGCredentialType::Witness => {
525                // taskContext is REQUIRED on a VWC: the meaning of a witness attestation
526                // depends on the conditions it was made under, which live in the trust task
527                // exchange it is bound to.
528                if value.task_context.is_none() {
529                    return Err(DTGCredentialError::MissingTaskContext);
530                }
531
532                match &value.credential_subject {
533                    CredentialSubject::Witness(_) => Ok(DTGCredential {
534                        type_: DTGCredentialType::Witness,
535                        version: value.context.as_slice().try_into()?,
536                        credential: value,
537                    }),
538                    CredentialSubject::Basic(subject) => {
539                        // If Witness CredentialSubject only contains id, it is still valid
540                        Ok(DTGCredential {
541                            type_: DTGCredentialType::Witness,
542                            version: value.context.as_slice().try_into()?,
543                            credential: DTGCommon {
544                                credential_subject: CredentialSubject::Witness(
545                                    CredentialSubjectWitness {
546                                        id: subject.id.clone(),
547                                        digest: None,
548                                        witness_context: None,
549                                    },
550                                ),
551                                ..value
552                            },
553                        })
554                    }
555                    _ => Err(DTGCredentialError::UnknownCredential),
556                }
557            }
558            DTGCredentialType::RCard => match &value.credential_subject {
559                CredentialSubject::RCard { .. } => Ok(DTGCredential {
560                    type_: DTGCredentialType::RCard,
561                    version: value.context.as_slice().try_into()?,
562                    credential: value,
563                }),
564                _ => Err(DTGCredentialError::UnknownCredential),
565            },
566        }
567    }
568}
569
570/// This correctly formats timestamps into the correct iso8601 specification for W3C Verifiable
571/// Credentials
572fn iso8601_format<S>(timestamp: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
573where
574    S: Serializer,
575{
576    s.serialize_str(
577        timestamp
578            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
579            .as_str(),
580    )
581}
582
583fn iso8601_format_option<S>(timestamp: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
584where
585    S: Serializer,
586{
587    if let Some(timestamp) = timestamp {
588        s.serialize_str(
589            timestamp
590                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
591                .as_str(),
592        )
593    } else {
594        s.serialize_none()
595    }
596}
597
598// ****************************************************************************
599// Credential Subject types
600// ****************************************************************************
601// NOTE: The DTG credential spec overloads the JSON attributes for different credential payloads.
602// The following enum will map the credential subject schema to correct Struct type
603
604/// This represents all possible credential subjects
605/// The order of the enum is important as it will match on first match
606#[allow(deprecated)]
607#[derive(Serialize, Deserialize, Debug, Clone)]
608#[serde(untagged)]
609pub enum CredentialSubject {
610    /// Verifiable Endorsement Credential subject
611    Endorsement(CredentialSubjectEndorsement),
612
613    /// R-Card Credential subject
614    #[deprecated(
615        since = "0.2.0",
616        note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
617                See DTGCredentialType::RCard. This variant will be removed in a future release."
618    )]
619    RCard(CredentialSubjectRCard),
620
621    /// Credential Subject of just `id`
622    /// Use by  VMC, VRC, VIC and VPC
623    Basic(CredentialSubjectBasic),
624
625    /// Verifiable Witness Credential subject
626    Witness(CredentialSubjectWitness),
627}
628
629/// id of the credential subject only
630#[derive(Serialize, Deserialize, Debug, Clone)]
631#[serde(deny_unknown_fields)]
632pub struct CredentialSubjectBasic {
633    pub id: String,
634}
635
636/// Endorsement Credential subject
637#[derive(Serialize, Deserialize, Debug, Clone)]
638#[serde(deny_unknown_fields)]
639pub struct CredentialSubjectEndorsement {
640    pub id: String,
641    /// There is no spec for the endorsement content, so we use a generic JSON value
642    pub endorsement: Value,
643}
644
645/// Witness Credential subject
646#[derive(Serialize, Deserialize, Debug, Clone)]
647#[serde(rename_all = "camelCase", deny_unknown_fields)]
648pub struct CredentialSubjectWitness {
649    pub id: String,
650
651    #[serde(skip_serializing_if = "Option::is_none")]
652    pub digest: Option<String>,
653
654    /// There is no spec for the witness context content, so we use a generic JSON value
655    #[serde(skip_serializing_if = "Option::is_none")]
656    pub witness_context: Option<WitnessContext>,
657}
658
659/// Witness Credential Context
660#[derive(Serialize, Deserialize, Debug, Clone)]
661#[serde(rename_all = "camelCase", deny_unknown_fields)]
662pub struct WitnessContext {
663    /// Human-readable event name
664    pub event: Option<String>,
665
666    /// Session or nonce identifier
667    pub session_id: Option<String>,
668
669    ///Verification method used
670    pub method: Option<String>,
671}
672
673/// R-Card Credential subject
674#[deprecated(
675    since = "0.2.0",
676    note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
677            See DTGCredentialType::RCard. This struct will be removed in a future release."
678)]
679#[derive(Serialize, Deserialize, Debug, Clone)]
680#[serde(deny_unknown_fields)]
681pub struct CredentialSubjectRCard {
682    pub id: String,
683
684    /// JCard spec, generic JSON value
685    pub card: Value,
686}
687
688#[cfg(test)]
689#[allow(deprecated)]
690mod tests {
691    use crate::{
692        CredentialSubject, CredentialSubjectRCard, DTGCommon, DTGCredential, DTGCredentialType,
693        W3CVCVersion,
694    };
695    use chrono::{DateTime, Utc};
696    use serde_json::Value;
697
698    #[test]
699    fn test_vmc_vc_1_deserialize() {
700        // tests deserialize a W3C VC Version 1.1 credential
701        let vmc: DTGCredential = match serde_json::from_str(
702            r#"{
703"@context": [
704    "https://www.w3.org/2018/credentials/v1",
705    "https://firstperson.network/credentials/dtg/v1",
706    "https://w3id.org/security/suites/ed25519-2020/v1"
707  ],
708  "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
709  "issuer": "did:web:chess-club.example",
710  "issuanceDate": "2026-01-06T10:00:00Z",
711  "expirationDate": "2027-01-06T10:00:00Z",
712  "credentialSubject": {
713    "id": "did:key:z6MkpTHR8VNs..."
714  }
715            }"#,
716        ) {
717            Ok(vmc) => vmc,
718            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
719        };
720
721        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
722        assert!(matches!(
723            vmc.credential().credential_subject,
724            CredentialSubject::Basic(_)
725        ));
726        assert!(matches!(vmc.version, W3CVCVersion::V1_1));
727        assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V1_1));
728    }
729
730    #[test]
731    fn test_missing_w3c_context() {
732        // tests deserialize a W3C VC Version 1.1 credential
733        assert!(
734            serde_json::from_str::<DTGCredential>(
735                r#"{
736"@context": [
737    "https://firstperson.network/credentials/dtg/v1",
738    "https://w3id.org/security/suites/ed25519-2020/v1"
739  ],
740  "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
741  "issuer": "did:web:chess-club.example",
742  "issuanceDate": "2026-01-06T10:00:00Z",
743  "expirationDate": "2027-01-06T10:00:00Z",
744  "credentialSubject": {
745    "id": "did:key:z6MkpTHR8VNs..."
746  }
747            }"#,
748            )
749            .is_err()
750        );
751    }
752
753    #[test]
754    fn test_mutable_credential() {
755        let mut vmc = DTGCredential::new_vmc(
756            "did:example:issuer".to_string(),
757            "did:example:subject".to_string(),
758            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
759                .unwrap()
760                .with_timezone(&Utc),
761            None,
762            false,
763        );
764
765        let cred = vmc.credential_mut();
766        cred.type_.push("PersonhoodCredential".to_string());
767        assert!(vmc.is_personhood_credential());
768    }
769
770    #[test]
771    fn test_vmc_deserialize() {
772        let vmc: DTGCredential = match serde_json::from_str(
773            r#"{
774                "@context": ["https://www.w3.org/ns/credentials/v2"],
775                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
776                "issuer": "did:example:community",
777                "validFrom": "2024-06-18T10:00:00Z",
778                "credentialSubject": { "id": "did:example:rDid" }
779            }"#,
780        ) {
781            Ok(vmc) => vmc,
782            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
783        };
784
785        assert!(!vmc.is_personhood_credential());
786        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
787        assert!(matches!(
788            vmc.credential().credential_subject,
789            CredentialSubject::Basic(_)
790        ));
791        assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V2_0));
792    }
793
794    #[test]
795    fn test_vmc_phc_deserialize() {
796        let vmc: DTGCredential = match serde_json::from_str(
797            r#"{
798                "@context": ["https://www.w3.org/ns/credentials/v2"],
799                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential", "PersonhoodCredential"],
800                "issuer": "did:example:community",
801                "validFrom": "2024-06-18T10:00:00Z",
802                "credentialSubject": { "id": "did:example:rDid" }
803            }"#,
804        ) {
805            Ok(vmc) => vmc,
806            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
807        };
808
809        assert!(vmc.is_personhood_credential());
810        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
811        assert!(matches!(
812            vmc.credential().credential_subject,
813            CredentialSubject::Basic(_)
814        ));
815    }
816
817    #[test]
818    fn test_vrc_deserialize() {
819        let vrc: DTGCredential = match serde_json::from_str(
820            r#"{
821                "@context": ["https://www.w3.org/ns/credentials/v2"],
822                "type": ["VerifiableCredential", "DTGCredential",  "RelationshipCredential"],
823                "issuer": "did:example:governmentAgencyDid",
824                "validFrom": "2024-06-18T10:00:00Z",
825                "credentialSubject": { "id": "did:example:citizenRDid" }
826            }"#,
827        ) {
828            Ok(vrc) => vrc,
829            Err(e) => panic!("Couldn't deserialize VRC: {}", e),
830        };
831
832        assert!(matches!(vrc.type_, DTGCredentialType::Relationship));
833        assert!(matches!(
834            vrc.credential().credential_subject,
835            CredentialSubject::Basic(_)
836        ));
837    }
838
839    #[test]
840    fn test_vic_deserialize() {
841        let vic: DTGCredential = match serde_json::from_str(
842            r#"{
843                "@context": ["https://www.w3.org/ns/credentials/v2"],
844                "type": ["VerifiableCredential", "DTGCredential",  "InvitationCredential"],
845                "issuer": "did:example:governmentAgencyVicDid",
846                "validFrom": "2024-06-18T10:00:00Z",
847                "credentialSubject": { "id": "did:example:citizenRDid" }
848            }"#,
849        ) {
850            Ok(vic) => vic,
851            Err(e) => panic!("Couldn't deserialize VIC: {}", e),
852        };
853
854        assert!(!vic.is_personhood_credential());
855        assert!(matches!(vic.type_, DTGCredentialType::Invitation));
856        assert!(matches!(
857            vic.credential().credential_subject,
858            CredentialSubject::Basic(_)
859        ));
860    }
861
862    #[test]
863    fn test_vpc_deserialize() {
864        let vpc: DTGCredential = match serde_json::from_str(
865            r#"{
866                "@context": ["https://www.w3.org/ns/credentials/v2"],
867                "type": ["VerifiableCredential", "DTGCredential",  "PersonaCredential"],
868                "issuer": "did:example:governmentAgencyDid",
869                "validFrom": "2024-06-18T10:00:00Z",
870                "credentialSubject": { "id": "did:example:citizenRDid" }
871            }"#,
872        ) {
873            Ok(vpc) => vpc,
874            Err(e) => panic!("Couldn't deserialize VPC: {}", e),
875        };
876
877        assert!(matches!(vpc.type_, DTGCredentialType::Persona));
878        assert!(matches!(
879            vpc.credential().credential_subject,
880            CredentialSubject::Basic(_)
881        ));
882    }
883
884    #[test]
885    fn test_vec_deserialize() {
886        let vec: DTGCredential = match serde_json::from_str(
887            r#"{
888                "@context": ["https://www.w3.org/ns/credentials/v2"],
889                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
890                "issuer": "did:example:governmentAgencyDid",
891                "validFrom": "2024-06-18T10:00:00Z",
892                "credentialSubject": { "id": "did:example:citizenRDid", "endorsement": {} }
893            }"#,
894        ) {
895            Ok(vec) => vec,
896            Err(e) => panic!("Couldn't deserialize VEC: {}", e),
897        };
898
899        assert!(matches!(vec.type_, DTGCredentialType::Endorsement));
900        assert!(matches!(vec.subject(), "did:example:citizenRDid"));
901        assert!(matches!(
902            vec.credential().credential_subject,
903            CredentialSubject::Endorsement(_)
904        ));
905    }
906
907    #[test]
908    fn test_vec_bad_deserialize() {
909        match serde_json::from_str::<DTGCredential>(
910            r#"{
911                "@context": ["https://www.w3.org/ns/credentials/v2"],
912                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
913                "issuer": "did:example:governmentAgencyDid",
914                "validFrom": "2024-06-18T10:00:00Z",
915                "credentialSubject": { "id": "did:example:citizenRDid", "other": [] }
916            }"#,
917        ) {
918            Ok(_) => panic!("Expected Unknown Credential type"),
919            Err(_) => {
920                // Good
921            }
922        };
923    }
924
925    #[test]
926    fn test_vwc_simple_deserialize() {
927        let vwc: DTGCredential = match serde_json::from_str(
928            r#"{
929                "@context": ["https://www.w3.org/ns/credentials/v2"],
930                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
931                "issuer": "did:example:governmentAgencyDid",
932                "validFrom": "2024-06-18T10:00:00Z",
933                "taskContext": "thread-abc-123",
934                "credentialSubject": { "id": "did:example:citizenRDid" }
935            }"#,
936        ) {
937            Ok(vwc) => vwc,
938            Err(e) => panic!("Couldn't deserialize VWC: {}", e),
939        };
940
941        assert!(matches!(vwc.type_, DTGCredentialType::Witness));
942        assert!(matches!(vwc.subject(), "did:example:citizenRDid"));
943        assert_eq!(vwc.task_context(), Some("thread-abc-123"));
944        assert!(matches!(
945            vwc.credential().credential_subject,
946            CredentialSubject::Witness(_)
947        ));
948    }
949
950    #[test]
951    fn test_vwc_full_deserialize() {
952        let vwc: DTGCredential = match serde_json::from_str(
953            r#"{
954                "@context": ["https://www.w3.org/ns/credentials/v2"],
955                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
956                "issuer": "did:example:governmentAgencyDid",
957                "validFrom": "2024-06-18T10:00:00Z",
958                "taskContext": "thread-abc-123",
959                "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "witnessContext": {} }
960            }"#,
961        ) {
962            Ok(vwc) => vwc,
963            Err(e) => panic!("Couldn't deserialize VWC: {}", e),
964        };
965
966        assert!(matches!(vwc.type_(), DTGCredentialType::Witness));
967        assert!(matches!(
968            vwc.credential().credential_subject,
969            CredentialSubject::Witness(_)
970        ));
971    }
972
973    #[test]
974    fn test_vwc_bad_deserialize() {
975        if serde_json::from_str::<DTGCredential>(
976            r#"{
977                "@context": ["https://www.w3.org/ns/credentials/v2"],
978                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
979                "issuer": "did:example:governmentAgencyDid",
980                "validFrom": "2024-06-18T10:00:00Z",
981                "taskContext": "thread-abc-123",
982                "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "wrongContext": {}  }
983            }"#,
984        ).is_ok() {
985            panic!("Should have failed due to wrong CredentialSubject!");
986        }
987    }
988
989    #[test]
990    fn test_rcard_simple_deserialize() {
991        let rcard: DTGCredential = match serde_json::from_str(
992            r#"{
993                "@context": ["https://www.w3.org/ns/credentials/v2"],
994                "type": ["VerifiableCredential", "DTGCredential",  "RCardCredential"],
995                "issuer": "did:example:governmentAgencyDid",
996                "validFrom": "2024-06-18T10:00:00Z",
997                "credentialSubject": { "id": "did:example:citizenRDid", "card": [] }
998            }"#,
999        ) {
1000            Ok(rcard) => rcard,
1001            Err(e) => panic!("Couldn't deserialize R-Card: {}", e),
1002        };
1003
1004        assert!(matches!(rcard.type_(), DTGCredentialType::RCard));
1005        assert!(matches!(rcard.subject(), "did:example:citizenRDid"));
1006        assert!(matches!(
1007            rcard.credential().credential_subject,
1008            CredentialSubject::RCard(_)
1009        ));
1010    }
1011
1012    #[test]
1013    fn test_rcard_bad_deserialize() {
1014        if serde_json::from_str::<DTGCredential>(
1015            r#"{
1016                "@context": ["https://www.w3.org/ns/credentials/v2"],
1017                "type": ["VerifiableCredential", "DTGCredential",  "RCardCredential"],
1018                "issuer": "did:example:governmentAgencyDid",
1019                "validFrom": "2024-06-18T10:00:00Z",
1020                "credentialSubject": { "id": "did:example:citizenRDid"  }
1021            }"#,
1022        )
1023        .is_ok()
1024        {
1025            panic!("Should have failed due to wrong CredentialSubject!");
1026        }
1027    }
1028    #[test]
1029    fn test_deserialize_unknown() {
1030        match serde_json::from_str::<DTGCredential>(
1031            r#"{
1032                "@context": ["https://www.w3.org/ns/credentials/v2"],
1033                "type": ["VerifiableCredential", "DTGCredential",  "UnknownCredential"],
1034                "issuer": "did:example:governmentAgencyDid",
1035                "validFrom": "2024-06-18T10:00:00Z",
1036                "credentialSubject": { "id": "did:example:citizenRDid" }
1037            }"#,
1038        ) {
1039            Ok(_) => panic!("Expected Unknown Credential type"),
1040            Err(e) => {
1041                if e.to_string() == "Unknown credential type" {
1042                    // test passed
1043                } else {
1044                    panic!("Wrong error type returned");
1045                }
1046            }
1047        };
1048    }
1049
1050    #[test]
1051    fn test_deserialize_mismatched_credential_subject() {
1052        match serde_json::from_str::<DTGCredential>(
1053            r#"{
1054                "@context": ["https://www.w3.org/ns/credentials/v2"],
1055                "type": ["VerifiableCredential", "DTGCredential",  "EndorsementCredential"],
1056                "issuer": "did:example:governmentAgencyDid",
1057                "validFrom": "2024-06-18T10:00:00Z",
1058                "credentialSubject": { "id": "did:example:citizenRDid" }
1059            }"#,
1060        ) {
1061            Ok(_) => panic!("Expected Unknown Credential type"),
1062            Err(e) => {
1063                if e.to_string() == "Unknown credential type" {
1064                    // test passed
1065                } else {
1066                    panic!("Wrong error type returned");
1067                }
1068            }
1069        };
1070    }
1071
1072    #[test]
1073    fn test_proof_signed() {
1074        let cred: DTGCredential = match serde_json::from_str(
1075            r#"{
1076                "@context": ["https://www.w3.org/ns/credentials/v2"],
1077                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1078                "issuer": "did:example:community",
1079                "validFrom": "2024-06-18T10:00:00Z",
1080                "credentialSubject": { "id": "did:example:rDid" },
1081                "proof": {
1082                    "type": "DataIntegrityProof",
1083                    "cryptosuite": "eddsa-jcs-2022",
1084                    "created": "2025-12-04T00:00:00",
1085                    "verificationMethod": "did:example:test#key-1",
1086                    "proofPurpose": "assertionMethod",
1087                    "proofValue": "abcd"
1088                }
1089            }"#,
1090        ) {
1091            Ok(vmc) => vmc,
1092            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1093        };
1094
1095        assert!(cred.signed());
1096        assert!(cred.proof_value().is_some());
1097    }
1098
1099    #[test]
1100    fn test_proof_not_signed() {
1101        let cred: DTGCredential = match serde_json::from_str(
1102            r#"{
1103                "@context": ["https://www.w3.org/ns/credentials/v2"],
1104                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1105                "issuer": "did:example:community",
1106                "validFrom": "2024-06-18T10:00:00Z",
1107                "credentialSubject": { "id": "did:example:rDid" }
1108            }"#,
1109        ) {
1110            Ok(vmc) => vmc,
1111            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1112        };
1113
1114        assert!(!cred.signed());
1115        assert!(cred.proof_value().is_none());
1116    }
1117
1118    #[test]
1119    fn test_helpers() {
1120        let cred: DTGCredential = match serde_json::from_str(
1121            r#"{
1122                "@context": ["https://www.w3.org/ns/credentials/v2"],
1123                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1124                "issuer": "did:example:issuer",
1125                "validFrom": "2024-06-18T00:00:00Z",
1126                "credentialSubject": { "id": "did:example:subject" }
1127            }"#,
1128        ) {
1129            Ok(vmc) => vmc,
1130            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1131        };
1132
1133        assert_eq!(cred.issuer(), "did:example:issuer");
1134        assert_eq!(cred.subject(), "did:example:subject");
1135        assert_eq!(
1136            cred.valid_from()
1137                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1138            "2024-06-18T00:00:00Z"
1139        );
1140        assert_eq!(cred.valid_until(), None);
1141    }
1142
1143    #[test]
1144    fn test_valid_until() {
1145        let cred: DTGCredential = match serde_json::from_str(
1146            r#"{
1147                "@context": ["https://www.w3.org/ns/credentials/v2"],
1148                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
1149                "issuer": "did:example:issuer",
1150                "validFrom": "2024-06-18T00:00:00Z",
1151                "validUntil": "2030-01-01T00:00:00Z",
1152                "credentialSubject": { "id": "did:example:subject" }
1153            }"#,
1154        ) {
1155            Ok(vmc) => vmc,
1156            Err(e) => panic!("Couldn't deserialize credential: {}", e),
1157        };
1158
1159        assert_eq!(
1160            cred.valid_until()
1161                .unwrap()
1162                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1163            "2030-01-01T00:00:00Z"
1164        );
1165    }
1166
1167    #[test]
1168    fn test_bad_type() {
1169        assert!(
1170            std::convert::TryInto::<DTGCredentialType>::try_into(
1171                vec!["bad_type".to_string()].as_slice(),
1172            )
1173            .is_err()
1174        );
1175    }
1176
1177    #[test]
1178    fn test_badly_constructed_vwc() {
1179        let mut cred = DTGCommon::default();
1180        cred.type_.push("WitnessCredential".to_string());
1181        // taskContext is set so this exercises the credentialSubject mismatch, not the
1182        // missing-taskContext path covered by test_vwc_missing_task_context()
1183        cred.task_context = Some("thread-abc-123".to_string());
1184        cred.credential_subject = CredentialSubject::RCard(CredentialSubjectRCard {
1185            id: "did:example:bad".to_string(),
1186            card: Value::Null,
1187        });
1188
1189        assert!(std::convert::TryInto::<DTGCredential>::try_into(cred).is_err());
1190    }
1191
1192    #[test]
1193    fn test_vwc_missing_task_context() {
1194        // taskContext is REQUIRED on a VWC
1195        match serde_json::from_str::<DTGCredential>(
1196            r#"{
1197                "@context": ["https://www.w3.org/ns/credentials/v2"],
1198                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1199                "issuer": "did:example:witness",
1200                "validFrom": "2024-06-18T10:00:00Z",
1201                "credentialSubject": { "id": "did:example:observed" }
1202            }"#,
1203        ) {
1204            Ok(_) => panic!("Expected a VWC without taskContext to be rejected"),
1205            Err(e) => assert_eq!(
1206                e.to_string(),
1207                "WitnessCredential is missing the required taskContext property"
1208            ),
1209        }
1210    }
1211
1212    #[test]
1213    fn test_task_context_round_trip() {
1214        // taskContext must survive deserialize -> serialize, otherwise a credential signed
1215        // elsewhere would fail verification here (and vice versa)
1216        let raw = r#"{
1217                "@context": ["https://www.w3.org/ns/credentials/v2"],
1218                "type": ["VerifiableCredential", "DTGCredential",  "WitnessCredential"],
1219                "issuer": "did:example:witness",
1220                "validFrom": "2024-06-18T10:00:00Z",
1221                "taskContext": "thread-abc-123",
1222                "credentialSubject": { "id": "did:example:observed" }
1223            }"#;
1224
1225        let cred: DTGCredential = serde_json::from_str(raw).unwrap();
1226        let out = serde_json::to_string(&cred).unwrap();
1227
1228        assert!(out.contains(r#""taskContext":"thread-abc-123""#));
1229    }
1230
1231    #[test]
1232    fn test_task_context_optional_on_other_types() {
1233        // taskContext is OPTIONAL everywhere except the VWC
1234        let vrc: DTGCredential = serde_json::from_str(
1235            r#"{
1236                "@context": ["https://www.w3.org/ns/credentials/v2"],
1237                "type": ["VerifiableCredential", "DTGCredential",  "RelationshipCredential"],
1238                "issuer": "did:example:issuer",
1239                "validFrom": "2024-06-18T10:00:00Z",
1240                "credentialSubject": { "id": "did:example:subject" }
1241            }"#,
1242        )
1243        .unwrap();
1244
1245        assert_eq!(vrc.task_context(), None);
1246        // and it is omitted from the serialization entirely when absent
1247        assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
1248    }
1249
1250    #[test]
1251    fn test_digest_multibase() {
1252        let vrc = DTGCredential::new_vrc(
1253            "did:example:issuer".to_string(),
1254            "did:example:subject".to_string(),
1255            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1256                .unwrap()
1257                .with_timezone(&Utc),
1258            None,
1259        );
1260
1261        let digest = vrc.digest_multibase().unwrap();
1262
1263        // base58btc multibase prefix
1264        assert!(digest.starts_with('z'));
1265
1266        // decodes to a sha2-256 multihash: 0x12 0x20 followed by 32 digest bytes
1267        let (base, bytes) = multibase::decode(&digest).unwrap();
1268        assert_eq!(base, multibase::Base::Base58Btc);
1269        assert_eq!(bytes.len(), 34);
1270        assert_eq!(&bytes[..2], &[0x12, 0x20]);
1271
1272        // stable across calls
1273        assert_eq!(digest, vrc.digest_multibase().unwrap());
1274
1275        // and distinct for a different credential
1276        let other = DTGCredential::new_vrc(
1277            "did:example:issuer".to_string(),
1278            "did:example:someone-else".to_string(),
1279            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1280                .unwrap()
1281                .with_timezone(&Utc),
1282            None,
1283        );
1284        assert_ne!(digest, other.digest_multibase().unwrap());
1285    }
1286
1287    #[test]
1288    fn test_verify_digest() {
1289        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1290            .unwrap()
1291            .with_timezone(&Utc);
1292
1293        let vrc = DTGCredential::new_vrc(
1294            "did:example:issuer".to_string(),
1295            "did:example:subject".to_string(),
1296            valid_from,
1297            None,
1298        );
1299
1300        let vwc = DTGCredential::new_vwc(
1301            "did:example:witness".to_string(),
1302            // the DID of the issuer of the VRC being attested
1303            "did:example:issuer".to_string(),
1304            valid_from,
1305            None,
1306            "thread-abc-123".to_string(),
1307            Some(vrc.digest_multibase().unwrap()),
1308            None,
1309        );
1310
1311        assert!(vwc.verify_digest(&vrc).unwrap());
1312
1313        // a different VRC must not match
1314        let other = DTGCredential::new_vrc(
1315            "did:example:issuer".to_string(),
1316            "did:example:someone-else".to_string(),
1317            valid_from,
1318            None,
1319        );
1320        assert!(!vwc.verify_digest(&other).unwrap());
1321    }
1322
1323    #[test]
1324    fn test_verify_digest_without_digest() {
1325        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1326            .unwrap()
1327            .with_timezone(&Utc);
1328
1329        let vrc = DTGCredential::new_vrc(
1330            "did:example:issuer".to_string(),
1331            "did:example:subject".to_string(),
1332            valid_from,
1333            None,
1334        );
1335
1336        // digest is OPTIONAL - with none present there is nothing to rely on
1337        let vwc = DTGCredential::new_vwc(
1338            "did:example:witness".to_string(),
1339            "did:example:issuer".to_string(),
1340            valid_from,
1341            None,
1342            "thread-abc-123".to_string(),
1343            None,
1344            None,
1345        );
1346
1347        assert!(!vwc.verify_digest(&vrc).unwrap());
1348    }
1349
1350    #[test]
1351    fn test_iso8601_format_option() {
1352        let now: DateTime<Utc> = DateTime::parse_from_rfc3339(
1353            &Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1354        )
1355        .unwrap()
1356        .to_utc();
1357        let cred = DTGCommon {
1358            valid_until: Some(now),
1359            ..Default::default()
1360        };
1361
1362        let value = serde_json::to_value(&cred).unwrap();
1363        let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
1364        assert_eq!(cred2.valid_until, Some(now));
1365
1366        let cred = DTGCommon::default();
1367        let value = serde_json::to_value(&cred).unwrap();
1368        let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
1369        assert_eq!(cred2.valid_until, None);
1370    }
1371
1372    #[cfg(feature = "affinidi-signing")]
1373    #[tokio::test]
1374    async fn test_signing() {
1375        use affinidi_secrets_resolver::secrets::Secret;
1376
1377        let secret = Secret::generate_ed25519(None, None);
1378
1379        let mut cred = DTGCredential::new_vrc(
1380            "did:example:issuer".to_string(),
1381            "did:example:subject".to_string(),
1382            Utc::now(),
1383            None,
1384        );
1385
1386        assert!(cred.sign(&secret, Some(Utc::now())).await.is_ok());
1387
1388        assert!(
1389            cred.verify_proof_with_public_key(secret.get_public_bytes())
1390                .is_ok()
1391        );
1392
1393        let secret2 = Secret::generate_ed25519(None, None);
1394        assert!(
1395            cred.verify_proof_with_public_key(secret2.get_public_bytes())
1396                .is_err()
1397        );
1398    }
1399
1400    /// The proof covers `id`, so it must be set *before* signing.
1401    ///
1402    /// This is the property that makes [DTGCredential::with_id]'s "set it before signing"
1403    /// caveat load-bearing rather than advisory: a credential signed without an identifier
1404    /// cannot be given one afterwards to satisfy a verifier that requires it, because the
1405    /// document that was signed did not contain it. Tampering with `id` after the fact is
1406    /// the same operation, and must fail the same way.
1407    #[cfg(feature = "affinidi-signing")]
1408    #[tokio::test]
1409    async fn test_id_is_covered_by_the_proof() {
1410        use affinidi_secrets_resolver::secrets::Secret;
1411
1412        let secret = Secret::generate_ed25519(None, None);
1413
1414        let mut cred = DTGCredential::new_vrc(
1415            "did:example:issuer".to_string(),
1416            "did:example:subject".to_string(),
1417            Utc::now(),
1418            None,
1419        )
1420        .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
1421
1422        cred.sign(&secret, Some(Utc::now()))
1423            .await
1424            .expect("signing a credential that carries an id");
1425        assert!(
1426            cred.verify_proof_with_public_key(secret.get_public_bytes())
1427                .is_ok(),
1428            "an id set before signing verifies"
1429        );
1430
1431        // Changing the id after signing — which is what "splice an id into the JSON on the
1432        // way out" amounts to — invalidates the proof.
1433        cred.set_id("urn:uuid:00000000-0000-0000-0000-000000000000");
1434        assert!(
1435            cred.verify_proof_with_public_key(secret.get_public_bytes())
1436                .is_err(),
1437            "an id changed after signing must break the proof"
1438        );
1439    }
1440
1441    #[cfg(feature = "affinidi-signing")]
1442    #[tokio::test]
1443    async fn test_signing_error() {
1444        use affinidi_secrets_resolver::secrets::Secret;
1445
1446        let secret = Secret::generate_x25519(None, None).unwrap();
1447
1448        let mut cred = DTGCredential::new_vrc(
1449            "did:example:issuer".to_string(),
1450            "did:example:subject".to_string(),
1451            Utc::now(),
1452            None,
1453        );
1454
1455        assert!(cred.sign(&secret, Some(Utc::now())).await.is_err());
1456    }
1457
1458    #[cfg(feature = "affinidi-signing")]
1459    #[test]
1460    fn test_signing_no_proof() {
1461        use crate::DTGCredentialError;
1462        use affinidi_secrets_resolver::secrets::Secret;
1463
1464        let cred = DTGCredential::new_vrc(
1465            "did:example:issuer".to_string(),
1466            "did:example:subject".to_string(),
1467            Utc::now(),
1468            None,
1469        );
1470
1471        let secret = Secret::generate_ed25519(None, None);
1472        match cred.verify_proof_with_public_key(secret.get_public_bytes()) {
1473            Err(DTGCredentialError::NotSigned) => {
1474                // Good
1475            }
1476            _ => panic!("Expected NotSigned error!"),
1477        }
1478    }
1479}