Skip to main content

dtg_credentials/
lib.rs

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