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 std::str::FromStr;
16use thiserror::Error;
17
18pub mod accept;
19pub mod authority;
20pub mod create;
21pub mod delegation;
22pub mod statement;
23
24pub use accept::{
25    AcceptListEntry, AdditionalMember, PredicateAcceptList, PredicateStatus, RegistryAcceptList,
26};
27pub use statement::{
28    CredentialSubjectStatement, ENDORSES_V1, ObjectKind, PRESENTED_V1, PredicateProfile,
29    StatementObject, VETTED_V1, WITNESSED_V1, check_predicate_iri,
30};
31
32/// The W3C VC Data Model 2.0 context, which a DTG credential lists **first**.
33pub const W3C_VC_V2_CONTEXT: &str = "https://www.w3.org/ns/credentials/v2";
34
35/// The W3C VC Data Model 1.1 context, accepted in first position under DTG Core Credentials
36/// §Legacy System Compatibility. This library never emits it.
37pub const W3C_VC_V1_CONTEXT: &str = "https://www.w3.org/2018/credentials/v1";
38
39/// The frozen DTG credential context, version 1, which a DTG credential lists **second**.
40///
41/// Published by the DTG VSC Predicate Registry as one byte-frozen document, and compared as
42/// an exact string: scheme `https`, host `registry.trustoverip.org` with no `www.`, no
43/// trailing slash. Every term this library emits is defined either here or in
44/// [W3C_VC_V2_CONTEXT].
45///
46/// # No other DTG context is recognized
47///
48/// Credentials issued before the Implementers Draft of DTG Core Credentials are not
49/// conformant to it, whatever context they list, and contexts published before `v1` under
50/// other IRIs — `https://firstperson.network/credentials/dtg/v1` among them — are not
51/// recognized. A credential listing one is refused at parse rather than aliased.
52pub const DTG_CONTEXT_V1: &str = "https://registry.trustoverip.org/dtg/context/v1";
53
54/// The correlation scope an issuer declares for its own identifier, carried as the
55/// REQUIRED top-level `issuerScope` of every DTG credential (DTG Core Credentials
56/// §Correlation Scope).
57///
58/// # Ordered, narrowest first
59///
60/// `pairwise < directed < public`, and the derived [Ord] follows it. Where a profile or a
61/// credential type states a minimum, a declaration narrower than that minimum does not
62/// satisfy it: see [IssuerScope::satisfies].
63///
64/// # A declaration about the issuer only
65///
66/// A credential declares the scope of the identifier in `issuer` and of nothing else — not
67/// its subject's, not its counterparty's. A verifier that needs the counterparty's scope
68/// reads it from a credential the counterparty issued.
69///
70/// Serialized exactly as `pairwise`, `directed` or `public`, and parsed case-sensitively:
71/// `"Public"` is not a declaration, and a credential carrying it is refused.
72#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
73#[serde(rename_all = "lowercase")]
74pub enum IssuerScope {
75    /// Known to exactly one counterparty; correlation confined to this one relationship.
76    Pairwise,
77
78    /// Known to a set of counterparties the holder chooses, and no further.
79    Directed,
80
81    /// Unbounded; ordinarily published so that it can be found.
82    Public,
83}
84
85impl IssuerScope {
86    /// The wire value: `pairwise`, `directed` or `public`.
87    pub fn as_str(&self) -> &'static str {
88        match self {
89            IssuerScope::Pairwise => "pairwise",
90            IssuerScope::Directed => "directed",
91            IssuerScope::Public => "public",
92        }
93    }
94
95    /// Does this declaration meet `minimum`? True when it is at least as wide.
96    pub fn satisfies(&self, minimum: IssuerScope) -> bool {
97        *self >= minimum
98    }
99}
100
101impl Display for IssuerScope {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.write_str(self.as_str())
104    }
105}
106
107impl FromStr for IssuerScope {
108    type Err = DTGCredentialError;
109
110    /// Parses the wire value, case-sensitively.
111    fn from_str(s: &str) -> Result<Self, Self::Err> {
112        match s {
113            "pairwise" => Ok(IssuerScope::Pairwise),
114            "directed" => Ok(IssuerScope::Directed),
115            "public" => Ok(IssuerScope::Public),
116            other => Err(DTGCredentialError::MalformedCredential(format!(
117                "`{other}` is not an issuerScope; expected `pairwise`, `directed` or `public`"
118            ))),
119        }
120    }
121}
122
123/// What W3C VC Format is the credential using?
124#[derive(Clone, Copy, Debug)]
125pub enum W3CVCVersion {
126    /// <https://www.w3.org/2018/credentials/v1>
127    V1_1,
128
129    /// <https://www.w3.org/ns/credentials/v2>
130    V2_0,
131}
132
133impl TryFrom<&[String]> for W3CVCVersion {
134    type Error = DTGCredentialError;
135
136    /// Will return the W3C Version from the context array
137    fn try_from(types: &[String]) -> Result<Self, Self::Error> {
138        if types.contains(&"https://www.w3.org/2018/credentials/v1".to_string()) {
139            Ok(W3CVCVersion::V1_1)
140        } else if types.contains(&"https://www.w3.org/ns/credentials/v2".to_string()) {
141            Ok(W3CVCVersion::V2_0)
142        } else {
143            Err(DTGCredentialError::UnknownVCVersion)
144        }
145    }
146}
147
148/// Errors related to DTG Credentials
149///
150/// New variants may be added in minor releases; match with a wildcard arm.
151#[derive(Error, Debug)]
152#[non_exhaustive]
153pub enum DTGCredentialError {
154    #[error("Unknown credential type")]
155    UnknownCredential,
156
157    #[cfg(feature = "affinidi-signing")]
158    #[error("Data Integrity Error: {0}")]
159    DataIntegrity(#[from] DataIntegrityError),
160
161    #[error("Credential is not signed")]
162    NotSigned,
163
164    #[error("Unknown W3C VC Version")]
165    UnknownVCVersion,
166
167    /// An AuthorityCredential (VAC) carried an empty `actions` list.
168    ///
169    /// Emptiness is never a wildcard: a VAC conferring no actions confers nothing, and is
170    /// rejected rather than treated as unrestricted.
171    #[error("AuthorityCredential carries an empty actions list, which confers nothing")]
172    EmptyAuthorityActions,
173
174    /// [DTGCredential::attenuate] was called on a credential that is not a VAC.
175    #[error("not an AuthorityCredential, so there is no authority to attenuate")]
176    NotAnAuthorityCredential,
177
178    /// [DTGCredential::attenuate] was called on a VAC with no `id`.
179    ///
180    /// No longer produced. Working Draft 02 makes `authority.parent` a **digest** of the
181    /// parent rather than its `id`, precisely so that no credential needs a top-level
182    /// identifier merely in order to be referenced.
183    #[deprecated(
184        since = "0.7.0",
185        note = "Never returned. `authority.parent` is a digest as of Working Draft 02, so a \
186                parent VAC no longer needs an `id` to be attenuated. This variant will be \
187                removed in a future release."
188    )]
189    #[error("cannot attenuate a credential with no id — the derived VAC could not name it")]
190    AttenuationParentHasNoId,
191
192    /// A digest value was not a well-formed `digestMultibase`.
193    ///
194    /// Either the multibase envelope or the multihash inside it failed to decode. A
195    /// `sha256:<hex>` value produced against Working Draft 01 lands here, which is the
196    /// intended outcome: it is reported rather than silently compared as unequal.
197    #[error("not a well-formed digestMultibase value: {0}")]
198    InvalidDigest(String),
199
200    /// A digest named a hash algorithm this library does not implement.
201    ///
202    /// The specification permits a governing party to require a stronger hash, and carries
203    /// the algorithm in the value itself. A verifier MUST reject an algorithm it does not
204    /// accept rather than treating it as a mismatch — hence a distinct error.
205    #[error("digest uses multihash algorithm 0x{0:x}, which this library does not accept")]
206    UnsupportedDigestAlgorithm(u64),
207
208    /// A DelegationCredential (VDC) was not a well-formed grant or acceptance.
209    #[error("malformed DelegationCredential: {0}")]
210    MalformedDelegation(String),
211
212    /// A delegation acknowledgement was built against something that is not a
213    /// delegation grant.
214    #[error("Not a delegation grant: {0}")]
215    NotADelegationGrant(String),
216
217    /// An attenuation attempted to confer more than its parent held.
218    #[error("attenuation would widen the parent grant: {0}")]
219    AttenuationWidens(String),
220
221    /// A StatementCredential under a profile that REQUIRES `taskContext` — `witnessed/1`,
222    /// `vetted/1`, `presented/1`, or an accept-list entry saying so — carries none.
223    #[error("the statement's predicate profile requires taskContext, and it has none")]
224    MissingTaskContext,
225
226    /// A credential that REQUIRES `taskContext` carries it without `taskDigestMultibase`,
227    /// which DTG Core Credentials makes REQUIRED wherever `taskContext` is.
228    #[error("taskContext is required here, and so is taskDigestMultibase, which is absent")]
229    MissingTaskDigest,
230
231    /// `@context` does not list the W3C VC context first and [DTG_CONTEXT_V1] second.
232    #[error("malformed @context: {0}")]
233    InvalidContext(String),
234
235    /// `type` is not `VerifiableCredential`, `DTGCredential` and exactly one concrete
236    /// subtype (with `PersonhoodCredential` permitted as a hint on a VMC only), or names a
237    /// subtype this specification has retired.
238    #[error("malformed type: {0}")]
239    InvalidType(String),
240
241    /// `issuerScope` is narrower than the credential type or predicate profile permits.
242    ///
243    /// A community-issued VMC can only truthfully declare `public`; `witnessed/1`,
244    /// `vetted/1` and `presented/1` require `directed` at minimum.
245    #[error("issuerScope `{declared}` is narrower than the required minimum `{minimum}`")]
246    IssuerScopeTooNarrow {
247        declared: IssuerScope,
248        minimum: IssuerScope,
249    },
250
251    /// A VSC `predicate` is not an absolute IRI in Unicode Normalization Form C.
252    ///
253    /// A compact form (a CURIE such as `dtg:witnessed`, or a bare JSON-LD term) is
254    /// malformed rather than unknown: no expansion is ever performed.
255    #[error("invalid predicate: {0}")]
256    InvalidPredicate(String),
257
258    /// A VSC does not meet a constraint of its predicate's profile: an `object` kind the
259    /// profile does not permit, a REQUIRED member missing, or a subject–object relationship
260    /// that does not hold.
261    #[error("the statement does not meet its predicate profile: {0}")]
262    ProfileViolation(String),
263
264    /// A verifier's accept-list does not contain the statement's predicate.
265    ///
266    /// Rejection is the only conforming outcome for an unrecognized predicate: it is not
267    /// processed as a generic statement, and no published equivalence is followed.
268    #[error("predicate `{0}` is not accepted by this verifier")]
269    PredicateNotAccepted(String),
270
271    /// A registry accept-list document could not be read.
272    #[error("malformed accept-list: {0}")]
273    MalformedAcceptList(String),
274
275    /// A document a credential was to cite as its `taskContext` is not a Trust Task
276    /// document that can be named: it is not a JSON object, or it has no string `id`.
277    #[error("cannot cite this document as a taskContext: {0}")]
278    MalformedTaskDocument(String),
279
280    /// [DTGCredential::new_witnessed_vsc] was given something other than the
281    /// `witness/session` document that opened the witness session.
282    ///
283    /// A VWC names the *innermost* exchange that attests the witnessing (Trust Tasks
284    /// §4.9.1): the party's own `witness/session`, not the `witness/session/submit`
285    /// exchanged on its thread and not the relationship exchange that contains it.
286    #[error("not the witness/session document that opened the session: {0}")]
287    NotAWitnessSession(String),
288
289    /// The credential could not be canonicalized (JCS, RFC 8785) for digesting
290    #[error("Could not canonicalize credential: {0}")]
291    Canonicalization(String),
292
293    /// A credential was not of the type an operation requires
294    #[error("Expected a {expected}, got a {got}")]
295    WrongCredentialType { expected: String, got: String },
296
297    /// A membership acknowledgement was built against something that is not a
298    /// community-issued membership grant
299    #[error("Not a community-issued membership grant: {0}")]
300    NotAMembershipGrant(String),
301
302    /// A credential's `validUntil` is not after its `validFrom`.
303    ///
304    /// A window that closes before, or at the instant, it opens describes a credential
305    /// that is never valid. It is refused where a credential is built or signed rather than
306    /// left for every verifier to notice. A `validFrom` in the past is not refused:
307    /// backdating is how a re-issued credential keeps the date the original took effect.
308    ///
309    /// Compared at whole seconds, the precision the wire form carries.
310    #[error("validUntil {valid_until} is not after validFrom {valid_from}")]
311    InvalidValidityWindow {
312        valid_from: DateTime<Utc>,
313        valid_until: DateTime<Utc>,
314    },
315
316    /// A JSON document was nested more deeply than [`MAX_JSON_DEPTH`] allows.
317    ///
318    /// Digesting, signing and verifying all walk a credential recursively, so a value deep
319    /// enough exhausts the stack and aborts the process. It is refused before any of that
320    /// work starts.
321    #[error("JSON is nested more than {max} levels deep")]
322    JsonTooDeep { max: usize },
323
324    /// A grant names a different party as its subject from the one answering it.
325    ///
326    /// Returned by [DTGCredential::new_member_vmc_for] and
327    /// [DTGCredential::new_delegate_vdc_for]. A party answers a grant for itself, so a grant
328    /// naming anyone else is refused rather than answered in that party's name.
329    #[error("the grant names `{found}` as its subject, not `{expected}`")]
330    NotTheGrantSubject { expected: String, found: String },
331
332    /// An acknowledgement or acceptance would remain valid after the grant it answers.
333    ///
334    /// `valid_until` is `None` where the answer was open-ended against a grant that expires.
335    #[error("would remain valid after the grant it answers, which expires at {grant_valid_until}")]
336    OutlivesGrant {
337        valid_until: Option<DateTime<Utc>>,
338        grant_valid_until: DateTime<Utc>,
339    },
340
341    /// A proof verified, but was made with a verification method that does not belong to
342    /// the credential's issuer.
343    #[error("the proof was made by `{verification_method}`, which is not the issuer `{issuer}`")]
344    ProofNotFromIssuer {
345        issuer: String,
346        verification_method: String,
347    },
348
349    /// A credential was not in force at the instant it was checked against.
350    #[error("the credential is not valid at {at}")]
351    NotValidAt { at: DateTime<Utc> },
352
353    /// A credential in its wire form lacks a member it needs, or carries one that cannot be
354    /// read.
355    #[error("malformed credential: {0}")]
356    MalformedCredential(String),
357}
358
359/// Defined DTG Credentials
360#[derive(Serialize, Deserialize, Debug, Clone)]
361#[serde(try_from = "Value")]
362pub struct DTGCredential {
363    /// The DTG Credential inner struct
364    #[serde(flatten)]
365    credential: DTGCommon,
366
367    /// Type of the credential
368    #[serde(skip)]
369    type_: DTGCredentialType,
370
371    /// W3C VC Version
372    #[serde(skip)]
373    version: W3CVCVersion,
374}
375
376impl DTGCredential {
377    /// get the raw credential
378    pub fn credential(&self) -> &DTGCommon {
379        &self.credential
380    }
381
382    /// Get the raw credential as mutable
383    pub fn credential_mut(&mut self) -> &mut DTGCommon {
384        &mut self.credential
385    }
386
387    /// Has this credential been signed?
388    pub fn signed(&self) -> bool {
389        self.credential.signed()
390    }
391
392    /// get the credential type
393    pub fn type_(&self) -> DTGCredentialType {
394        self.type_.clone()
395    }
396
397    /// This credential's own identifier, if it has one.
398    ///
399    /// `None` for a credential built by one of the `new_*` constructors and never given one
400    /// with [DTGCredential::with_id]. See [DTGCommon::id] for why a counterparty may require
401    /// it.
402    pub fn id(&self) -> Option<&str> {
403        self.credential.id()
404    }
405
406    /// Returns the Issuer DID
407    pub fn issuer(&self) -> &str {
408        self.credential.issuer()
409    }
410
411    /// The correlation scope the issuer declares for its own identifier. See [IssuerScope].
412    pub fn issuer_scope(&self) -> IssuerScope {
413        self.credential.issuer_scope
414    }
415
416    /// The statement, when this credential is a VSC. See [DTGCommon::statement].
417    pub fn statement(&self) -> Option<&CredentialSubjectStatement> {
418        self.credential.statement()
419    }
420
421    /// The `predicate` IRI, when this credential is a VSC.
422    ///
423    /// Compare it byte for byte — [PredicateAcceptList] does — and never by spelling,
424    /// prefix or a published equivalence.
425    pub fn predicate(&self) -> Option<&str> {
426        self.statement().map(|s| s.predicate.as_str())
427    }
428
429    /// Returns the Subject DID
430    pub fn subject(&self) -> &str {
431        self.credential.subject()
432    }
433
434    /// Returns the valid_from timestamp
435    pub fn valid_from(&self) -> DateTime<Utc> {
436        self.credential.valid_from()
437    }
438
439    /// Returns the valid until timestamp
440    pub fn valid_until(&self) -> Option<DateTime<Utc>> {
441        self.credential.valid_until()
442    }
443
444    /// The `id` naming the trust task exchange this credential cites, if set. See
445    /// [DTGCommon::task_context].
446    ///
447    /// This is always `Some` for a VSC under a predicate profile that makes `taskContext`
448    /// REQUIRED — [WITNESSED_V1], [VETTED_V1] and [PRESENTED_V1].
449    pub fn task_context(&self) -> Option<&str> {
450        self.credential.task_context()
451    }
452
453    /// The task digest of the Trust Task document `taskContext` names, if set. See
454    /// [DTGCommon::task_digest_multibase].
455    pub fn task_digest_multibase(&self) -> Option<&str> {
456        self.credential.task_digest_multibase()
457    }
458
459    /// Does this credential cite `document` — the Trust Task document its `taskContext`
460    /// names — and is it bound to that document's content?
461    ///
462    /// Both halves of the citation have to hold:
463    ///
464    /// 1. `taskContext` equals the document's `id`, which **locates** the exchange;
465    /// 2. `taskDigestMultibase` matches the task digest recomputed from `document`, which
466    ///    **binds** the credential to it.
467    ///
468    /// Returns `Ok(false)` where either fails, and where the credential carries no
469    /// `taskContext` or no `taskDigestMultibase`. The last case is deliberate: Trust Tasks
470    /// §4.9.3 forbids falling back to comparing `id`s alone, because an `id` is a name
471    /// anyone may reuse on a counterfeit.
472    ///
473    /// # Compares bytes, not strings
474    ///
475    /// The digest is recomputed with the top-level `proof` removed, so a signed and an
476    /// unsigned copy of the same document agree, and compared as **decoded multihash bytes**.
477    /// A task digest may be base58btc or base64url: two conforming encodings of one digest
478    /// are different strings, and a string comparison would reject an honest citation.
479    ///
480    /// # What this does not check
481    ///
482    /// That the exchange completed, which needs the outcome evidence of DTG Core
483    /// Credentials §Outcome Interpretability, and that the document was attributable, which
484    /// needs its own proof. A task digest attests content, not authenticity. It is
485    /// load-bearing because it is the credential's issuer who signed it.
486    ///
487    /// # Errors
488    ///
489    /// [DTGCredentialError::InvalidDigest] if the carried value is not a well-formed
490    /// multibase multihash, and [DTGCredentialError::UnsupportedDigestAlgorithm] if it
491    /// names a hash this library does not implement. Trust Tasks §4.9.3 requires such a
492    /// citation to be treated as unverified, never recomputed under another algorithm, so
493    /// it is reported rather than folded into `Ok(false)`. [DTGCredentialError::JsonTooDeep]
494    /// if `document` is nested past [`MAX_JSON_DEPTH`].
495    pub fn cites_task(&self, document: &Value) -> Result<bool, DTGCredentialError> {
496        let (Some(task_context), Some(carried)) =
497            (self.task_context(), self.task_digest_multibase())
498        else {
499            return Ok(false);
500        };
501
502        if document.get("id").and_then(Value::as_str) != Some(task_context) {
503            return Ok(false);
504        }
505
506        digests_match(carried, &task_digest_multibase_json(document)?)
507    }
508
509    /// This credential's digest, in the encoding a credential that references it carries —
510    /// a member-issued VMC acknowledging a membership grant, a VSC whose `object` names it
511    /// (a VWC attesting an edge credential), or the `parent` of an attenuated VAC.
512    ///
513    /// Per DTG Core Credentials [Digest Encoding], that is the SHA-256 hash of the
514    /// credential's JSON representation **excluding its top-level `proof` member**,
515    /// canonicalized with the JSON Canonicalization Scheme
516    /// ([JCS, RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)), wrapped in a
517    /// `sha2-256` multihash and encoded base58btc with a multibase `z` prefix.
518    ///
519    /// [Digest Encoding]: https://github.com/trustoverip/dtgwg-cred-spec
520    ///
521    /// # Why `proof` is excluded
522    ///
523    /// The digest binds to what the credential *says*, not to a particular signature over
524    /// it. A referencing credential therefore survives a re-proofing of its referent: a
525    /// re-signed grant carrying identical claims still satisfies an acknowledgement made
526    /// against the earlier signature. It also means the digest can be computed before the
527    /// referent is signed, and is stable whichever of its proofs a holder happens to have.
528    ///
529    /// # Prefer the wire form for a credential you received
530    ///
531    /// This digests the model. [`DTGCommon::extra`] carries top-level members this library
532    /// does not model through a round trip, so for most received credentials the two agree
533    /// — but a member *inside* `credentialSubject` that the subject types do not model is
534    /// still not represented. Where you still hold the bytes a counterparty sent, digest
535    /// those with [`digest_multibase_json`].
536    ///
537    /// # Errors
538    ///
539    /// [DTGCredentialError::JsonTooDeep] if an open JSON member takes the credential past
540    /// [`MAX_JSON_DEPTH`], checked before the credential is cloned or serialized.
541    pub fn digest_multibase(&self) -> Result<String, DTGCredentialError> {
542        self.credential.check_depth()?;
543
544        let unsigned = DTGCommon {
545            proof: None,
546            ..self.credential.clone()
547        };
548        let value = serde_json::to_value(&unsigned)
549            .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
550        digest_multibase_json(&value)
551    }
552
553    /// This credential's digest in the superseded `sha256:<hex>` encoding.
554    #[deprecated(
555        since = "0.7.0",
556        note = "Working Draft 02 replaced the `sha256:<hex>` digest with a base58btc \
557                multibase multihash under the property name `digestMultibase`. Use \
558                DTGCredential::digest_multibase. This method will be removed in a future \
559                release."
560    )]
561    pub fn digest(&self) -> Result<String, DTGCredentialError> {
562        self.credential.check_depth()?;
563
564        let unsigned = DTGCommon {
565            proof: None,
566            ..self.credential.clone()
567        };
568        let value = serde_json::to_value(&unsigned)
569            .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
570        #[allow(deprecated)]
571        digest_json(&value)
572    }
573
574    /// The digest this credential carries of the credential it references, if it carries one.
575    ///
576    /// `Some` for a member-issued VMC (which MUST carry one), for a VSC whose `object` is a
577    /// `digestMultibase` (a VWC bound to the edge credential it attests, among them), for an
578    /// attenuated VAC (`authority.parent`), and for a derived or accepting VDC
579    /// (`delegation.parent` / `delegation.accepts`). `None` for a community-issued VMC,
580    /// which MUST omit it, and for a credential that references nothing.
581    pub fn subject_digest(&self) -> Option<&str> {
582        match &self.credential.credential_subject {
583            CredentialSubject::Membership(subject) => subject.digest_multibase.as_deref(),
584            CredentialSubject::Statement(subject) => subject.object.digest_multibase(),
585            CredentialSubject::Authority(subject) => subject.authority.parent.as_deref(),
586            CredentialSubject::Delegation(subject) => subject
587                .delegation
588                .accepts
589                .as_deref()
590                .or(subject.delegation.parent.as_deref()),
591            _ => None,
592        }
593    }
594
595    /// Checks that the digest this credential carries matches the credential it claims to
596    /// reference.
597    ///
598    /// Answers one question only — whether the hashes agree. It does not check that the two
599    /// credentials are of the types the reference requires, nor that their issuers and
600    /// subjects line up. For a membership acknowledgement, [DTGCredential::acknowledges]
601    /// checks all of that together and is what a verifier completing an edge should call.
602    ///
603    /// # Compares bytes, not strings
604    ///
605    /// The specification requires a verifier to decode the multibase envelope and the
606    /// multihash inside it, and to compare the algorithm identifier and the raw digest —
607    /// never the encoded strings. Two equal digests can be written differently, and a
608    /// string comparison would report a mismatch where the credentials agree.
609    ///
610    /// Returns `Ok(false)` if the digests do not match, or if this credential carries no
611    /// digest, in which case there is nothing to rely on.
612    ///
613    /// # Errors
614    ///
615    /// [DTGCredentialError::InvalidDigest] if the carried value is not a well-formed
616    /// `digestMultibase` — a Working Draft 01 `sha256:<hex>` value among them — and
617    /// [DTGCredentialError::UnsupportedDigestAlgorithm] if it names a hash this library
618    /// does not implement. Both are reported rather than folded into `Ok(false)`: a digest
619    /// that cannot be read is not a digest that disagrees.
620    pub fn verify_digest(&self, referenced: &DTGCredential) -> Result<bool, DTGCredentialError> {
621        let Some(carried) = self.subject_digest() else {
622            return Ok(false);
623        };
624
625        digests_match(carried, &referenced.digest_multibase()?)
626    }
627
628    /// Does this member-issued VMC acknowledge `grant`, completing that membership edge?
629    ///
630    /// A membership edge is complete only when both VMCs of the pair exist and are valid:
631    /// the community-issued VMC that grants membership, and the member-issued VMC that
632    /// acknowledges it. This checks everything that binds the two together:
633    ///
634    /// 1. `grant` is a `MembershipCredential` carrying no `digest` — a community-issued grant
635    /// 2. `self` is a `MembershipCredential` carrying one — a member-issued acknowledgement
636    /// 3. the two name the same pair of parties, in mirrored roles: this credential's issuer
637    ///    is the grant's subject, and its subject is the grant's issuer
638    /// 4. the `digest` matches the grant
639    ///
640    /// Returns `Ok(false)` where any of those does not hold, rather than distinguishing
641    /// them: a caller deciding whether an edge is complete has one decision to make, and
642    /// every failing case answers it the same way.
643    ///
644    /// # What this does not check
645    ///
646    /// Neither credential's proof, and neither validity window. Both are the caller's to
647    /// verify — proof verification needs a resolver this crate does not hold, and whether a
648    /// window is current is a question about an instant the caller chooses. An edge is
649    /// complete when both VMCs are *valid* as well as bound, and this covers only the
650    /// binding.
651    ///
652    /// # Security
653    ///
654    /// `Ok(true)` is binding evidence, not membership. A pair binds whether or not anybody
655    /// signed either half: an acknowledgement can be built against a grant the community
656    /// never issued, and this accepts the two together. Before treating an edge as complete,
657    /// verify the grant's proof against the community's key and the acknowledgement's
658    /// against the member's — each made by a verification method of that credential's
659    /// issuer — and check both windows at the instant you care about.
660    /// `verify_grant_with_public_key`, under the `affinidi-signing` feature, does that for
661    /// the grant in its wire form.
662    pub fn acknowledges(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
663        if !matches!(self.type_, DTGCredentialType::Membership)
664            || !matches!(grant.type_, DTGCredentialType::Membership)
665        {
666            return Ok(false);
667        }
668
669        // The grant is the half that MUST omit `digest`; a credential carrying one is an
670        // acknowledgement, and an acknowledgement of an acknowledgement is not an edge.
671        if grant.subject_digest().is_some() {
672            return Ok(false);
673        }
674
675        if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
676            return Ok(false);
677        }
678
679        self.verify_digest(grant)
680    }
681
682    /// Does this delegate-issued VDC accept `grant`, completing that delegation edge?
683    ///
684    /// A delegation edge is complete only when both VDCs exist and are valid: the
685    /// delegator's grant, and the delegate's acceptance of it. This checks everything that
686    /// binds the two together:
687    ///
688    /// 1. `grant` is a `DelegationCredential` carrying `scope` and no `accepts` — a grant
689    /// 2. `self` is a `DelegationCredential` carrying `accepts` — an acceptance
690    /// 3. the two name the same pair of parties in mirrored roles: this credential's issuer
691    ///    is the grant's subject, and its subject is the grant's issuer
692    /// 4. the `accepts` digest matches the grant
693    ///
694    /// Returns `Ok(false)` where any of those does not hold, rather than distinguishing
695    /// them: a caller deciding whether an edge is complete has one decision to make, and
696    /// every failing case answers it the same way.
697    ///
698    /// # What this does not check
699    ///
700    /// Neither credential's proof, neither validity window, and neither's revocation
701    /// status. Nor does it establish that the *delegator* may perform the act in question
702    /// — that is a separate question, asked of the delegator at the time of the act, which
703    /// a VDC moves but never answers. This covers the binding.
704    ///
705    /// # Security
706    ///
707    /// As with [DTGCredential::acknowledges], `Ok(true)` is binding evidence only. Verify
708    /// both proofs, each against a verification method of its own credential's issuer, and
709    /// both windows, before accepting anybody as acting under the delegation.
710    pub fn accepts(&self, grant: &DTGCredential) -> Result<bool, DTGCredentialError> {
711        if !matches!(self.type_, DTGCredentialType::Delegation)
712            || !matches!(grant.type_, DTGCredentialType::Delegation)
713        {
714            return Ok(false);
715        }
716
717        let (Some(acceptance), Some(appointment)) =
718            (self.credential.delegation(), grant.credential.delegation())
719        else {
720            return Ok(false);
721        };
722
723        // The grant is the half carrying `scope` and no `accepts`; accepting an acceptance
724        // is not an edge.
725        if appointment.accepts.is_some() || appointment.scope.is_none() {
726            return Ok(false);
727        }
728        let Some(carried) = &acceptance.accepts else {
729            return Ok(false);
730        };
731
732        if self.issuer() != grant.subject() || self.subject() != grant.issuer() {
733            return Ok(false);
734        }
735
736        digests_match(carried, &grant.digest_multibase()?)
737    }
738
739    /// Returns the proof value if signed else None
740    pub fn proof_value(&self) -> Option<&str> {
741        if let Some(proof) = &self.credential.proof {
742            proof.proof_value.as_deref()
743        } else {
744            None
745        }
746    }
747
748    /// Checks the invariants this library holds a credential to before putting a proof on
749    /// it.
750    ///
751    /// - The validity window is well formed: `validUntil`, where present, is after
752    ///   `validFrom` ([DTGCredentialError::InvalidValidityWindow]).
753    /// - No open JSON member — a statement's `object.value`, `credentialStatus`, an
754    ///   unmodelled member — takes the document past [`MAX_JSON_DEPTH`]
755    ///   ([DTGCredentialError::JsonTooDeep]). The check does not recurse.
756    /// - Everything a parse checks still holds: the `@context` and `type` arrays, the
757    ///   subject's shape for the type, `issuerScope` where the type or profile constrains it,
758    ///   a VSC's `predicate`, and the constraints of its core profile — `taskContext` and
759    ///   `taskDigestMultibase` where [WITNESSED_V1], [VETTED_V1] or [PRESENTED_V1] require
760    ///   them. [DTGCredential::credential_mut] can break any of these, and this is where a
761    ///   broken credential is caught before it is signed.
762    ///
763    /// [DTGCredential::sign] calls this first, so this library never signs a credential
764    /// that fails it, and [DTGCredential::verify_proof_with_public_key] calls it before
765    /// examining a proof. The `new_*` constructors that return a plain `Self` have no way to
766    /// refuse, so a credential built by one of them is checked here rather than there. If
767    /// you sign with another backend, call this yourself before you do.
768    ///
769    /// A `validFrom` in the past is accepted. Backdating is legitimate — re-issuing a
770    /// credential with the date the original took effect is the usual case — so only the
771    /// ordering of the two ends is checked, never either end against the clock.
772    pub fn validate(&self) -> Result<(), DTGCredentialError> {
773        crate::create::check_window(self.valid_from(), self.valid_until())?;
774        self.credential.check_depth()?;
775        self.check_conformance()
776    }
777
778    /// The structural rules a parse enforces, re-run over the model.
779    ///
780    /// Shared by `TryFrom<DTGCommon>` and [DTGCredential::validate], so that a credential
781    /// this library would refuse to parse is also one it refuses to sign.
782    fn check_conformance(&self) -> Result<(), DTGCredentialError> {
783        let c = &self.credential;
784        check_context(&c.context)?;
785        if check_type(&c.type_)? != self.type_ {
786            return Err(DTGCredentialError::InvalidType(format!(
787                "the type array no longer names a {}",
788                self.type_
789            )));
790        }
791
792        match (&self.type_, &c.credential_subject) {
793            (DTGCredentialType::Membership, CredentialSubject::Membership(subject)) => {
794                // A community's own identifier can only truthfully be declared `public`: a
795                // community that cannot be found cannot be joined. The grant is the half
796                // with no digest.
797                if subject.digest_multibase.is_none() && c.issuer_scope != IssuerScope::Public {
798                    return Err(DTGCredentialError::IssuerScopeTooNarrow {
799                        declared: c.issuer_scope,
800                        minimum: IssuerScope::Public,
801                    });
802                }
803            }
804            (
805                DTGCredentialType::Relationship
806                | DTGCredentialType::Invitation
807                | DTGCredentialType::Persona,
808                CredentialSubject::Basic(_),
809            ) => {}
810            (DTGCredentialType::Statement, CredentialSubject::Statement(subject)) => {
811                statement::check_statement(c, subject)?;
812            }
813            (DTGCredentialType::Authority, CredentialSubject::Authority(subject)) => {
814                if subject.authority.actions.is_empty() {
815                    // Emptiness is never a wildcard. Refusing here means a caller cannot
816                    // construct one by deserialization either.
817                    return Err(DTGCredentialError::EmptyAuthorityActions);
818                }
819            }
820            (DTGCredentialType::Delegation, CredentialSubject::Delegation(subject)) => {
821                check_delegation_shape(&subject.delegation)?;
822            }
823            _ => return Err(DTGCredentialError::UnknownCredential),
824        }
825        Ok(())
826    }
827
828    #[cfg(feature = "affinidi-signing")]
829    /// Sign the credential using W3C Data Integrity Proof with JCS EdDSA 2022
830    /// signing_secret: The secret key to use to sign the credential
831    /// create_time: Optional creation time for the proof, defaults to now if None
832    ///
833    /// # Errors
834    ///
835    /// Anything [DTGCredential::validate] refuses, before any signing is attempted.
836    pub async fn sign(
837        &mut self,
838        signing_secret: &Secret,
839        create_time: Option<DateTime<Utc>>,
840    ) -> Result<DataIntegrityProof, DTGCredentialError> {
841        self.validate()?;
842
843        let mut options = SignOptions::new();
844        if let Some(ts) = create_time {
845            options = options.with_created(ts);
846        }
847
848        let proof = DataIntegrityProof::sign(self, signing_secret, options).await?;
849
850        self.credential.proof = Some(proof.clone());
851        Ok(proof)
852    }
853
854    #[cfg(feature = "affinidi-signing")]
855    /// Verify the credential if you already know the public key bytes
856    /// otherwise use the affinidi_tdk:verify_data() method
857    /// public_key_bytes: The public key bytes to use to verify the credential
858    ///
859    /// # Errors
860    ///
861    /// Anything [DTGCredential::validate] refuses, before the proof is examined: a
862    /// credential this library would not have signed does not verify either.
863    pub fn verify_proof_with_public_key(
864        &self,
865        public_key_bytes: &[u8],
866    ) -> Result<(), DTGCredentialError> {
867        self.validate()?;
868
869        let proof = if let Some(proof) = &self.credential.proof {
870            proof.clone()
871        } else {
872            use tracing::warn;
873
874            warn!("Trying to verify a DTG Credential that has no proof");
875            return Err(DTGCredentialError::NotSigned);
876        };
877
878        let unsigned = DTGCommon {
879            proof: None,
880            ..self.credential.clone()
881        };
882
883        proof.verify_with_public_key(&unsigned, public_key_bytes, VerifyOptions::new())?;
884        Ok(())
885    }
886
887    /// Is this credential a W3C VC Version 1.1 or 2.0 credential?
888    pub fn get_w3c_vc_version(&self) -> W3CVCVersion {
889        self.version
890    }
891
892    /// returns true if this credential a personhood credential (PHC)
893    pub fn is_personhood_credential(&self) -> bool {
894        if let DTGCredentialType::Membership = self.type_ {
895            self.credential
896                .type_
897                .contains(&"PersonhoodCredential".to_string())
898        } else {
899            false
900        }
901    }
902}
903
904/// The `sha2-256` multihash code, per the [multicodec] table.
905///
906/// [multicodec]: https://www.w3.org/TR/cid-1.0/#multihash
907const MULTIHASH_SHA2_256: u64 = 0x12;
908
909/// The deepest JSON document this library will digest, sign or verify.
910///
911/// Depth counts from the top of the credential: the document itself is depth 1, and each
912/// value inside an object or array is one deeper than its container. A VSC's `object.value`
913/// therefore sits at depth 4, and a top-level member such as `credentialStatus` at depth 2.
914///
915/// # Why there is a bound
916///
917/// Digesting, signing and verifying clone, serialize and canonicalize a credential, and each
918/// of those recurses once per level of nesting. A value nested a few thousand levels deep
919/// exhausts the stack, and a stack overflow aborts the process — it is not an error a caller
920/// can handle. The members this library holds as open JSON are where such a value gets in:
921/// a VSC's `object.value` and unmodelled subject members, `credentialStatus`, and the
922/// unmodelled members in [`DTGCommon::extra`].
923///
924/// # Why this value
925///
926/// `serde_json` already refuses to parse JSON nested 128 levels deep, so a credential that
927/// arrived over the wire is bounded before it gets here. 64 stays well under that — nothing
928/// this library signs is too deep for a stock verifier to parse back — and is still far more
929/// than any credential in the specification needs.
930///
931/// # What it cannot do
932///
933/// A `serde_json::Value` is dropped recursively as well. A caller already holding a value
934/// deep enough to overflow the stack will overflow it when that value goes out of scope,
935/// whatever this library returns. The parser is the real boundary: `serde_json` applies its
936/// limit by default, so leave it on.
937pub const MAX_JSON_DEPTH: usize = 64;
938
939/// Is any value reachable from `roots` deeper than [`MAX_JSON_DEPTH`]?
940///
941/// Each root is paired with the depth it sits at in the enclosing document. The walk keeps
942/// an explicit stack rather than recursing: its job is to refuse a value too deep to process
943/// safely, so it must not be what exhausts the call stack.
944fn exceeds_max_depth<'a>(roots: impl IntoIterator<Item = (&'a Value, usize)>) -> bool {
945    let mut pending: Vec<(&Value, usize)> = roots.into_iter().collect();
946    while let Some((value, depth)) = pending.pop() {
947        if depth > MAX_JSON_DEPTH {
948            return true;
949        }
950        match value {
951            Value::Array(items) => pending.extend(items.iter().map(|item| (item, depth + 1))),
952            Value::Object(members) => {
953                pending.extend(members.values().map(|member| (member, depth + 1)))
954            }
955            _ => {}
956        }
957    }
958    false
959}
960
961/// Refuses a JSON document nested more deeply than [`MAX_JSON_DEPTH`].
962pub(crate) fn check_json_depth(doc: &Value) -> Result<(), DTGCredentialError> {
963    if exceeds_max_depth([(doc, 1)]) {
964        Err(DTGCredentialError::JsonTooDeep {
965            max: MAX_JSON_DEPTH,
966        })
967    } else {
968        Ok(())
969    }
970}
971
972/// Strips a credential's top-level `proof` member, if it has one.
973fn proofless(doc: &Value) -> Value {
974    match doc {
975        Value::Object(members) => {
976            let mut members = members.clone();
977            members.remove("proof");
978            Value::Object(members)
979        }
980        // Not an object: canonicalize as-is. A shape check belongs to the caller, which
981        // has a better error to give than this would.
982        other => other.clone(),
983    }
984}
985
986/// The digest a DTG credential carries of another credential, computed over that
987/// credential in its **wire form**.
988///
989/// This is the encoding DTG Core Credentials calls `digestMultibase`, and every
990/// cross-credential reference in the specification uses it: the member-issued VMC's
991/// `digestMultibase` of the grant it acknowledges, a VSC's `object.digestMultibase` (a
992/// VWC's, of the edge credential it attests), an attenuated VAC's `authority.parent`, and a
993/// VDC's `delegation.parent` and `delegation.accepts`.
994///
995/// Four steps, per [CID v1.0](https://www.w3.org/TR/cid-1.0/):
996///
997/// 1. canonicalize `doc` with its top-level `proof` member removed, using JCS (RFC 8785);
998/// 2. SHA-256 the resulting UTF-8 bytes;
999/// 3. prefix the `sha2-256` multihash header (`0x12`) and the length (`0x20`);
1000/// 4. encode base58btc with the multibase `z` prefix.
1001///
1002/// # Digest what you received, not what you parsed
1003///
1004/// Take the document as it arrived. [`DTGCommon::extra`] preserves unmodelled *top-level*
1005/// members through a round trip, but the subject types do not model every member a
1006/// `credentialSubject` may carry, so a parse-then-re-serialise of an unusual credential
1007/// can still differ from the bytes its issuer hashed. Where you hold those bytes, hash
1008/// them.
1009///
1010/// # Why `proof` is excluded
1011///
1012/// The digest binds to what the credential says, not to a signature over it, so a
1013/// reference survives its referent being re-signed. A re-issued credential carries
1014/// different claims and therefore a different digest, which is what makes renewal force
1015/// re-acknowledgement.
1016///
1017/// # Errors
1018///
1019/// [DTGCredentialError::JsonTooDeep] if `doc` is nested more deeply than
1020/// [`MAX_JSON_DEPTH`], checked before anything clones or canonicalizes it.
1021pub fn digest_multibase_json(doc: &Value) -> Result<String, DTGCredentialError> {
1022    check_json_depth(doc)?;
1023
1024    let canonical = serde_json_canonicalizer::to_vec(&proofless(doc))
1025        .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
1026
1027    let digest = Sha256::digest(&canonical);
1028
1029    // multihash prefix: 0x12 = sha2-256, 0x20 = 32 byte digest length. Both are varints,
1030    // and both are single-byte at these values.
1031    let mut multihash = Vec::with_capacity(2 + digest.len());
1032    multihash.push(MULTIHASH_SHA2_256 as u8);
1033    multihash.push(digest.len() as u8);
1034    multihash.extend_from_slice(&digest);
1035
1036    Ok(multibase::encode(Base::Base58Btc, &multihash))
1037}
1038
1039/// The *task digest* of a Trust Task document, the value a credential carries as
1040/// `taskDigestMultibase` alongside the `taskContext` that names the document.
1041///
1042/// Trust Tasks §4.9.3 *Binding a Citation to the Document It Names* defines it as
1043///
1044/// ```text
1045/// taskDigest = multibase( multihash( H( JCS( document ∖ proof ) ) ) )
1046/// ```
1047///
1048/// where `document ∖ proof` removes the **top-level** `proof` only — a `proof` inside
1049/// `payload`, in an embedded presentation or credential, is content and stays. That is
1050/// the computation DTG Core Credentials §Digest Encoding already fixes for every other
1051/// digest-valued member, with a Trust Task document as the input instead of a credential,
1052/// so this is [`digest_multibase_json`] under the name of the question it answers: `H` is
1053/// SHA-256 and the encoding base58btc, the single form an issuer of a DTG credential emits.
1054///
1055/// # Not the digest of the document as it arrived
1056///
1057/// Trust Tasks names two digests over a document, and they differ only in `proof`. The
1058/// task digest asks *what the document says*, so a signed and an unsigned copy have one
1059/// value. A *step digest* asks *which serialization arrived* and includes the `proof` —
1060/// the document identity `idConflict` is keyed on, and what a `witness/session/submit`
1061/// response's `vwcDigestMultibase` is taken over. A function computing one of these must
1062/// never stand in for the other; whichever it picks, it is wrong for the other question.
1063///
1064/// # Errors
1065///
1066/// [DTGCredentialError::JsonTooDeep] if `document` is nested more deeply than
1067/// [`MAX_JSON_DEPTH`].
1068pub fn task_digest_multibase_json(document: &Value) -> Result<String, DTGCredentialError> {
1069    digest_multibase_json(document)
1070}
1071
1072/// Verifies a grant **in its wire form** before it is answered: that its issuer signed it,
1073/// and that it is in force at `at`.
1074///
1075/// Call this on the JSON a community or delegator sent, before passing that JSON to
1076/// [DTGCredential::new_member_vmc_for] or [DTGCredential::new_delegate_vdc_for]. Those
1077/// constructors bind an answer to a grant; they do not establish that anybody signed it.
1078///
1079/// Checks, in order:
1080///
1081/// 1. the document is within [`MAX_JSON_DEPTH`] and carries a `proof`, else
1082///    [DTGCredentialError::JsonTooDeep] or [DTGCredentialError::NotSigned];
1083/// 2. the proof verifies under `public_key` over the document with its top-level `proof`
1084///    removed, else [DTGCredentialError::DataIntegrity];
1085/// 3. the proof's `verificationMethod` belongs to the grant's `issuer` — the DID before its
1086///    `#` fragment is exactly the issuer — else [DTGCredentialError::ProofNotFromIssuer];
1087/// 4. the validity window is well formed and contains `at`, else
1088///    [DTGCredentialError::InvalidValidityWindow] or [DTGCredentialError::NotValidAt].
1089///
1090/// A document with no `issuer` or `validFrom`, or with a timestamp or a single `proof` that
1091/// cannot be read, is [DTGCredentialError::MalformedCredential].
1092///
1093/// # Where `public_key` comes from
1094///
1095/// Resolve it from the issuer's DID document, for the verification method the proof names,
1096/// and confirm that method is authorized for assertion. Step 3 ties the proof to the issuer
1097/// only as far as the key does: a key taken from the grant itself, or from whoever sent it,
1098/// establishes nothing about the issuer.
1099///
1100/// # What this does not check
1101///
1102/// Revocation — a `credentialStatus` entry is not resolved — and the grant's shape beyond
1103/// the members read above. The constructors check the shape.
1104#[cfg(feature = "affinidi-signing")]
1105pub fn verify_grant_with_public_key(
1106    grant: &Value,
1107    public_key: &[u8],
1108    at: DateTime<Utc>,
1109) -> Result<(), DTGCredentialError> {
1110    check_json_depth(grant)?;
1111
1112    let object = grant
1113        .as_object()
1114        .ok_or_else(|| DTGCredentialError::MalformedCredential("not a JSON object".into()))?;
1115    let Some(proof) = object.get("proof") else {
1116        return Err(DTGCredentialError::NotSigned);
1117    };
1118    let proof: DataIntegrityProof = serde_json::from_value(proof.clone())
1119        .map_err(|e| DTGCredentialError::MalformedCredential(format!("unreadable `proof`: {e}")))?;
1120
1121    proof.verify_with_public_key(&proofless(grant), public_key, VerifyOptions::new())?;
1122
1123    let issuer = create::issuer_of(object)
1124        .ok_or_else(|| DTGCredentialError::MalformedCredential("no `issuer`".into()))?;
1125    let method_did = proof
1126        .verification_method
1127        .split_once('#')
1128        .map_or(proof.verification_method.as_str(), |(did, _)| did);
1129    if method_did != issuer {
1130        return Err(DTGCredentialError::ProofNotFromIssuer {
1131            issuer,
1132            verification_method: proof.verification_method,
1133        });
1134    }
1135
1136    let valid_from = create::read_timestamp(object, "validFrom", "issuanceDate")
1137        .map_err(DTGCredentialError::MalformedCredential)?
1138        .ok_or_else(|| DTGCredentialError::MalformedCredential("no `validFrom`".into()))?;
1139    let valid_until = create::read_timestamp(object, "validUntil", "expirationDate")
1140        .map_err(DTGCredentialError::MalformedCredential)?;
1141    create::check_window(valid_from, valid_until)?;
1142    if valid_from > at || valid_until.is_some_and(|until| until < at) {
1143        return Err(DTGCredentialError::NotValidAt { at });
1144    }
1145
1146    Ok(())
1147}
1148
1149/// Decodes a `digestMultibase` value into the algorithm it names and the raw digest bytes.
1150///
1151/// The specification requires verifiers to compare digests this way rather than as
1152/// strings, so that two encodings of the same digest are recognised as equal and an
1153/// algorithm the verifier does not accept is *rejected* rather than reported as a
1154/// mismatch.
1155///
1156/// # Errors
1157///
1158/// [DTGCredentialError::InvalidDigest] if the multibase or multihash envelope is
1159/// malformed, or if the declared length does not match the bytes present.
1160/// [DTGCredentialError::UnsupportedDigestAlgorithm] if the multihash names anything other
1161/// than `sha2-256`.
1162pub fn decode_digest_multibase(digest: &str) -> Result<(u64, Vec<u8>), DTGCredentialError> {
1163    let (_, bytes) = multibase::decode(digest)
1164        .map_err(|e| DTGCredentialError::InvalidDigest(format!("multibase: {e}")))?;
1165
1166    // Both the code and the length are varints. Every algorithm this library accepts has a
1167    // single-byte code and a single-byte length, so a two-byte header is all that is read;
1168    // a continuation bit in either is an algorithm we would reject anyway.
1169    let (&code, rest) = bytes
1170        .split_first()
1171        .ok_or_else(|| DTGCredentialError::InvalidDigest("empty multihash".into()))?;
1172    if code & 0x80 != 0 {
1173        return Err(DTGCredentialError::InvalidDigest(
1174            "multi-byte multihash code, which names no algorithm this library accepts".into(),
1175        ));
1176    }
1177    let (&length, raw) = rest
1178        .split_first()
1179        .ok_or_else(|| DTGCredentialError::InvalidDigest("multihash has no length".into()))?;
1180
1181    if code as u64 != MULTIHASH_SHA2_256 {
1182        return Err(DTGCredentialError::UnsupportedDigestAlgorithm(code as u64));
1183    }
1184    if length as usize != raw.len() {
1185        return Err(DTGCredentialError::InvalidDigest(format!(
1186            "multihash declares {length} bytes but carries {}",
1187            raw.len()
1188        )));
1189    }
1190
1191    Ok((code as u64, raw.to_vec()))
1192}
1193
1194/// Do two `digestMultibase` values refer to the same credential?
1195///
1196/// Decodes both and compares the algorithm and the raw digest bytes, as
1197/// [`decode_digest_multibase`] describes. Never compares the encoded strings.
1198pub fn digests_match(left: &str, right: &str) -> Result<bool, DTGCredentialError> {
1199    Ok(decode_digest_multibase(left)? == decode_digest_multibase(right)?)
1200}
1201
1202/// A credential's digest in the superseded `sha256:<hex>` encoding.
1203#[deprecated(
1204    since = "0.7.0",
1205    note = "Working Draft 02 replaced the `sha256:<hex>` digest with a base58btc multibase \
1206            multihash under the property name `digestMultibase`. Use \
1207            digest_multibase_json. This function will be removed in a future release."
1208)]
1209pub fn digest_json(doc: &Value) -> Result<String, DTGCredentialError> {
1210    check_json_depth(doc)?;
1211
1212    let canonical = serde_json_canonicalizer::to_vec(&proofless(doc))
1213        .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?;
1214
1215    const HEX: &[u8; 16] = b"0123456789abcdef";
1216    let mut out = String::with_capacity("sha256:".len() + 64);
1217    out.push_str("sha256:");
1218    for byte in Sha256::digest(&canonical) {
1219        out.push(HEX[(byte >> 4) as usize] as char);
1220        out.push(HEX[(byte & 0x0f) as usize] as char);
1221    }
1222    Ok(out)
1223}
1224
1225/// DTG VC Type Identifiers — the concrete `DTGCredential` subtypes the v1 context defines.
1226///
1227/// `PartialEq` is derived so that a consumer can assert by equality
1228/// (`assert_eq!(cred.credential_type(), &DTGCredentialType::Delegation)`) rather than by
1229/// pattern (`matches!`), which reports the actual variant on failure.
1230///
1231/// # What is no longer here
1232///
1233/// `EndorsementCredential` and `WitnessCredential` are not DTG types: DTG Core Credentials
1234/// replaced them with the [DTGCredentialType::Statement] type under the [ENDORSES_V1] and
1235/// [WITNESSED_V1] predicate profiles, and a VSC carries its meaning in `predicate` alone so
1236/// that a type string and a predicate can never disagree. `RCardCredential` is not a DTG
1237/// credential at all — the r-card is a verifiable data structure. A credential naming any
1238/// of the three is refused at parse with [DTGCredentialError::InvalidType].
1239#[derive(Debug, Clone, PartialEq, Eq)]
1240#[non_exhaustive]
1241pub enum DTGCredentialType {
1242    /// Verifiable Membership Credential (VMC) — one half of a membership edge.
1243    Membership,
1244
1245    /// Verifiable Relationship Credential (VRC) — one half of a relationship edge.
1246    Relationship,
1247
1248    /// Verifiable Invitation Credential (VIC).
1249    Invitation,
1250
1251    /// Verifiable Persona Credential (VPC).
1252    Persona,
1253
1254    /// Verifiable Statement Credential (VSC) — a signed statement by one node about
1255    /// another, whose meaning is fixed by its `predicate`.
1256    ///
1257    /// The VEC and the VWC are VSCs under the [ENDORSES_V1] and [WITNESSED_V1] profiles.
1258    Statement,
1259
1260    /// Verifiable Authority Credential (VAC) — confers authority on a party to perform
1261    /// specified actions within a named scope governed by the issuer.
1262    ///
1263    /// Key control at invocation — a VAC is not a bearer credential — and the
1264    /// `maxAttenuation` ceiling are implemented in [crate::authority::verify_chain].
1265    /// Revocation via `credentialStatus` is a live lookup the caller performs.
1266    Authority,
1267
1268    /// Verifiable Delegation Credential (VDC) — establishes that one entity may act in
1269    /// another's name.
1270    Delegation,
1271}
1272
1273impl DTGCredentialType {
1274    /// The `type` string this subtype is named by on the wire.
1275    pub fn as_str(&self) -> &'static str {
1276        match self {
1277            DTGCredentialType::Membership => "MembershipCredential",
1278            DTGCredentialType::Relationship => "RelationshipCredential",
1279            DTGCredentialType::Invitation => "InvitationCredential",
1280            DTGCredentialType::Persona => "PersonaCredential",
1281            DTGCredentialType::Statement => "StatementCredential",
1282            DTGCredentialType::Authority => "AuthorityCredential",
1283            DTGCredentialType::Delegation => "DelegationCredential",
1284        }
1285    }
1286
1287    /// The subtype a `type` string names, if it names one.
1288    fn from_type_str(type_: &str) -> Option<Self> {
1289        DTG_TYPES.iter().find(|t| t.as_str() == type_).cloned()
1290    }
1291}
1292
1293impl Display for DTGCredentialType {
1294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1295        f.write_str(self.as_str())
1296    }
1297}
1298
1299impl TryFrom<&[String]> for DTGCredentialType {
1300    type Error = DTGCredentialError;
1301
1302    /// The concrete subtype a `type` array names, under the same rules a parse applies: see
1303    /// [DTGCredentialError::InvalidType].
1304    fn try_from(types: &[String]) -> Result<Self, Self::Error> {
1305        check_type(types)
1306    }
1307}
1308
1309/// Every concrete subtype, in the order the v1 context defines them.
1310const DTG_TYPES: [DTGCredentialType; 7] = [
1311    DTGCredentialType::Membership,
1312    DTGCredentialType::Relationship,
1313    DTGCredentialType::Delegation,
1314    DTGCredentialType::Invitation,
1315    DTGCredentialType::Persona,
1316    DTGCredentialType::Statement,
1317    DTGCredentialType::Authority,
1318];
1319
1320/// Type strings earlier drafts used for a DTG subtype, refused with a message naming what
1321/// replaced them rather than as merely unknown.
1322const RETIRED_TYPES: [(&str, &str); 3] = [
1323    (
1324        "EndorsementCredential",
1325        "replaced by StatementCredential under the endorses/1 predicate",
1326    ),
1327    (
1328        "WitnessCredential",
1329        "replaced by StatementCredential under the witnessed/1 predicate",
1330    ),
1331    (
1332        "RCardCredential",
1333        "the r-card is a verifiable data structure, not a DTG credential",
1334    ),
1335];
1336
1337/// The non-authoritative personhood hint a community-issued VMC may carry in `type`.
1338const PERSONHOOD_HINT: &str = "PersonhoodCredential";
1339
1340/// Reads the concrete subtype off a `type` array, refusing anything but
1341/// `VerifiableCredential`, `DTGCredential`, exactly one concrete subtype and — on a VMC
1342/// only — the `PersonhoodCredential` hint.
1343///
1344/// Every other string is refused, retired DTG types and unknown ones alike: a `type` the
1345/// v1 context does not define is one this library cannot say it understood.
1346fn check_type(types: &[String]) -> Result<DTGCredentialType, DTGCredentialError> {
1347    let (mut vc, mut dtg, mut personhood) = (false, false, false);
1348    let mut concrete: Option<DTGCredentialType> = None;
1349
1350    for type_ in types {
1351        let seen = match type_.as_str() {
1352            "VerifiableCredential" => std::mem::replace(&mut vc, true),
1353            "DTGCredential" => std::mem::replace(&mut dtg, true),
1354            PERSONHOOD_HINT => std::mem::replace(&mut personhood, true),
1355            other => match DTGCredentialType::from_type_str(other) {
1356                Some(found) => match &concrete {
1357                    Some(already) if *already == found => true,
1358                    Some(already) => {
1359                        return Err(DTGCredentialError::InvalidType(format!(
1360                            "names both {already} and {found}; a DTG credential has exactly \
1361                             one concrete subtype"
1362                        )));
1363                    }
1364                    None => {
1365                        concrete = Some(found);
1366                        false
1367                    }
1368                },
1369                None => {
1370                    let reason = RETIRED_TYPES
1371                        .iter()
1372                        .find(|(retired, _)| *retired == other)
1373                        .map_or("not a type the DTG v1 context defines", |(_, why)| why);
1374                    return Err(DTGCredentialError::InvalidType(format!(
1375                        "`{other}`: {reason}"
1376                    )));
1377                }
1378            },
1379        };
1380        if seen {
1381            return Err(DTGCredentialError::InvalidType(format!(
1382                "`{type_}` is listed twice"
1383            )));
1384        }
1385    }
1386
1387    if !vc || !dtg {
1388        return Err(DTGCredentialError::InvalidType(
1389            "must include both `VerifiableCredential` and `DTGCredential`".into(),
1390        ));
1391    }
1392    let concrete = concrete.ok_or(DTGCredentialError::UnknownCredential)?;
1393    if personhood && concrete != DTGCredentialType::Membership {
1394        return Err(DTGCredentialError::InvalidType(format!(
1395            "`{PERSONHOOD_HINT}` is a hint on a MembershipCredential only, not on a {concrete}"
1396        )));
1397    }
1398    Ok(concrete)
1399}
1400
1401/// Reads the W3C VC version off an `@context` array that lists the W3C context first and
1402/// [DTG_CONTEXT_V1] second, refusing any other arrangement.
1403///
1404/// Further contexts — a proof suite's, a community vocabulary's — may follow in any number.
1405fn check_context(context: &[String]) -> Result<W3CVCVersion, DTGCredentialError> {
1406    let version = match context.first().map(String::as_str) {
1407        Some(W3C_VC_V2_CONTEXT) => W3CVCVersion::V2_0,
1408        Some(W3C_VC_V1_CONTEXT) => W3CVCVersion::V1_1,
1409        _ => return Err(DTGCredentialError::UnknownVCVersion),
1410    };
1411    match context.get(1).map(String::as_str) {
1412        Some(DTG_CONTEXT_V1) => Ok(version),
1413        Some(other) => Err(DTGCredentialError::InvalidContext(format!(
1414            "second entry is `{other}`, not `{DTG_CONTEXT_V1}`"
1415        ))),
1416        None => Err(DTGCredentialError::InvalidContext(format!(
1417            "`{DTG_CONTEXT_V1}` is not listed second"
1418        ))),
1419    }
1420}
1421
1422/// Refuses a `delegation` object that is neither a well-formed grant nor a well-formed
1423/// acceptance.
1424fn check_delegation_shape(d: &DelegationGrant) -> Result<(), DTGCredentialError> {
1425    // The two halves are distinguished by `accepts`, and each half has exactly one shape.
1426    match (&d.accepts, &d.scope) {
1427        (Some(_), Some(_)) => Err(DTGCredentialError::MalformedDelegation(
1428            "carries both `accepts` and `scope`: an acceptance consents to the scope of the \
1429             grant it names rather than restating it"
1430                .into(),
1431        )),
1432        (Some(_), None) if d.parent.is_some() || d.max_depth.is_some() => {
1433            Err(DTGCredentialError::MalformedDelegation(
1434                "an acceptance carries `accepts` and nothing else".into(),
1435            ))
1436        }
1437        (Some(_), None) => Ok(()),
1438        (None, Some(scope)) if scope.is_empty() => Err(DTGCredentialError::MalformedDelegation(
1439            "a grant's `scope` MUST contain at least one entry — emptying it is not how an \
1440             unbounded appointment is expressed, because there is no way to express one"
1441                .into(),
1442        )),
1443        (None, Some(_)) => Ok(()),
1444        (None, None) => Err(DTGCredentialError::MalformedDelegation(
1445            "carries neither `scope` nor `accepts`, so it is neither a grant nor an \
1446             acceptance"
1447                .into(),
1448        )),
1449    }
1450}
1451
1452/// All DTG Credentials follow a common structure.
1453#[derive(Serialize, Deserialize, Debug, Clone)]
1454#[serde(rename_all = "camelCase")]
1455pub struct DTGCommon {
1456    /// JSON-LD links to contexts.
1457    ///
1458    /// MUST list [W3C_VC_V2_CONTEXT] first (or, for a v1.1 credential,
1459    /// [W3C_VC_V1_CONTEXT]) and [DTG_CONTEXT_V1] second, followed by any contexts a proof
1460    /// type, a predicate profile or a community vocabulary requires. A later context MUST
1461    /// NOT redefine a term the DTG context protects.
1462    #[serde(rename = "@context")]
1463    pub context: Vec<String>,
1464
1465    /// Credential type identifiers.
1466    ///
1467    /// MUST include `VerifiableCredential`, `DTGCredential` and exactly one concrete
1468    /// subtype. A community-issued VMC MAY add `PersonhoodCredential` as a non-authoritative
1469    /// hint; nothing else is accepted.
1470    #[serde(rename = "type")]
1471    pub type_: Vec<String>,
1472
1473    /// OPTIONAL identifier for this specific credential, per the W3C VC Data Model.
1474    ///
1475    /// When present it MUST be a single URL. A `urn:uuid:` URN is the usual choice for a
1476    /// credential with no dereferenceable home.
1477    ///
1478    /// This is the handle a holder or verifier stores the credential *under*, so it is what
1479    /// makes re-delivery of the same credential idempotent and re-issuance of a different one
1480    /// recognisable as a renewal rather than a duplicate. A counterparty that keys credentials
1481    /// by `id` cannot accept one that has none — so issue with an `id` unless you know nobody
1482    /// on the other side needs it.
1483    ///
1484    /// # Set it before signing
1485    ///
1486    /// A Data Integrity proof covers the credential minus its `proof`, which includes this
1487    /// property. Set it while building — [DTGCredential::with_id] — never after
1488    /// [DTGCredential::sign], which would leave a document whose proof no longer verifies.
1489    #[serde(skip_serializing_if = "Option::is_none", default)]
1490    pub id: Option<String>,
1491
1492    /// DID of the entity issuing this credential
1493    pub issuer: String,
1494
1495    /// The correlation scope the issuer declares for the identifier in `issuer`.
1496    ///
1497    /// REQUIRED: a credential without it, or with any value but `pairwise`, `directed` or
1498    /// `public`, is refused at parse. See [IssuerScope].
1499    pub issuer_scope: IssuerScope,
1500
1501    /// ISO 8601 format of when this credentials become valid from
1502    #[serde(serialize_with = "iso8601_format", alias = "issuanceDate")]
1503    pub valid_from: DateTime<Utc>,
1504
1505    /// ISO 8601 format of when these credentials are valid to
1506    #[serde(serialize_with = "iso8601_format_option")]
1507    #[serde(
1508        skip_serializing_if = "Option::is_none",
1509        alias = "expirationDate",
1510        default
1511    )]
1512    pub valid_until: Option<DateTime<Utc>>,
1513
1514    /// Names the trust task exchange this credential cites: the `id` of the document that
1515    /// initiated the innermost exchange attesting what the credential states (Trust Tasks
1516    /// §4.9.1). For `witness/session` that document's `threadId` is its own `id`, so the
1517    /// earlier description of this member as the exchange's `threadId` gives the same
1518    /// value there; it does not in general, since a `threadId` need not be unique.
1519    ///
1520    /// Carry [`DTGCommon::task_digest_multibase`] with it, which binds the credential to
1521    /// the document this only names.
1522    ///
1523    /// REQUIRED on a VSC whose predicate profile requires it ([WITNESSED_V1], [VETTED_V1],
1524    /// [PRESENTED_V1]), OPTIONAL otherwise. A DTG credential without a `taskContext` MUST be
1525    /// interpretable standing alone, independent of any exchange.
1526    ///
1527    /// NOTE: A verifier MUST NOT interpret a `taskContext`-bearing credential as proof that
1528    /// the associated trust task completed unless the matching trust task outcome evidence is
1529    /// also present and verified.
1530    #[serde(skip_serializing_if = "Option::is_none", default)]
1531    pub task_context: Option<String>,
1532
1533    /// The *task digest* of the Trust Task document [`DTGCommon::task_context`] names.
1534    ///
1535    /// `taskContext` locates the exchange a credential cites; this binds the credential to
1536    /// it. An `id` is only a name, and anyone can write a different document that reuses
1537    /// it, so a verifier pairing a credential with the cited document by `id` alone accepts
1538    /// a counterfeit.
1539    ///
1540    /// Computed as Trust Tasks §4.9.3 *Binding a Citation to the Document It Names* defines
1541    /// a task digest: the document with its **top-level** `proof` removed (a `proof` inside
1542    /// `payload` stays), canonicalized with JCS (RFC 8785), hashed, multihash-tagged and
1543    /// multibase-encoded. An issuer uses `sha2-256` and base58btc, as for every other
1544    /// digest-valued member of DTG Core Credentials. [`task_digest_multibase_json`] computes
1545    /// it; [DTGCredential::with_task_citation] sets it together with `taskContext`.
1546    ///
1547    /// REQUIRED wherever `taskContext` is REQUIRED, and SHOULD accompany it where it is
1548    /// OPTIONAL. A VSC under a profile requiring it is refused at parse without one.
1549    /// [DTGCredential::cites_task] reports a credential without one as citing nothing rather
1550    /// than falling back to comparing `id`s.
1551    #[serde(skip_serializing_if = "Option::is_none", default)]
1552    pub task_digest_multibase: Option<String>,
1553
1554    /// The assertion between the entities involved
1555    pub credential_subject: CredentialSubject,
1556
1557    /// A W3C VC status mechanism through which a verifier determines whether this
1558    /// credential has been revoked.
1559    ///
1560    /// Held as an opaque [`Value`]: the mechanism is chosen by the governing VTC or VTN,
1561    /// and this library neither selects one nor resolves it. `BitstringStatusListEntry` is
1562    /// the common choice.
1563    ///
1564    /// CONDITIONAL on a VDC — REQUIRED where the appointment outlives the freshness window
1565    /// the governing party defines for delegations, and permitted to be absent otherwise,
1566    /// with short validity and re-issuance preferred wherever the delegator is reachable.
1567    /// A status check is a live lookup that reveals the verification event to whoever
1568    /// hosts the status list.
1569    ///
1570    /// # Modelled so that digests survive a round trip
1571    ///
1572    /// Every VMC issued against a status list carries this, and before it was modelled a
1573    /// parse-then-re-serialise dropped it silently — producing a digest its issuer would
1574    /// not recognise. See [`DTGCommon::extra`], which closes the same gap for members this
1575    /// library does not name at all.
1576    #[serde(skip_serializing_if = "Option::is_none", default)]
1577    pub credential_status: Option<Value>,
1578
1579    /// Cryptographic proof of credential authenticity
1580    #[serde(skip_serializing_if = "Option::is_none", default)]
1581    pub proof: Option<DataIntegrityProof>,
1582
1583    /// Top-level members this library does not model, preserved verbatim.
1584    ///
1585    /// A DTG credential may legitimately carry properties beyond the ones named here —
1586    /// `credentialSchema`, `termsOfUse`, `evidence`, an extension a governing party
1587    /// defines. Without somewhere to keep them, a parse-then-re-serialise round trip drops
1588    /// them, and the digest computed over the result matches nothing the issuer signed.
1589    ///
1590    /// Capturing them makes [DTGCredential::digest_multibase] agree with
1591    /// [`digest_multibase_json`] over the wire form for any credential whose extra members
1592    /// are top-level. It is not a complete answer — the `credentialSubject` types still
1593    /// reject members they do not model — so where you hold the bytes a counterparty sent,
1594    /// hashing those remains the safe habit.
1595    #[serde(flatten)]
1596    pub extra: serde_json::Map<String, Value>,
1597}
1598
1599impl DTGCommon {
1600    /// Has this credential been signed?
1601    /// Returns true if a proof exists
1602    /// NOTE: This does NOT validate the proof itself
1603    pub fn signed(&self) -> bool {
1604        self.proof.is_some()
1605    }
1606
1607    /// This credential's own identifier, if it has one. See [DTGCommon::id].
1608    pub fn id(&self) -> Option<&str> {
1609        self.id.as_deref()
1610    }
1611
1612    /// Returns the issuer DID
1613    pub fn issuer(&self) -> &str {
1614        &self.issuer
1615    }
1616
1617    /// The correlation scope the issuer declares for its own identifier.
1618    pub fn issuer_scope(&self) -> IssuerScope {
1619        self.issuer_scope
1620    }
1621
1622    /// Returns the subject DID
1623    pub fn subject(&self) -> &str {
1624        match &self.credential_subject {
1625            CredentialSubject::Basic(subject) => &subject.id,
1626            CredentialSubject::Statement(subject) => &subject.id,
1627            CredentialSubject::Membership(subject) => &subject.id,
1628            CredentialSubject::Authority(subject) => &subject.id,
1629            CredentialSubject::Delegation(subject) => &subject.id,
1630        }
1631    }
1632
1633    /// The statement — `predicate`, `object` and any profile members — when this
1634    /// credential is a VSC.
1635    ///
1636    /// `None` for every other credential type, for the same reason [DTGCommon::authority]
1637    /// is fallible.
1638    pub fn statement(&self) -> Option<&CredentialSubjectStatement> {
1639        match &self.credential_subject {
1640            CredentialSubject::Statement(subject) => Some(subject),
1641            _ => None,
1642        }
1643    }
1644
1645    /// Mutable access to the statement, when this credential is a VSC.
1646    ///
1647    /// Present for the same reason as [DTGCommon::authority_mut]: a verifier must be
1648    /// testable against statements this library's own constructors would refuse to build.
1649    pub fn statement_mut(&mut self) -> Option<&mut CredentialSubjectStatement> {
1650        match &mut self.credential_subject {
1651            CredentialSubject::Statement(subject) => Some(subject),
1652            _ => None,
1653        }
1654    }
1655
1656    /// The `authority` grant, when this credential is a VAC.
1657    ///
1658    /// `None` for every other credential type — the accessor is deliberately fallible
1659    /// rather than panicking, so a caller handed a credential of unknown type can ask
1660    /// without first matching on `type_`.
1661    pub fn authority(&self) -> Option<&AuthorityGrant> {
1662        match &self.credential_subject {
1663            CredentialSubject::Authority(subject) => Some(&subject.authority),
1664            _ => None,
1665        }
1666    }
1667
1668    /// Mutable access to the `authority` grant, when this credential is a VAC.
1669    ///
1670    /// Present so that a caller can construct chains this library's own
1671    /// [DTGCredential::attenuate] would refuse — which is exactly what a verifier must be
1672    /// tested against, since nothing stops another implementation emitting such JSON.
1673    pub fn authority_mut(&mut self) -> Option<&mut AuthorityGrant> {
1674        match &mut self.credential_subject {
1675            CredentialSubject::Authority(subject) => Some(&mut subject.authority),
1676            _ => None,
1677        }
1678    }
1679
1680    /// The `delegation` object, when this credential is a VDC.
1681    ///
1682    /// `None` for every other credential type, for the same reason [DTGCommon::authority]
1683    /// is fallible: a caller handed a credential of unknown type can ask without first
1684    /// matching on `type_`.
1685    pub fn delegation(&self) -> Option<&DelegationGrant> {
1686        match &self.credential_subject {
1687            CredentialSubject::Delegation(subject) => Some(&subject.delegation),
1688            _ => None,
1689        }
1690    }
1691
1692    /// Mutable access to the `delegation` object, when this credential is a VDC.
1693    ///
1694    /// Present for the same reason as [DTGCommon::authority_mut]: a verifier must be
1695    /// testable against chains this library's own constructors would refuse to build,
1696    /// since nothing stops another implementation emitting such JSON.
1697    pub fn delegation_mut(&mut self) -> Option<&mut DelegationGrant> {
1698        match &mut self.credential_subject {
1699            CredentialSubject::Delegation(subject) => Some(&mut subject.delegation),
1700            _ => None,
1701        }
1702    }
1703
1704    /// The credential is valid from this timestamp
1705    pub fn valid_from(&self) -> DateTime<Utc> {
1706        self.valid_from
1707    }
1708
1709    /// The credential is valid until this timestamp, if set
1710    pub fn valid_until(&self) -> Option<DateTime<Utc>> {
1711        self.valid_until
1712    }
1713
1714    /// The `id` naming the trust task exchange this credential cites, if set. See
1715    /// [DTGCommon::task_context].
1716    pub fn task_context(&self) -> Option<&str> {
1717        self.task_context.as_deref()
1718    }
1719
1720    /// The task digest of the document `taskContext` names, if set. See
1721    /// [DTGCommon::task_digest_multibase].
1722    pub fn task_digest_multibase(&self) -> Option<&str> {
1723        self.task_digest_multibase.as_deref()
1724    }
1725
1726    /// Refuses a credential whose open JSON members take the document past
1727    /// [`MAX_JSON_DEPTH`].
1728    ///
1729    /// Every other member is a type this library defines, none more than four levels deep,
1730    /// so the open members are the only place the bound can be crossed.
1731    fn check_depth(&self) -> Result<(), DTGCredentialError> {
1732        // The document is depth 1, so a top-level member sits at 2, a member of
1733        // `credentialSubject` at 3, and `object.value` at 4.
1734        let mut roots: Vec<(&Value, usize)> =
1735            self.extra.values().map(|member| (member, 2)).collect();
1736        if let Some(status) = &self.credential_status {
1737            roots.push((status, 2));
1738        }
1739        if let CredentialSubject::Statement(subject) = &self.credential_subject {
1740            if let Some(value) = subject.object.value() {
1741                roots.push((value, 4));
1742            }
1743            roots.extend(subject.extra.values().map(|member| (member, 3)));
1744        }
1745
1746        if exceeds_max_depth(roots) {
1747            Err(DTGCredentialError::JsonTooDeep {
1748                max: MAX_JSON_DEPTH,
1749            })
1750        } else {
1751            Ok(())
1752        }
1753    }
1754}
1755
1756impl DTGCredential {
1757    /// The skeleton every `new_*` constructor starts from: the two required contexts, the
1758    /// three required types, and the members every credential carries.
1759    ///
1760    /// There is deliberately no `Default` for [DTGCommon]. A default would have to choose
1761    /// an `issuerScope`, and a declaration nobody made is the one thing the member exists to
1762    /// rule out.
1763    pub(crate) fn build(
1764        type_: DTGCredentialType,
1765        issuer: String,
1766        issuer_scope: IssuerScope,
1767        valid_from: DateTime<Utc>,
1768        valid_until: Option<DateTime<Utc>>,
1769        credential_subject: CredentialSubject,
1770    ) -> Self {
1771        DTGCredential {
1772            credential: DTGCommon {
1773                context: vec![W3C_VC_V2_CONTEXT.to_string(), DTG_CONTEXT_V1.to_string()],
1774                type_: vec![
1775                    "VerifiableCredential".to_string(),
1776                    "DTGCredential".to_string(),
1777                    type_.to_string(),
1778                ],
1779                id: None,
1780                issuer,
1781                issuer_scope,
1782                valid_from,
1783                valid_until,
1784                task_context: None,
1785                task_digest_multibase: None,
1786                credential_subject,
1787                credential_status: None,
1788                proof: None,
1789                extra: serde_json::Map::new(),
1790            },
1791            type_,
1792            version: W3CVCVersion::V2_0,
1793        }
1794    }
1795}
1796
1797/// Deserialization: `@context` and `type` are checked on the raw document first, so that a
1798/// credential of a retired type, or under an unrecognized context, is refused with an error
1799/// that says so — rather than with whatever the subject's shape happens to fail on.
1800impl TryFrom<Value> for DTGCredential {
1801    type Error = DTGCredentialError;
1802
1803    fn try_from(value: Value) -> Result<Self, Self::Error> {
1804        let strings = |member: &str| -> Option<Vec<String>> {
1805            value
1806                .get(member)?
1807                .as_array()?
1808                .iter()
1809                .map(|v| v.as_str().map(str::to_string))
1810                .collect()
1811        };
1812        if let Some(context) = strings("@context") {
1813            check_context(&context)?;
1814        }
1815        if let Some(types) = strings("type") {
1816            check_type(&types)?;
1817        }
1818
1819        let common: DTGCommon = serde_json::from_value(value)
1820            .map_err(|e| DTGCredentialError::MalformedCredential(e.to_string()))?;
1821        DTGCredential::try_from(common)
1822    }
1823}
1824
1825/// Post deserialize setup of a CredentialSubject and CredentialType
1826impl TryFrom<DTGCommon> for DTGCredential {
1827    type Error = DTGCredentialError;
1828
1829    fn try_from(value: DTGCommon) -> Result<Self, Self::Error> {
1830        let version = check_context(&value.context)?;
1831        let type_ = check_type(&value.type_)?;
1832
1833        // A VMC's subject is normalized into `Membership`, so a caller matching on the
1834        // subject of a VMC sees one shape rather than two: `{ id }` — the community-issued
1835        // grant — is claimed first by the untagged match as `Basic`. See
1836        // [CredentialSubject::Membership].
1837        let value = match (&type_, value.credential_subject) {
1838            (DTGCredentialType::Membership, CredentialSubject::Basic(subject)) => DTGCommon {
1839                credential_subject: CredentialSubject::Membership(CredentialSubjectMembership {
1840                    id: subject.id,
1841                    digest_multibase: None,
1842                }),
1843                ..value
1844            },
1845            (_, credential_subject) => DTGCommon {
1846                credential_subject,
1847                ..value
1848            },
1849        };
1850
1851        let credential = DTGCredential {
1852            credential: value,
1853            type_,
1854            version,
1855        };
1856        credential.check_conformance()?;
1857        Ok(credential)
1858    }
1859}
1860
1861/// This correctly formats timestamps into the correct iso8601 specification for W3C Verifiable
1862/// Credentials
1863fn iso8601_format<S>(timestamp: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error>
1864where
1865    S: Serializer,
1866{
1867    s.serialize_str(
1868        timestamp
1869            .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1870            .as_str(),
1871    )
1872}
1873
1874fn iso8601_format_option<S>(timestamp: &Option<DateTime<Utc>>, s: S) -> Result<S::Ok, S::Error>
1875where
1876    S: Serializer,
1877{
1878    if let Some(timestamp) = timestamp {
1879        s.serialize_str(
1880            timestamp
1881                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1882                .as_str(),
1883        )
1884    } else {
1885        s.serialize_none()
1886    }
1887}
1888
1889// ****************************************************************************
1890// Credential Subject types
1891// ****************************************************************************
1892// NOTE: The DTG credential spec overloads the JSON attributes for different credential payloads.
1893// The following enum will map the credential subject schema to correct Struct type
1894
1895/// This represents all possible credential subjects
1896/// The order of the enum is important as it will match on first match
1897#[derive(Serialize, Deserialize, Debug, Clone)]
1898#[serde(untagged)]
1899pub enum CredentialSubject {
1900    /// Credential Subject of just `id`
1901    /// Used by a community-issued VMC, and by VRC, VIC and VPC
1902    Basic(CredentialSubjectBasic),
1903
1904    /// Verifiable Statement Credential subject: `id`, `predicate`, `object`.
1905    ///
1906    /// Unambiguous under the untagged match: no other DTG subject carries a `predicate` or
1907    /// an `object`, and both are REQUIRED here. Unmodelled members are kept rather than
1908    /// refused, because a verifier MUST ignore members a profile does not define.
1909    Statement(CredentialSubjectStatement),
1910
1911    /// Verifiable Authority Credential subject.
1912    ///
1913    /// Unambiguous under the untagged match: no other DTG subject carries an `authority`
1914    /// member, and `deny_unknown_fields` keeps a subject that does not have one from
1915    /// landing here.
1916    Authority(CredentialSubjectAuthority),
1917
1918    /// Verifiable Delegation Credential subject.
1919    ///
1920    /// Unambiguous for the same reason as [CredentialSubject::Authority]: `delegation` is
1921    /// carried by no other DTG subject.
1922    Delegation(CredentialSubjectDelegation),
1923
1924    /// Membership Credential subject, carrying the `digestMultibase` that a member-issued
1925    /// VMC MUST set and a community-issued one MUST omit.
1926    ///
1927    /// # `{ id }` lands on `Basic` first
1928    ///
1929    /// The grant's shape is also [CredentialSubject::Basic]'s, which the untagged match
1930    /// takes first; only the credential's `type` says it is a membership. So
1931    /// `TryFrom<DTGCommon> for DTGCredential` re-wraps a `Basic` subject as this one when
1932    /// `type` includes `MembershipCredential`, and a `Membership` subject reaching a matcher
1933    /// has been through that normalization. The acknowledgement's `{ id, digestMultibase }`
1934    /// lands here directly.
1935    Membership(CredentialSubjectMembership),
1936}
1937
1938/// id of the credential subject only
1939#[derive(Serialize, Deserialize, Debug, Clone)]
1940#[serde(deny_unknown_fields)]
1941pub struct CredentialSubjectBasic {
1942    pub id: String,
1943}
1944
1945/// The `authority` object a [CredentialSubject::Authority] carries.
1946///
1947/// # Attenuation
1948///
1949/// A holder may derive a narrower VAC from one they hold without involving the issuer. An
1950/// attenuated VAC sets [AuthorityGrant::parent] to the **digest** of the credential it
1951/// derives from, and MUST NOT widen `actions`, `scope`, or the validity window. Verification walks
1952/// the chain to a VAC issued by the party governing the scope — see
1953/// [crate::authority::verify_chain], which is where the security of this credential
1954/// actually lives. Issuing one is a struct and a signature; refusing a widening link is the
1955/// part that matters.
1956#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1957#[serde(rename_all = "camelCase", deny_unknown_fields)]
1958pub struct AuthorityGrant {
1959    /// The DID or URI the authority applies to.
1960    ///
1961    /// Matched exactly. A verifier rejects a VAC whose `scope` is not the resource being
1962    /// accessed; nothing here implies containment between scopes.
1963    pub scope: String,
1964
1965    /// The permitted actions, from a vocabulary the governing party defines.
1966    ///
1967    /// MUST NOT be empty. An empty list confers nothing — emptiness is never a wildcard,
1968    /// which is the failure mode this rule exists to prevent. Action strings are compared
1969    /// exactly and case-sensitively, and no action implies another: `admin` does not grant
1970    /// `write` unless both are listed.
1971    pub actions: Vec<String>,
1972
1973    /// The **digest** of the VAC this one was attenuated from, as
1974    /// [DTGCredential::digest_multibase] computes it.
1975    ///
1976    /// Absent means this VAC was issued directly by the party governing the scope, and is
1977    /// therefore a chain root.
1978    ///
1979    /// # A digest, not an identifier
1980    ///
1981    /// Working Draft 02 made this deliberate rather than incidental. A digest names
1982    /// nothing that can be fetched, so verification cannot come to depend on network
1983    /// availability, a verifier cannot be induced to make a request against an address of
1984    /// the holder's choosing, and nobody hosting an identifier learns when a credential is
1985    /// used. It also binds an attenuated VAC to the exact claims its issuer narrowed from:
1986    /// re-issuing a parent with different claims does not re-parent the children of the
1987    /// old one, while re-proofing it with identical claims leaves them undisturbed,
1988    /// because the digest excludes `proof`.
1989    #[serde(skip_serializing_if = "Option::is_none", default)]
1990    pub parent: Option<String>,
1991
1992    /// The number of further attenuations permitted below this VAC.
1993    ///
1994    /// `0` prohibits attenuating it at all. **Absence permits** attenuation as far as
1995    /// [crate::authority::MAX_CHAIN_DEPTH] allows — the opposite default from a VDC's
1996    /// `maxDepth`, deliberately: an attenuation only narrows and its subject acts as
1997    /// itself, and forbidding it pushes a holder to lend their own key instead.
1998    ///
1999    /// A VAC attenuated from a parent bearing `n` MUST NOT bear more than `n - 1`, and
2000    /// nothing may lie more than `n` steps below it. Any link may set a lower limit, or set
2001    /// one where its parent set none; none may raise one.
2002    #[serde(skip_serializing_if = "Option::is_none", default)]
2003    pub max_attenuation: Option<u32>,
2004}
2005
2006/// The `delegation` object a [CredentialSubject::Delegation] carries.
2007///
2008/// A VDC is one of a **pair**. The delegator issues a *grant* — carrying `scope`, and
2009/// optionally `parent` and `maxDepth` — and the delegate answers with an *acceptance*
2010/// carrying `accepts` and nothing else. The two together form a complete DTG edge, and a
2011/// verifier MUST have both: a grant alone establishes what the delegator appointed, not
2012/// what the delegate agreed to.
2013///
2014/// # A VDC is not authority
2015///
2016/// It never supplies permission the delegator did not itself hold. A verifier presented
2017/// with one substitutes the delegator for the delegate and then asks the permission
2018/// question it would have asked of the delegator directly — live, at the time of the act.
2019/// The reach of a delegated act is the *intersection* of what the delegator may do and
2020/// what the chain appoints the delegate for. See [AuthorityGrant] for the credential that
2021/// answers the permission question.
2022#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
2023#[serde(rename_all = "camelCase", deny_unknown_fields)]
2024pub struct DelegationGrant {
2025    /// The acts the delegate may perform in the delegator's name.
2026    ///
2027    /// REQUIRED on a grant and MUST contain at least one entry — a VDC MUST NOT express an
2028    /// unbounded appointment by omitting or emptying it. MUST be omitted on an acceptance,
2029    /// which consents to the scope of the grant it names rather than restating it.
2030    ///
2031    /// Entries are opaque strings compared for exact equality. The specification defines no
2032    /// wildcard, prefix or hierarchical semantics, so the subset test on a chain is set
2033    /// inclusion over exact matches; a governing vocabulary that wants structure must put
2034    /// it in the terms themselves.
2035    #[serde(skip_serializing_if = "Option::is_none", default)]
2036    pub scope: Option<Vec<String>>,
2037
2038    /// The digest of the VDC this delegation was derived from, when the delegator is
2039    /// itself acting under a delegation. A VDC with no `parent` is a **root delegation**.
2040    #[serde(skip_serializing_if = "Option::is_none", default)]
2041    pub parent: Option<String>,
2042
2043    /// The number of further re-delegations permitted below this one.
2044    ///
2045    /// `0` prohibits re-delegation, and so does **absence** — the default is a single hop.
2046    /// Setting it above `0` is the delegator's explicit authorisation to re-delegate;
2047    /// there is no other. Note that this is the opposite default from a VAC, where
2048    /// attenuation is permitted unless forbidden: a delegate speaks in the principal's
2049    /// name, so the principal keeps the register of who may do so.
2050    #[serde(skip_serializing_if = "Option::is_none", default)]
2051    pub max_depth: Option<u32>,
2052
2053    /// The digest of the grant being accepted.
2054    ///
2055    /// REQUIRED on an acceptance and MUST be omitted on a grant. Its presence is what
2056    /// distinguishes the two halves of a delegation edge.
2057    #[serde(skip_serializing_if = "Option::is_none", default)]
2058    pub accepts: Option<String>,
2059}
2060
2061/// Delegation Credential subject
2062#[derive(Serialize, Deserialize, Debug, Clone)]
2063#[serde(rename_all = "camelCase", deny_unknown_fields)]
2064pub struct CredentialSubjectDelegation {
2065    /// DID of the delegate on a grant; DID of the delegator on an acceptance.
2066    pub id: String,
2067
2068    /// The appointment itself.
2069    pub delegation: DelegationGrant,
2070}
2071
2072/// Verifiable Authority Credential (VAC) subject.
2073#[derive(Serialize, Deserialize, Debug, Clone)]
2074#[serde(rename_all = "camelCase", deny_unknown_fields)]
2075pub struct CredentialSubjectAuthority {
2076    /// DID of the party receiving the authority.
2077    pub id: String,
2078
2079    /// What the subject may do, and where.
2080    pub authority: AuthorityGrant,
2081}
2082
2083/// Membership Credential subject
2084///
2085/// The two directions of a membership edge share this shape and are told apart by
2086/// `digest`: a community-issued VMC (the membership grant) MUST omit it, and a
2087/// member-issued VMC (the membership acknowledgement) MUST carry it. Where both endpoints
2088/// are community identifiers, as in VTN membership, `digestMultibase` is the only
2089/// discriminator — the issuer and subject rules cannot separate the directions.
2090#[derive(Serialize, Deserialize, Debug, Clone)]
2091#[serde(rename_all = "camelCase", deny_unknown_fields)]
2092pub struct CredentialSubjectMembership {
2093    pub id: String,
2094
2095    /// Digest of the community-issued VMC this acknowledges, as
2096    /// [DTGCredential::digest_multibase] computes it.
2097    ///
2098    /// REQUIRED on the member-issued VMC, and MUST be omitted on the community-issued VMC.
2099    /// `Option` rather than two structs because the same property distinguishes the two
2100    /// directions: a type that could not represent both could not deserialize the pair.
2101    ///
2102    /// Serializes as `digestMultibase`. The Working Draft 01 name `digest` is no longer
2103    /// accepted: a credential issued before the Implementers Draft is not conformant.
2104    #[serde(skip_serializing_if = "Option::is_none", default)]
2105    pub digest_multibase: Option<String>,
2106}
2107
2108/// The `witnessContext` a [WITNESSED_V1] statement MAY carry: context of the witnessing
2109/// event.
2110///
2111/// Every member is OPTIONAL, and the member set is frozen with the predicate version —
2112/// `witnessed/1`'s schema admits no others, so an unknown member is refused. The terms are
2113/// defined in [DTG_CONTEXT_V1].
2114#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
2115#[serde(rename_all = "camelCase", deny_unknown_fields)]
2116pub struct WitnessContext {
2117    /// Human-readable event name
2118    #[serde(skip_serializing_if = "Option::is_none", default)]
2119    pub event: Option<String>,
2120
2121    /// Session or nonce identifier
2122    #[serde(skip_serializing_if = "Option::is_none", default)]
2123    pub session_id: Option<String>,
2124
2125    /// Verification method used
2126    #[serde(skip_serializing_if = "Option::is_none", default)]
2127    pub method: Option<String>,
2128}
2129
2130#[cfg(test)]
2131mod tests {
2132    use crate::{
2133        CredentialSubject, DTG_CONTEXT_V1, DTGCommon, DTGCredential, DTGCredentialError,
2134        DTGCredentialType, ENDORSES_V1, IssuerScope, W3C_VC_V2_CONTEXT, W3CVCVersion, WITNESSED_V1,
2135        check_predicate_iri, check_type, decode_digest_multibase, digest_multibase_json,
2136        digests_match,
2137    };
2138    use chrono::{DateTime, Utc};
2139    use multibase::Base;
2140    use serde_json::Value;
2141    use sha2::{Digest, Sha256};
2142
2143    #[test]
2144    fn test_vmc_vc_1_deserialize() {
2145        // tests deserialize a W3C VC Version 1.1 credential
2146        let vmc: DTGCredential = match serde_json::from_str(
2147            r#"{
2148"@context": [
2149    "https://www.w3.org/2018/credentials/v1",
2150    "https://registry.trustoverip.org/dtg/context/v1",
2151    "https://w3id.org/security/suites/ed25519-2020/v1"
2152  ],
2153  "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2154  "issuer": "did:web:chess-club.example",
2155  "issuerScope": "public",
2156  "issuanceDate": "2026-01-06T10:00:00Z",
2157  "expirationDate": "2027-01-06T10:00:00Z",
2158  "credentialSubject": {
2159    "id": "did:key:z6MkpTHR8VNs..."
2160  }
2161            }"#,
2162        ) {
2163            Ok(vmc) => vmc,
2164            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
2165        };
2166
2167        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
2168        assert!(matches!(
2169            vmc.credential().credential_subject,
2170            CredentialSubject::Membership(_)
2171        ));
2172        assert!(matches!(vmc.version, W3CVCVersion::V1_1));
2173        assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V1_1));
2174    }
2175
2176    #[test]
2177    fn test_missing_w3c_context() {
2178        // tests deserialize a W3C VC Version 1.1 credential
2179        assert!(
2180            serde_json::from_str::<DTGCredential>(
2181                r#"{
2182"@context": [
2183    "https://registry.trustoverip.org/dtg/context/v1",
2184    "https://w3id.org/security/suites/ed25519-2020/v1"
2185  ],
2186  "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
2187  "issuer": "did:web:chess-club.example",
2188  "issuerScope": "public",
2189  "issuanceDate": "2026-01-06T10:00:00Z",
2190  "expirationDate": "2027-01-06T10:00:00Z",
2191  "credentialSubject": {
2192    "id": "did:key:z6MkpTHR8VNs..."
2193  }
2194            }"#,
2195            )
2196            .is_err()
2197        );
2198    }
2199
2200    #[test]
2201    fn test_mutable_credential() {
2202        let mut vmc = DTGCredential::new_vmc(
2203            "did:example:issuer".to_string(),
2204            "did:example:subject".to_string(),
2205            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2206                .unwrap()
2207                .with_timezone(&Utc),
2208            None,
2209            false,
2210        );
2211
2212        let cred = vmc.credential_mut();
2213        cred.type_.push("PersonhoodCredential".to_string());
2214        assert!(vmc.is_personhood_credential());
2215    }
2216
2217    #[test]
2218    fn test_vmc_deserialize() {
2219        let vmc: DTGCredential = match serde_json::from_str(
2220            r#"{
2221                "@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
2222                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
2223                "issuer": "did:example:community",
2224                "issuerScope": "public",
2225                "validFrom": "2024-06-18T10:00:00Z",
2226                "credentialSubject": { "id": "did:example:rDid" }
2227            }"#,
2228        ) {
2229            Ok(vmc) => vmc,
2230            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
2231        };
2232
2233        assert!(!vmc.is_personhood_credential());
2234        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
2235        assert!(matches!(
2236            vmc.credential().credential_subject,
2237            CredentialSubject::Membership(_)
2238        ));
2239        assert!(matches!(vmc.get_w3c_vc_version(), W3CVCVersion::V2_0));
2240    }
2241
2242    #[test]
2243    fn test_vmc_phc_deserialize() {
2244        let vmc: DTGCredential = match serde_json::from_str(
2245            r#"{
2246                "@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
2247                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential", "PersonhoodCredential"],
2248                "issuer": "did:example:community",
2249                "issuerScope": "public",
2250                "validFrom": "2024-06-18T10:00:00Z",
2251                "credentialSubject": { "id": "did:example:rDid" }
2252            }"#,
2253        ) {
2254            Ok(vmc) => vmc,
2255            Err(e) => panic!("Couldn't deserialize VMC: {}", e),
2256        };
2257
2258        assert!(vmc.is_personhood_credential());
2259        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
2260        assert!(matches!(
2261            vmc.credential().credential_subject,
2262            CredentialSubject::Membership(_)
2263        ));
2264    }
2265
2266    #[test]
2267    fn test_vrc_deserialize() {
2268        let vrc: DTGCredential = match serde_json::from_str(
2269            r#"{
2270                "@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
2271                "type": ["VerifiableCredential", "DTGCredential",  "RelationshipCredential"],
2272                "issuer": "did:example:governmentAgencyDid",
2273                "issuerScope": "public",
2274                "validFrom": "2024-06-18T10:00:00Z",
2275                "credentialSubject": { "id": "did:example:citizenRDid" }
2276            }"#,
2277        ) {
2278            Ok(vrc) => vrc,
2279            Err(e) => panic!("Couldn't deserialize VRC: {}", e),
2280        };
2281
2282        assert!(matches!(vrc.type_, DTGCredentialType::Relationship));
2283        assert!(matches!(
2284            vrc.credential().credential_subject,
2285            CredentialSubject::Basic(_)
2286        ));
2287    }
2288
2289    #[test]
2290    fn test_vic_deserialize() {
2291        let vic: DTGCredential = match serde_json::from_str(
2292            r#"{
2293                "@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
2294                "type": ["VerifiableCredential", "DTGCredential",  "InvitationCredential"],
2295                "issuer": "did:example:governmentAgencyVicDid",
2296                "issuerScope": "public",
2297                "validFrom": "2024-06-18T10:00:00Z",
2298                "credentialSubject": { "id": "did:example:citizenRDid" }
2299            }"#,
2300        ) {
2301            Ok(vic) => vic,
2302            Err(e) => panic!("Couldn't deserialize VIC: {}", e),
2303        };
2304
2305        assert!(!vic.is_personhood_credential());
2306        assert!(matches!(vic.type_, DTGCredentialType::Invitation));
2307        assert!(matches!(
2308            vic.credential().credential_subject,
2309            CredentialSubject::Basic(_)
2310        ));
2311    }
2312
2313    #[test]
2314    fn test_vpc_deserialize() {
2315        let vpc: DTGCredential = match serde_json::from_str(
2316            r#"{
2317                "@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
2318                "type": ["VerifiableCredential", "DTGCredential",  "PersonaCredential"],
2319                "issuer": "did:example:governmentAgencyDid",
2320                "issuerScope": "public",
2321                "validFrom": "2024-06-18T10:00:00Z",
2322                "credentialSubject": { "id": "did:example:citizenRDid" }
2323            }"#,
2324        ) {
2325            Ok(vpc) => vpc,
2326            Err(e) => panic!("Couldn't deserialize VPC: {}", e),
2327        };
2328
2329        assert!(matches!(vpc.type_, DTGCredentialType::Persona));
2330        assert!(matches!(
2331            vpc.credential().credential_subject,
2332            CredentialSubject::Basic(_)
2333        ));
2334    }
2335
2336    /// A minimal conformant credential of `type_` with `subject`, for the parse tests below.
2337    fn doc(type_: &str, scope: &str, subject: Value) -> Value {
2338        serde_json::json!({
2339            "@context": [W3C_VC_V2_CONTEXT, DTG_CONTEXT_V1],
2340            "type": ["VerifiableCredential", "DTGCredential", type_],
2341            "issuer": "did:example:issuer",
2342            "issuerScope": scope,
2343            "validFrom": "2024-06-18T10:00:00Z",
2344            "credentialSubject": subject,
2345        })
2346    }
2347
2348    fn parse(value: Value) -> Result<DTGCredential, String> {
2349        serde_json::from_value::<DTGCredential>(value).map_err(|e| e.to_string())
2350    }
2351
2352    /// The spec's `dtg:endorses` example parses as a Statement, not as any retired type.
2353    #[test]
2354    fn test_vsc_deserialize() {
2355        let vsc = parse(doc(
2356            "StatementCredential",
2357            "directed",
2358            serde_json::json!({
2359                "id": "did:example:subject",
2360                "predicate": ENDORSES_V1,
2361                "object": { "value": { "type": "SkillEndorsement" } }
2362            }),
2363        ))
2364        .unwrap();
2365
2366        assert_eq!(vsc.type_(), DTGCredentialType::Statement);
2367        assert_eq!(vsc.subject(), "did:example:subject");
2368        assert_eq!(vsc.predicate(), Some(ENDORSES_V1));
2369        assert_eq!(
2370            vsc.statement().unwrap().object.value(),
2371            Some(&serde_json::json!({ "type": "SkillEndorsement" }))
2372        );
2373    }
2374
2375    /// `object` carries exactly one of `id`, `digestMultibase` or `value`.
2376    #[test]
2377    fn test_vsc_object_is_exactly_one_member() {
2378        let with = |object: Value| {
2379            parse(doc(
2380                "StatementCredential",
2381                "directed",
2382                serde_json::json!({
2383                    "id": "did:example:subject",
2384                    "predicate": "https://vtc.example/vocab#p",
2385                    "object": object
2386                }),
2387            ))
2388        };
2389
2390        assert!(with(serde_json::json!({ "id": "did:example:thing" })).is_ok());
2391        assert!(with(serde_json::json!({ "digestMultibase": "zQm" })).is_ok());
2392        assert!(with(serde_json::json!({ "value": null })).is_ok());
2393
2394        assert!(with(serde_json::json!({})).is_err(), "no member");
2395        assert!(
2396            with(serde_json::json!({ "id": "did:example:a", "value": 1 })).is_err(),
2397            "two members"
2398        );
2399        assert!(
2400            with(serde_json::json!({ "other": 1 })).is_err(),
2401            "unknown member"
2402        );
2403        assert!(
2404            with(serde_json::json!({ "id": "not-an-iri" })).is_err(),
2405            "`object.id` is a DID or other IRI"
2406        );
2407    }
2408
2409    /// A verifier MUST ignore subject members a profile does not define, and a round trip
2410    /// must keep them or the digest would change.
2411    #[test]
2412    fn test_vsc_keeps_unmodelled_subject_members() {
2413        let wire = doc(
2414            "StatementCredential",
2415            "directed",
2416            serde_json::json!({
2417                "id": "did:example:subject",
2418                "predicate": "https://vtc.example/vocab#p",
2419                "object": { "value": 1 },
2420                "note": { "added": "by a later profile version" }
2421            }),
2422        );
2423        let vsc = parse(wire.clone()).unwrap();
2424        assert!(vsc.statement().unwrap().extra.contains_key("note"));
2425        assert_eq!(
2426            vsc.digest_multibase().unwrap(),
2427            digest_multibase_json(&wire).unwrap()
2428        );
2429    }
2430
2431    /// A compact form is malformed, not unknown, and no expansion is performed.
2432    #[test]
2433    fn test_vsc_predicate_must_be_an_absolute_nfc_iri() {
2434        for bad in [
2435            "dtg:witnessed",
2436            "witnessed",
2437            "/dtg/vsc/witnessed/1",
2438            "https://registry.trustoverip.org/dtg/vsc/witnessed/1 ",
2439            "https:///no-authority",
2440            // "e" followed by a combining acute accent: renders as "é", is not NFC.
2441            "https://vtc.example/vocab#caf\u{0065}\u{0301}",
2442        ] {
2443            let result = parse(doc(
2444                "StatementCredential",
2445                "public",
2446                serde_json::json!({
2447                    "id": "did:example:subject",
2448                    "predicate": bad,
2449                    "object": { "value": true }
2450                }),
2451            ));
2452            assert!(result.is_err(), "`{bad}` must be refused");
2453        }
2454
2455        for good in [
2456            WITNESSED_V1,
2457            "https://vtc.example/vocab/vetting/v1#vetted",
2458            "https://vtc.example/vocab#caf\u{00e9}",
2459            "urn:example:predicate:1",
2460            "did:example:vocab#term",
2461        ] {
2462            check_predicate_iri(good).unwrap_or_else(|e| panic!("`{good}`: {e}"));
2463        }
2464    }
2465
2466    /// The core profile constraints hold at parse: `witnessed/1` requires the citation and a
2467    /// scope of at least `directed`, and names a credential by digest.
2468    #[test]
2469    fn test_vsc_witnessed_profile_is_enforced_at_parse() {
2470        let witnessed = |scope: &str, cited: bool, object: Value| {
2471            let mut vwc = doc(
2472                "StatementCredential",
2473                scope,
2474                serde_json::json!({
2475                    "id": "did:example:subject",
2476                    "predicate": WITNESSED_V1,
2477                    "object": object,
2478                    "witnessContext": { "method": "in-person-proximity" }
2479                }),
2480            );
2481            if cited {
2482                vwc["taskContext"] = serde_json::json!("urn:uuid:session");
2483                vwc["taskDigestMultibase"] =
2484                    serde_json::json!("zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n");
2485            }
2486            serde_json::from_value::<DTGCredential>(vwc)
2487        };
2488        let digest = serde_json::json!({ "digestMultibase": "zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n" });
2489
2490        let vwc = witnessed("directed", true, digest.clone()).unwrap();
2491        assert_eq!(
2492            vwc.statement()
2493                .unwrap()
2494                .witness_context
2495                .as_ref()
2496                .unwrap()
2497                .method
2498                .as_deref(),
2499            Some("in-person-proximity")
2500        );
2501        assert!(witnessed("public", true, digest.clone()).is_ok());
2502
2503        assert!(witnessed("pairwise", true, digest.clone()).is_err());
2504        assert!(witnessed("directed", false, digest.clone()).is_err());
2505        assert!(witnessed("directed", true, serde_json::json!({ "value": {} })).is_err());
2506
2507        // Without the digest the citation only names the session, which the profile does
2508        // not accept either.
2509        let mut named_only = doc(
2510            "StatementCredential",
2511            "directed",
2512            serde_json::json!({
2513                "id": "did:example:subject",
2514                "predicate": WITNESSED_V1,
2515                "object": digest
2516            }),
2517        );
2518        named_only["taskContext"] = serde_json::json!("urn:uuid:session");
2519        assert!(matches!(
2520            parse_err(named_only),
2521            DTGCredentialError::MissingTaskDigest
2522        ));
2523    }
2524
2525    /// The error a parse fails with, recovered through the model so it can be matched.
2526    fn parse_err(value: Value) -> DTGCredentialError {
2527        let mut common: DTGCommon = serde_json::from_value(value).expect("shape parses");
2528        common.proof = None;
2529        DTGCredential::try_from(common).expect_err("refused")
2530    }
2531
2532    /// Every credential declares `issuerScope`, exactly lowercase.
2533    #[test]
2534    fn test_issuer_scope_is_required_and_case_sensitive() {
2535        let subject = serde_json::json!({ "id": "did:example:subject" });
2536        for scope in ["pairwise", "directed", "public"] {
2537            let vrc = parse(doc("RelationshipCredential", scope, subject.clone())).unwrap();
2538            assert_eq!(vrc.issuer_scope().as_str(), scope);
2539            assert_eq!(scope.parse::<IssuerScope>().unwrap(), vrc.issuer_scope());
2540        }
2541
2542        for scope in ["Public", "PAIRWISE", "community", ""] {
2543            assert!(
2544                parse(doc("RelationshipCredential", scope, subject.clone())).is_err(),
2545                "`{scope}` is not a declaration"
2546            );
2547            assert!(scope.parse::<IssuerScope>().is_err());
2548        }
2549
2550        let mut missing = doc("RelationshipCredential", "pairwise", subject);
2551        missing.as_object_mut().unwrap().remove("issuerScope");
2552        let err = parse(missing).unwrap_err();
2553        assert!(err.contains("issuerScope"), "{err}");
2554    }
2555
2556    /// Narrowest first: a minimum is met by itself and by anything wider.
2557    #[test]
2558    fn test_issuer_scope_is_ordered() {
2559        assert!(IssuerScope::Pairwise < IssuerScope::Directed);
2560        assert!(IssuerScope::Directed < IssuerScope::Public);
2561        assert!(IssuerScope::Public.satisfies(IssuerScope::Directed));
2562        assert!(IssuerScope::Directed.satisfies(IssuerScope::Directed));
2563        assert!(!IssuerScope::Pairwise.satisfies(IssuerScope::Directed));
2564        assert_eq!(
2565            serde_json::to_value(IssuerScope::Directed).unwrap(),
2566            "directed"
2567        );
2568    }
2569
2570    /// A community's grant can only truthfully declare `public`; the member's
2571    /// acknowledgement declares whatever the member chose.
2572    #[test]
2573    fn test_community_issued_vmc_must_be_public() {
2574        let grant = |scope| {
2575            doc(
2576                "MembershipCredential",
2577                scope,
2578                serde_json::json!({ "id": "did:example:member" }),
2579            )
2580        };
2581        assert!(parse(grant("public")).is_ok());
2582        assert!(matches!(
2583            parse_err(grant("directed")),
2584            DTGCredentialError::IssuerScopeTooNarrow {
2585                declared: IssuerScope::Directed,
2586                minimum: IssuerScope::Public,
2587            }
2588        ));
2589
2590        let ack = doc(
2591            "MembershipCredential",
2592            "pairwise",
2593            serde_json::json!({
2594                "id": "did:example:community",
2595                "digestMultibase": "zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n"
2596            }),
2597        );
2598        assert_eq!(parse(ack).unwrap().issuer_scope(), IssuerScope::Pairwise);
2599    }
2600
2601    /// The retired subtypes are refused, by name, rather than read as something else.
2602    #[test]
2603    fn test_retired_types_are_refused() {
2604        for (retired, subject) in [
2605            (
2606                "EndorsementCredential",
2607                serde_json::json!({ "id": "did:example:subject", "endorsement": {} }),
2608            ),
2609            (
2610                "WitnessCredential",
2611                serde_json::json!({ "id": "did:example:subject", "digestMultibase": "zQm" }),
2612            ),
2613            (
2614                "RCardCredential",
2615                serde_json::json!({ "id": "did:example:subject", "card": [] }),
2616            ),
2617        ] {
2618            let mut vc = doc(retired, "public", subject);
2619            vc["taskContext"] = serde_json::json!("urn:uuid:session");
2620            let err = parse(vc).unwrap_err();
2621            assert!(err.contains(retired), "{retired}: {err}");
2622        }
2623    }
2624
2625    /// Exactly one concrete subtype; `PersonhoodCredential` on a VMC only; nothing unknown.
2626    #[test]
2627    fn test_type_array_rules() {
2628        let subject = serde_json::json!({ "id": "did:example:subject" });
2629        let with_types = |types: Value| {
2630            let mut vc = doc("RelationshipCredential", "public", subject.clone());
2631            vc["type"] = types;
2632            parse(vc)
2633        };
2634
2635        assert!(
2636            with_types(serde_json::json!([
2637                "VerifiableCredential",
2638                "DTGCredential",
2639                "RelationshipCredential",
2640                "InvitationCredential"
2641            ]))
2642            .is_err(),
2643            "two concrete subtypes"
2644        );
2645        assert!(
2646            with_types(serde_json::json!([
2647                "VerifiableCredential",
2648                "RelationshipCredential"
2649            ]))
2650            .is_err(),
2651            "no DTGCredential"
2652        );
2653        assert!(
2654            with_types(serde_json::json!([
2655                "VerifiableCredential",
2656                "DTGCredential",
2657                "RelationshipCredential",
2658                "PersonhoodCredential"
2659            ]))
2660            .is_err(),
2661            "the personhood hint belongs on a VMC"
2662        );
2663        assert!(
2664            with_types(serde_json::json!([
2665                "VerifiableCredential",
2666                "DTGCredential",
2667                "RelationshipCredential",
2668                "SomethingElse"
2669            ]))
2670            .is_err(),
2671            "an undefined type"
2672        );
2673        assert!(
2674            with_types(serde_json::json!([
2675                "VerifiableCredential",
2676                "DTGCredential",
2677                "RelationshipCredential",
2678                "RelationshipCredential"
2679            ]))
2680            .is_err(),
2681            "listed twice"
2682        );
2683    }
2684
2685    /// The DTG context is listed second, exactly; the pre-v1 context is not recognized.
2686    #[test]
2687    fn test_context_rules() {
2688        let subject = serde_json::json!({ "id": "did:example:subject" });
2689        let with_context = |context: Value| {
2690            let mut vc = doc("RelationshipCredential", "public", subject.clone());
2691            vc["@context"] = context;
2692            parse(vc)
2693        };
2694
2695        assert!(
2696            with_context(serde_json::json!([
2697                W3C_VC_V2_CONTEXT,
2698                DTG_CONTEXT_V1,
2699                "https://w3id.org/security/suites/ed25519-2020/v1"
2700            ]))
2701            .is_ok(),
2702            "further contexts may follow"
2703        );
2704        for bad in [
2705            serde_json::json!([W3C_VC_V2_CONTEXT]),
2706            serde_json::json!([DTG_CONTEXT_V1, W3C_VC_V2_CONTEXT]),
2707            serde_json::json!([
2708                W3C_VC_V2_CONTEXT,
2709                "https://firstperson.network/credentials/dtg/v1"
2710            ]),
2711            serde_json::json!([
2712                W3C_VC_V2_CONTEXT,
2713                "https://www.registry.trustoverip.org/dtg/context/v1"
2714            ]),
2715            serde_json::json!([
2716                W3C_VC_V2_CONTEXT,
2717                "https://registry.trustoverip.org/dtg/context/v1/"
2718            ]),
2719            serde_json::json!([
2720                W3C_VC_V2_CONTEXT,
2721                "https://w3id.org/security/suites/ed25519-2020/v1",
2722                DTG_CONTEXT_V1
2723            ]),
2724        ] {
2725            assert!(with_context(bad.clone()).is_err(), "{bad}");
2726        }
2727    }
2728
2729    #[test]
2730    fn test_deserialize_unknown() {
2731        match serde_json::from_str::<DTGCredential>(
2732            r#"{
2733                "@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
2734                "type": ["VerifiableCredential", "DTGCredential"],
2735                "issuer": "did:example:governmentAgencyDid",
2736                "issuerScope": "public",
2737                "validFrom": "2024-06-18T10:00:00Z",
2738                "credentialSubject": { "id": "did:example:citizenRDid" }
2739            }"#,
2740        ) {
2741            Ok(_) => panic!("Expected Unknown Credential type"),
2742            Err(e) => assert_eq!(e.to_string(), "Unknown credential type"),
2743        };
2744    }
2745
2746    /// A subject whose shape does not fit the type is refused rather than coerced.
2747    #[test]
2748    fn test_deserialize_mismatched_credential_subject() {
2749        for (type_, subject) in [
2750            (
2751                "StatementCredential",
2752                serde_json::json!({ "id": "did:example:subject" }),
2753            ),
2754            (
2755                "RelationshipCredential",
2756                serde_json::json!({ "id": "did:example:s", "predicate": ENDORSES_V1, "object": { "value": 1 } }),
2757            ),
2758            (
2759                "AuthorityCredential",
2760                serde_json::json!({ "id": "did:example:subject" }),
2761            ),
2762        ] {
2763            assert!(parse(doc(type_, "public", subject)).is_err(), "{type_}");
2764        }
2765    }
2766
2767    #[test]
2768    fn test_proof_signed() {
2769        let cred: DTGCredential = match serde_json::from_str(
2770            r#"{
2771                "@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
2772                "type": ["VerifiableCredential", "DTGCredential",  "MembershipCredential"],
2773                "issuer": "did:example:community",
2774                "issuerScope": "public",
2775                "validFrom": "2024-06-18T10:00:00Z",
2776                "credentialSubject": { "id": "did:example:rDid" },
2777                "proof": {
2778                    "type": "DataIntegrityProof",
2779                    "cryptosuite": "eddsa-jcs-2022",
2780                    "created": "2025-12-04T00:00:00",
2781                    "verificationMethod": "did:example:test#key-1",
2782                    "proofPurpose": "assertionMethod",
2783                    "proofValue": "abcd"
2784                }
2785            }"#,
2786        ) {
2787            Ok(vmc) => vmc,
2788            Err(e) => panic!("Couldn't deserialize credential: {}", e),
2789        };
2790
2791        assert!(cred.signed());
2792        assert!(cred.proof_value().is_some());
2793    }
2794
2795    #[test]
2796    fn test_proof_not_signed() {
2797        let cred = parse(doc(
2798            "MembershipCredential",
2799            "public",
2800            serde_json::json!({ "id": "did:example:rDid" }),
2801        ))
2802        .unwrap();
2803
2804        assert!(!cred.signed());
2805        assert!(cred.proof_value().is_none());
2806    }
2807
2808    #[test]
2809    fn test_helpers() {
2810        let cred = parse(doc(
2811            "MembershipCredential",
2812            "public",
2813            serde_json::json!({ "id": "did:example:subject" }),
2814        ))
2815        .unwrap();
2816
2817        assert_eq!(cred.issuer(), "did:example:issuer");
2818        assert_eq!(cred.issuer_scope(), IssuerScope::Public);
2819        assert_eq!(cred.subject(), "did:example:subject");
2820        assert_eq!(
2821            cred.valid_from()
2822                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2823            "2024-06-18T10:00:00Z"
2824        );
2825        assert_eq!(cred.valid_until(), None);
2826    }
2827
2828    #[test]
2829    fn test_valid_until() {
2830        let mut vc = doc(
2831            "MembershipCredential",
2832            "public",
2833            serde_json::json!({ "id": "did:example:subject" }),
2834        );
2835        vc["validUntil"] = serde_json::json!("2030-01-01T00:00:00Z");
2836        let cred = parse(vc).unwrap();
2837
2838        assert_eq!(
2839            cred.valid_until()
2840                .unwrap()
2841                .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
2842            "2030-01-01T00:00:00Z"
2843        );
2844    }
2845
2846    #[test]
2847    fn test_bad_type() {
2848        assert!(check_type(&["bad_type".to_string()]).is_err());
2849    }
2850
2851    /// A model mutated out of shape is refused by `validate`, so it is never signed.
2852    #[test]
2853    fn test_badly_constructed_credential_is_refused_by_validate() {
2854        let mut cred = DTGCredential::new_vrc(
2855            "did:example:issuer".to_string(),
2856            IssuerScope::Pairwise,
2857            "did:example:subject".to_string(),
2858            Utc::now(),
2859            None,
2860        );
2861        cred.credential_mut().type_[2] = "StatementCredential".to_string();
2862        assert!(cred.validate().is_err());
2863
2864        let mut vmc = DTGCredential::new_vmc(
2865            "did:example:community".to_string(),
2866            "did:example:member".to_string(),
2867            Utc::now(),
2868            None,
2869            false,
2870        );
2871        vmc.credential_mut().issuer_scope = IssuerScope::Pairwise;
2872        assert!(matches!(
2873            vmc.validate(),
2874            Err(DTGCredentialError::IssuerScopeTooNarrow { .. })
2875        ));
2876    }
2877
2878    #[test]
2879    fn test_task_context_round_trip() {
2880        // taskContext must survive deserialize -> serialize, otherwise a credential signed
2881        // elsewhere would fail verification here (and vice versa)
2882        let mut vc = doc(
2883            "RelationshipCredential",
2884            "pairwise",
2885            serde_json::json!({ "id": "did:example:observed" }),
2886        );
2887        vc["taskContext"] = serde_json::json!("thread-abc-123");
2888
2889        let cred = parse(vc).unwrap();
2890        let out = serde_json::to_string(&cred).unwrap();
2891
2892        assert!(out.contains(r#""taskContext":"thread-abc-123""#));
2893    }
2894
2895    #[test]
2896    fn test_task_context_optional_on_other_types() {
2897        // taskContext is OPTIONAL unless a statement's profile requires it
2898        let vrc = parse(doc(
2899            "RelationshipCredential",
2900            "pairwise",
2901            serde_json::json!({ "id": "did:example:subject" }),
2902        ))
2903        .unwrap();
2904
2905        assert_eq!(vrc.task_context(), None);
2906        // and it is omitted from the serialization entirely when absent
2907        assert!(!serde_json::to_string(&vrc).unwrap().contains("taskContext"));
2908
2909        let vec = parse(doc(
2910            "StatementCredential",
2911            "pairwise",
2912            serde_json::json!({
2913                "id": "did:example:subject",
2914                "predicate": ENDORSES_V1,
2915                "object": { "value": "a good egg" }
2916            }),
2917        ))
2918        .unwrap();
2919        assert_eq!(vec.task_context(), None);
2920    }
2921
2922    #[test]
2923    fn test_digest_multibase() {
2924        let vrc = DTGCredential::new_vrc(
2925            "did:example:issuer".to_string(),
2926            IssuerScope::Pairwise,
2927            "did:example:subject".to_string(),
2928            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2929                .unwrap()
2930                .with_timezone(&Utc),
2931            None,
2932        );
2933
2934        let digest = vrc.digest_multibase().unwrap();
2935
2936        // base58btc multibase prefix
2937        assert!(digest.starts_with('z'));
2938
2939        // decodes to a sha2-256 multihash: 0x12 0x20 followed by 32 digest bytes
2940        let (base, bytes) = multibase::decode(&digest).unwrap();
2941        assert_eq!(base, multibase::Base::Base58Btc);
2942        assert_eq!(bytes.len(), 34);
2943        assert_eq!(&bytes[..2], &[0x12, 0x20]);
2944
2945        // stable across calls
2946        assert_eq!(digest, vrc.digest_multibase().unwrap());
2947
2948        // and distinct for a different credential
2949        let other = DTGCredential::new_vrc(
2950            "did:example:issuer".to_string(),
2951            IssuerScope::Pairwise,
2952            "did:example:someone-else".to_string(),
2953            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2954                .unwrap()
2955                .with_timezone(&Utc),
2956            None,
2957        );
2958        assert_ne!(digest, other.digest_multibase().unwrap());
2959    }
2960
2961    #[test]
2962    fn test_verify_digest() {
2963        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
2964            .unwrap()
2965            .with_timezone(&Utc);
2966
2967        let vrc = DTGCredential::new_vrc(
2968            "did:example:issuer".to_string(),
2969            IssuerScope::Pairwise,
2970            "did:example:subject".to_string(),
2971            valid_from,
2972            None,
2973        );
2974
2975        let session = serde_json::json!({
2976            "id": "urn:uuid:session",
2977            "type": "https://trusttasks.org/spec/witness/session/0.1",
2978            "threadId": "urn:uuid:session",
2979        });
2980        let vwc = DTGCredential::new_witnessed_vsc(
2981            "did:example:witness".to_string(),
2982            IssuerScope::Public,
2983            &serde_json::to_value(&vrc).unwrap(),
2984            &session,
2985            valid_from,
2986            None,
2987            None,
2988        )
2989        .unwrap();
2990        // the DID of the issuer of the VRC being attested
2991        assert_eq!(vwc.subject(), "did:example:issuer");
2992
2993        assert!(vwc.verify_digest(&vrc).unwrap());
2994
2995        // a different VRC must not match
2996        let other = DTGCredential::new_vrc(
2997            "did:example:issuer".to_string(),
2998            IssuerScope::Pairwise,
2999            "did:example:someone-else".to_string(),
3000            valid_from,
3001            None,
3002        );
3003        assert!(!vwc.verify_digest(&other).unwrap());
3004    }
3005
3006    #[test]
3007    fn test_verify_digest_without_digest() {
3008        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3009            .unwrap()
3010            .with_timezone(&Utc);
3011
3012        let vrc = DTGCredential::new_vrc(
3013            "did:example:issuer".to_string(),
3014            IssuerScope::Pairwise,
3015            "did:example:subject".to_string(),
3016            valid_from,
3017            None,
3018        );
3019
3020        // A statement whose object is not a digest references nothing to rely on
3021        let vec = DTGCredential::new_endorses_vsc(
3022            "did:example:peer".to_string(),
3023            IssuerScope::Directed,
3024            "did:example:issuer".to_string(),
3025            serde_json::json!({ "skill": "chess" }),
3026            valid_from,
3027            None,
3028        )
3029        .unwrap();
3030        assert_eq!(vec.subject_digest(), None);
3031        let vwc = vec;
3032
3033        assert!(!vwc.verify_digest(&vrc).unwrap());
3034    }
3035
3036    /// The digest encoding is the interoperability surface: a credential referencing another
3037    /// is compared against a value some other implementation produced. Pinned against a
3038    /// literal rather than a recomputation, because a test that recomputes agrees with
3039    /// whatever the code does and would follow the encoding silently if it drifted.
3040    #[test]
3041    fn test_digest_is_a_base58btc_multihash_over_the_proofless_jcs_form() {
3042        let vmc = DTGCredential::new_vmc(
3043            "did:example:community".to_string(),
3044            "did:example:member".to_string(),
3045            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3046                .unwrap()
3047                .with_timezone(&Utc),
3048            None,
3049            false,
3050        )
3051        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
3052
3053        let digest = vmc.digest_multibase().unwrap();
3054
3055        // Multibase base58btc.
3056        assert!(digest.starts_with('z'), "multibase base58btc prefix");
3057
3058        // Decodes to a sha2-256 multihash: 0x12 0x20 followed by 32 digest bytes.
3059        let (base, bytes) = multibase::decode(&digest).unwrap();
3060        assert_eq!(base, Base::Base58Btc);
3061        assert_eq!(bytes.len(), 34);
3062        assert_eq!(&bytes[..2], &[0x12, 0x20]);
3063
3064        // Computed outside this crate over the JCS canonical form of the document below,
3065        // then wrapped per CID v1.0 §2.4-2.5:
3066        //   {"@context":[...],"credentialSubject":{"id":"did:example:member"},
3067        //    "id":"urn:uuid:2a4e...","issuer":"did:example:community",
3068        //    "issuerScope":"public","type":[...],"validFrom":"2025-12-11T00:00:00Z"}
3069        // whose SHA-256 is efcb1e1022a5dbfd35af908d18ed7cc20af00cef049bd83d8ff955b97f4d07fe.
3070        assert_eq!(digest, "zQmeUhnd33Xp8egdUiQjPCVgANMXKSL7Nt5sCZbkcs2h2A1");
3071
3072        // Stable across calls.
3073        assert_eq!(digest, vmc.digest_multibase().unwrap());
3074    }
3075
3076    /// The superseded encoding still produces what it always did, so a caller migrating can
3077    /// recompute a Working Draft 01 digest to compare against one they stored.
3078    #[test]
3079    #[allow(deprecated)]
3080    fn the_superseded_hex_digest_is_unchanged() {
3081        let vmc = DTGCredential::new_vmc(
3082            "did:example:community".to_string(),
3083            "did:example:member".to_string(),
3084            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3085                .unwrap()
3086                .with_timezone(&Utc),
3087            None,
3088            false,
3089        )
3090        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
3091
3092        assert_eq!(
3093            vmc.digest().unwrap(),
3094            "sha256:efcb1e1022a5dbfd35af908d18ed7cc20af00cef049bd83d8ff955b97f4d07fe"
3095        );
3096    }
3097
3098    /// A Working Draft 01 digest reaching a Working Draft 02 verifier is *reported*, not
3099    /// silently treated as a mismatch. The two say different things: one is a credential
3100    /// that disagrees, the other a credential that cannot be read at all.
3101    #[test]
3102    fn a_superseded_digest_value_is_rejected_as_malformed() {
3103        let err = decode_digest_multibase(
3104            "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2",
3105        )
3106        .unwrap_err();
3107
3108        assert!(
3109            matches!(err, DTGCredentialError::InvalidDigest(_)),
3110            "expected InvalidDigest, got {err:?}"
3111        );
3112    }
3113
3114    /// Digests are compared as decoded bytes, never as strings — the specification requires
3115    /// it, because one digest has more than one spelling.
3116    #[test]
3117    fn digests_are_compared_by_bytes_not_by_string() {
3118        // The same sha2-256 multihash, encoded base58btc and base16. Identical bytes,
3119        // different strings.
3120        let multihash = {
3121            let mut v = vec![0x12u8, 0x20];
3122            v.extend_from_slice(&Sha256::digest(b"an edge credential"));
3123            v
3124        };
3125        let b58 = multibase::encode(Base::Base58Btc, &multihash);
3126        let b16 = multibase::encode(Base::Base16Lower, &multihash);
3127
3128        assert_ne!(b58, b16, "the two spellings differ as strings");
3129        assert!(
3130            digests_match(&b58, &b16).unwrap(),
3131            "but name the same digest"
3132        );
3133    }
3134
3135    /// An algorithm the library does not implement is *rejected*, not reported as a
3136    /// mismatch. A verifier that conflated the two would silently downgrade a governing
3137    /// party's choice of a stronger hash into a failed comparison.
3138    #[test]
3139    fn an_unaccepted_hash_algorithm_is_rejected_rather_than_mismatched() {
3140        // 0x13 is sha2-512 in the multicodec table.
3141        let mut multihash = vec![0x13u8, 0x40];
3142        multihash.extend_from_slice(&[0u8; 64]);
3143        let encoded = multibase::encode(Base::Base58Btc, &multihash);
3144
3145        assert!(matches!(
3146            decode_digest_multibase(&encoded),
3147            Err(DTGCredentialError::UnsupportedDigestAlgorithm(0x13))
3148        ));
3149    }
3150
3151    /// The digest binds to what a credential says, not to a signature over it, so a
3152    /// re-proofed credential still satisfies a reference made against the earlier one. This
3153    /// is what lets a member's acknowledgement survive the community re-signing its grant.
3154    #[cfg(feature = "affinidi-signing")]
3155    #[tokio::test]
3156    async fn test_digest_is_unchanged_by_signing() {
3157        use affinidi_secrets_resolver::secrets::Secret;
3158
3159        let secret = Secret::generate_ed25519(None, None);
3160
3161        let mut vmc = DTGCredential::new_vmc(
3162            "did:example:community".to_string(),
3163            "did:example:member".to_string(),
3164            Utc::now(),
3165            None,
3166            false,
3167        );
3168
3169        let before = vmc.digest_multibase().unwrap();
3170        vmc.sign(&secret, None).await.expect("signs");
3171        assert!(vmc.signed());
3172        assert_eq!(before, vmc.digest_multibase().unwrap());
3173    }
3174
3175    /// A grant in the wire form a member actually receives.
3176    fn wire(c: &DTGCredential) -> Value {
3177        serde_json::to_value(c.credential()).expect("credential serialises")
3178    }
3179
3180    /// The whole point of the pair: a grant and the acknowledgement built from it form a
3181    /// complete membership edge, and the parties are mirrored across the two halves.
3182    #[test]
3183    fn test_member_vmc_acknowledges_its_grant() {
3184        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3185            .unwrap()
3186            .with_timezone(&Utc);
3187
3188        let grant = DTGCredential::new_vmc(
3189            "did:example:community".to_string(),
3190            "did:example:member".to_string(),
3191            valid_from,
3192            None,
3193            false,
3194        );
3195
3196        let ack = DTGCredential::new_member_vmc_for(
3197            &wire(&grant),
3198            "did:example:member",
3199            IssuerScope::Directed,
3200            valid_from,
3201            None,
3202        )
3203        .expect("builds");
3204
3205        // Roles reversed.
3206        assert_eq!(ack.issuer(), "did:example:member");
3207        assert_eq!(ack.subject(), "did:example:community");
3208
3209        // The grant MUST omit the digest; the acknowledgement MUST carry it.
3210        assert_eq!(grant.subject_digest(), None);
3211        assert_eq!(
3212            ack.subject_digest(),
3213            Some(grant.digest_multibase().unwrap().as_str())
3214        );
3215
3216        assert!(ack.acknowledges(&grant).unwrap());
3217    }
3218
3219    /// An acknowledgement completes the edge it names and no other. Each case below verifies
3220    /// as a credential in its own right; what fails is the binding.
3221    #[test]
3222    fn test_acknowledges_rejects_a_mismatched_pair() {
3223        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3224            .unwrap()
3225            .with_timezone(&Utc);
3226
3227        let grant = DTGCredential::new_vmc(
3228            "did:example:community".to_string(),
3229            "did:example:member".to_string(),
3230            valid_from,
3231            None,
3232            false,
3233        );
3234        let ack = DTGCredential::new_member_vmc_for(
3235            &wire(&grant),
3236            "did:example:member",
3237            IssuerScope::Directed,
3238            valid_from,
3239            None,
3240        )
3241        .expect("builds");
3242
3243        // A grant to a different member: right community, wrong edge.
3244        let other_member = DTGCredential::new_vmc(
3245            "did:example:community".to_string(),
3246            "did:example:someone-else".to_string(),
3247            valid_from,
3248            None,
3249            false,
3250        );
3251        assert!(!ack.acknowledges(&other_member).unwrap());
3252
3253        // A grant from a different community.
3254        let other_community = DTGCredential::new_vmc(
3255            "did:example:other-community".to_string(),
3256            "did:example:member".to_string(),
3257            valid_from,
3258            None,
3259            false,
3260        );
3261        assert!(!ack.acknowledges(&other_community).unwrap());
3262
3263        // A re-issued grant to the same member — different claims, so a different digest.
3264        // This is what forces re-acknowledgement on renewal rather than letting a stale
3265        // consent carry over to a membership the member never agreed to.
3266        let renewed = DTGCredential::new_vmc(
3267            "did:example:community".to_string(),
3268            "did:example:member".to_string(),
3269            valid_from + chrono::Duration::days(365),
3270            None,
3271            false,
3272        );
3273        assert!(!ack.acknowledges(&renewed).unwrap());
3274
3275        // The acknowledgement is not itself a grant: acknowledging one forms no edge.
3276        let ack_of_ack = DTGCredential::new_member_vmc_for(
3277            &wire(&grant),
3278            "did:example:member",
3279            IssuerScope::Directed,
3280            valid_from,
3281            None,
3282        )
3283        .expect("builds");
3284        assert!(!ack_of_ack.acknowledges(&ack).unwrap());
3285
3286        // A grant on its own does not complete anything — it carries no digest to check.
3287        assert!(!grant.acknowledges(&grant).unwrap());
3288    }
3289
3290    /// A VDC's `credentialStatus` is CONDITIONAL, not required: a delegation whose validity
3291    /// exceeds the freshness window the governing VTC or VTN defines MUST carry one, and
3292    /// one short enough to be bounded by expiry alone MAY omit it. This library does not
3293    /// know that window, so the entry is attached rather than demanded — and once attached,
3294    /// it must reach the wire.
3295    #[test]
3296    fn a_vdc_carries_the_credential_status_it_is_given() {
3297        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3298            .unwrap()
3299            .with_timezone(&Utc);
3300        let valid_until = DateTime::parse_from_rfc3339("2026-12-11T00:00:00Z")
3301            .unwrap()
3302            .with_timezone(&Utc);
3303
3304        let status = serde_json::json!({
3305            "id": "https://delegator.example/status#12",
3306            "type": "BitstringStatusListEntry",
3307            "statusPurpose": "revocation",
3308            "statusListIndex": "12"
3309        });
3310
3311        let vdc = DTGCredential::new_vdc(
3312            "did:example:delegator".to_string(),
3313            IssuerScope::Directed,
3314            "did:example:delegate".to_string(),
3315            valid_from,
3316            valid_until,
3317            vec!["sign:invoices".to_string()],
3318            None,
3319        )
3320        .expect("a bounded grant is well formed");
3321
3322        // Omitting it is legitimate, so the constructor must not invent one.
3323        assert!(
3324            vdc.credential().credential_status.is_none(),
3325            "a VDC MAY omit `credentialStatus`, so the constructor must not supply one"
3326        );
3327
3328        let vdc = vdc.with_credential_status(status.clone());
3329        assert_eq!(vdc.credential().credential_status.as_ref(), Some(&status));
3330        assert_eq!(wire(&vdc).get("credentialStatus"), Some(&status));
3331
3332        // And it must survive the trip back, or a verifier reading the wire form loses the
3333        // only thing that lets it check revocation.
3334        let parsed: DTGCredential = serde_json::from_value(wire(&vdc)).expect("parses");
3335        assert_eq!(
3336            parsed.credential().credential_status.as_ref(),
3337            Some(&status)
3338        );
3339    }
3340
3341    /// The non-consuming form sets the same field.
3342    #[test]
3343    fn set_credential_status_matches_the_builder() {
3344        let status = serde_json::json!({ "type": "BitstringStatusListEntry" });
3345
3346        let mut vmc = DTGCredential::new_vmc(
3347            "did:example:community".to_string(),
3348            "did:example:member".to_string(),
3349            Utc::now(),
3350            None,
3351            false,
3352        );
3353        vmc.set_credential_status(status.clone());
3354
3355        assert_eq!(vmc.credential().credential_status.as_ref(), Some(&status));
3356    }
3357
3358    /// `DTGCredentialType` derives `PartialEq` so a consumer can assert by equality rather
3359    /// than by pattern, and get the actual variant reported on failure.
3360    #[test]
3361    fn credential_types_compare_by_equality() {
3362        let vdc = DTGCredential::new_vdc(
3363            "did:example:delegator".to_string(),
3364            IssuerScope::Directed,
3365            "did:example:delegate".to_string(),
3366            Utc::now(),
3367            Utc::now() + chrono::Duration::days(1),
3368            vec!["sign:invoices".to_string()],
3369            None,
3370        )
3371        .expect("a bounded grant is well formed");
3372
3373        assert_eq!(vdc.type_(), DTGCredentialType::Delegation);
3374        assert_ne!(vdc.type_(), DTGCredentialType::Membership);
3375    }
3376
3377    /// `credentialStatus` used to be dropped by a parse-then-re-serialise round trip, which
3378    /// silently changed a credential's digest. [`DTGCommon::credential_status`] models it,
3379    /// and this pins that it survives.
3380    #[test]
3381    fn credential_status_survives_a_round_trip() {
3382        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3383            .unwrap()
3384            .with_timezone(&Utc);
3385
3386        let mut grant = wire(&DTGCredential::new_vmc(
3387            "did:example:community".to_string(),
3388            "did:example:member".to_string(),
3389            valid_from,
3390            None,
3391            false,
3392        ));
3393        let status = serde_json::json!({
3394            "id": "https://community.example/status#7",
3395            "type": "BitstringStatusListEntry",
3396            "statusPurpose": "revocation",
3397            "statusListIndex": "7"
3398        });
3399        grant["credentialStatus"] = status.clone();
3400
3401        let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
3402        assert_eq!(
3403            parsed.credential().credential_status.as_ref(),
3404            Some(&status)
3405        );
3406        assert_eq!(wire(&parsed).get("credentialStatus"), Some(&status));
3407        assert_eq!(
3408            parsed.digest_multibase().unwrap(),
3409            digest_multibase_json(&grant).unwrap(),
3410            "the digest must not change under a round trip that preserves every member"
3411        );
3412    }
3413
3414    /// Top-level members this library does not model at all are preserved too, by
3415    /// [`DTGCommon::extra`]. `credentialSchema` stands in for the open set of them.
3416    #[test]
3417    fn unmodelled_top_level_members_survive_a_round_trip() {
3418        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3419            .unwrap()
3420            .with_timezone(&Utc);
3421
3422        let mut grant = wire(&DTGCredential::new_vmc(
3423            "did:example:community".to_string(),
3424            "did:example:member".to_string(),
3425            valid_from,
3426            None,
3427            false,
3428        ));
3429        let schema = serde_json::json!({
3430            "id": "https://community.example/schemas/vmc",
3431            "type": "JsonSchema"
3432        });
3433        grant["credentialSchema"] = schema.clone();
3434
3435        let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
3436        assert_eq!(
3437            parsed.credential().extra.get("credentialSchema"),
3438            Some(&schema)
3439        );
3440        assert_eq!(
3441            parsed.digest_multibase().unwrap(),
3442            digest_multibase_json(&grant).unwrap()
3443        );
3444    }
3445
3446    /// # Why the wire form is still what gets digested
3447    ///
3448    /// [`DTGCommon::extra`] closed the dropped-member hazard, but not the whole of it. A
3449    /// timestamp is *normalized* on the way out — `2025-12-11T00:00:00.000+00:00` and
3450    /// `2025-12-11T00:00:00Z` are the same instant and parse to the same
3451    /// [`chrono::DateTime`], and this library re-serializes both as the latter. The
3452    /// document that comes back out is therefore equivalent to the one that went in, and
3453    /// hashes differently.
3454    ///
3455    /// An acknowledgement built by digesting the *parsed* grant would carry a digest over a
3456    /// document the community never issued, and the community would rightly refuse it.
3457    /// Silently: both credentials verify, and only the digest comparison fails, with
3458    /// nothing to say why.
3459    ///
3460    /// So `new_member_vmc` takes the wire form, and this pins that it digests what it was
3461    /// handed rather than what it could parse.
3462    #[test]
3463    fn the_acknowledgement_digests_the_grant_as_it_arrived() {
3464        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3465            .unwrap()
3466            .with_timezone(&Utc);
3467
3468        let mut grant = wire(&DTGCredential::new_vmc(
3469            "did:example:community".to_string(),
3470            "did:example:member".to_string(),
3471            valid_from,
3472            None,
3473            false,
3474        ));
3475        // The same instant, spelled the way another implementation might.
3476        grant["validFrom"] = Value::String("2025-12-11T00:00:00.000+00:00".to_string());
3477
3478        // The parse normalizes it — this is the hazard, asserted rather than assumed.
3479        let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses");
3480        assert_ne!(
3481            wire(&parsed).get("validFrom"),
3482            grant.get("validFrom"),
3483            "the model is expected to normalize the timestamp; if it now round-trips \
3484             verbatim, this test has stopped guarding anything"
3485        );
3486
3487        let ack = DTGCredential::new_member_vmc_for(
3488            &grant,
3489            "did:example:member",
3490            IssuerScope::Directed,
3491            valid_from,
3492            None,
3493        )
3494        .expect("builds");
3495
3496        assert_eq!(
3497            ack.subject_digest(),
3498            Some(digest_multibase_json(&grant).unwrap().as_str()),
3499            "the acknowledgement must digest the grant as received"
3500        );
3501        assert_ne!(
3502            ack.subject_digest(),
3503            Some(parsed.digest_multibase().unwrap().as_str()),
3504            "digesting the parsed model would produce a digest the community cannot match"
3505        );
3506    }
3507
3508    #[test]
3509    fn digest_multibase_json_agrees_with_digest_where_the_model_is_complete() {
3510        let vmc = DTGCredential::new_vmc(
3511            "did:example:community".to_string(),
3512            "did:example:member".to_string(),
3513            DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3514                .unwrap()
3515                .with_timezone(&Utc),
3516            None,
3517            false,
3518        )
3519        .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
3520
3521        assert_eq!(
3522            vmc.digest_multibase().unwrap(),
3523            digest_multibase_json(&wire(&vmc)).unwrap()
3524        );
3525    }
3526
3527    /// `acknowledges` answers only about VMC pairs. A VRC edge is completed by its own
3528    /// reciprocal, not by this.
3529    #[test]
3530    fn test_acknowledges_is_membership_only() {
3531        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3532            .unwrap()
3533            .with_timezone(&Utc);
3534
3535        let grant = DTGCredential::new_vmc(
3536            "did:example:community".to_string(),
3537            "did:example:member".to_string(),
3538            valid_from,
3539            None,
3540            false,
3541        );
3542        let ack = DTGCredential::new_member_vmc_for(
3543            &wire(&grant),
3544            "did:example:member",
3545            IssuerScope::Directed,
3546            valid_from,
3547            None,
3548        )
3549        .expect("builds");
3550
3551        let vrc = DTGCredential::new_vrc(
3552            "did:example:member".to_string(),
3553            IssuerScope::Pairwise,
3554            "did:example:community".to_string(),
3555            valid_from,
3556            None,
3557        );
3558        assert!(!ack.acknowledges(&vrc).unwrap());
3559
3560        // And a VWC bound to the grant is a witness attestation, not a member's consent.
3561        let vwc = DTGCredential::new_witnessed_vsc(
3562            "did:example:witness".to_string(),
3563            IssuerScope::Public,
3564            &wire(&grant),
3565            &serde_json::json!({
3566                "id": "urn:uuid:session",
3567                "type": "https://trusttasks.org/spec/witness/session/0.1",
3568                "threadId": "urn:uuid:session",
3569            }),
3570            valid_from,
3571            None,
3572            None,
3573        )
3574        .unwrap();
3575        assert!(vwc.verify_digest(&grant).unwrap(), "the digest does match");
3576        assert!(
3577            !vwc.acknowledges(&grant).unwrap(),
3578            "but a VWC is not the member's acknowledgement"
3579        );
3580    }
3581
3582    /// A grant built against something that cannot be one is refused at construction, where
3583    /// the caller can still do something about it — rather than producing an acknowledgement
3584    /// that verifies as a credential and completes no edge.
3585    #[test]
3586    fn test_new_member_vmc_refuses_a_non_grant() {
3587        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3588            .unwrap()
3589            .with_timezone(&Utc);
3590
3591        let vrc = DTGCredential::new_vrc(
3592            "did:example:a".to_string(),
3593            IssuerScope::Pairwise,
3594            "did:example:b".to_string(),
3595            valid_from,
3596            None,
3597        );
3598        assert!(matches!(
3599            DTGCredential::new_member_vmc_for(
3600                &wire(&vrc),
3601                "did:example:b",
3602                IssuerScope::Directed,
3603                valid_from,
3604                None
3605            ),
3606            Err(DTGCredentialError::NotAMembershipGrant(_))
3607        ));
3608
3609        let grant = DTGCredential::new_vmc(
3610            "did:example:community".to_string(),
3611            "did:example:member".to_string(),
3612            valid_from,
3613            None,
3614            false,
3615        );
3616        let ack = DTGCredential::new_member_vmc_for(
3617            &wire(&grant),
3618            "did:example:member",
3619            IssuerScope::Directed,
3620            valid_from,
3621            None,
3622        )
3623        .expect("builds");
3624        assert!(matches!(
3625            DTGCredential::new_member_vmc_for(
3626                &wire(&ack),
3627                "did:example:community",
3628                IssuerScope::Directed,
3629                valid_from,
3630                None,
3631            ),
3632            Err(DTGCredentialError::NotAMembershipGrant(_))
3633        ));
3634    }
3635
3636    /// `{ id, digestMultibase }` is the member-issued half, and lands on `Membership`
3637    /// directly — no other subject shape has a top-level `digestMultibase` to compete for it.
3638    #[test]
3639    fn test_member_issued_vmc_deserializes_as_membership_not_witness() {
3640        let vmc: DTGCredential = serde_json::from_str(
3641            r#"{
3642                "@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
3643                "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
3644                "issuer": "did:example:member",
3645                "issuerScope": "directed",
3646                "validFrom": "2024-06-18T10:00:00Z",
3647                "credentialSubject": {
3648                    "id": "did:example:community",
3649                    "digestMultibase": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
3650                }
3651            }"#,
3652        )
3653        .expect("deserializes");
3654
3655        assert!(matches!(vmc.type_, DTGCredentialType::Membership));
3656        assert!(matches!(
3657            vmc.credential().credential_subject,
3658            CredentialSubject::Membership(_)
3659        ));
3660        assert_eq!(
3661            vmc.subject_digest(),
3662            Some("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
3663        );
3664        assert_eq!(vmc.subject(), "did:example:community");
3665    }
3666
3667    /// `witnessContext` belongs to a `witnessed/1` statement. A VMC carrying one is malformed
3668    /// rather than merely surprising, and is refused instead of being silently read as a grant.
3669    #[test]
3670    fn test_membership_credential_rejects_a_witness_context() {
3671        let result: Result<DTGCredential, _> = serde_json::from_str(
3672            r#"{
3673                "@context": ["https://www.w3.org/ns/credentials/v2", "https://registry.trustoverip.org/dtg/context/v1"],
3674                "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
3675                "issuer": "did:example:member",
3676                "issuerScope": "public",
3677                "validFrom": "2024-06-18T10:00:00Z",
3678                "credentialSubject": {
3679                    "id": "did:example:community",
3680                    "digestMultibase": "sha256:e3b0c4",
3681                    "witnessContext": { "event": "not a membership property" }
3682                }
3683            }"#,
3684        );
3685        assert!(result.is_err());
3686    }
3687
3688    /// The two halves must be distinguishable on the wire by `digestMultibase` alone — that is the
3689    /// only discriminator where both endpoints are C-DIDs, as in VTN membership.
3690    #[test]
3691    fn test_the_two_halves_round_trip_over_the_wire() {
3692        let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3693            .unwrap()
3694            .with_timezone(&Utc);
3695
3696        let grant = DTGCredential::new_vmc(
3697            "did:example:community".to_string(),
3698            "did:example:member".to_string(),
3699            valid_from,
3700            None,
3701            false,
3702        );
3703        let ack = DTGCredential::new_member_vmc_for(
3704            &wire(&grant),
3705            "did:example:member",
3706            IssuerScope::Directed,
3707            valid_from,
3708            None,
3709        )
3710        .expect("builds");
3711
3712        let grant_json = serde_json::to_value(&grant).unwrap();
3713        assert!(
3714            grant_json["credentialSubject"]
3715                .get("digestMultibase")
3716                .is_none(),
3717            "the grant MUST omit `digestMultibase`: {grant_json}"
3718        );
3719
3720        let ack_json = serde_json::to_value(&ack).unwrap();
3721        assert_eq!(
3722            ack_json["credentialSubject"]["digestMultibase"],
3723            Value::String(grant.digest_multibase().unwrap()),
3724        );
3725
3726        // And the pair still binds after a round trip through JSON, which is how each side
3727        // actually receives the other's half.
3728        let grant: DTGCredential = serde_json::from_value(grant_json).expect("grant round trips");
3729        let ack: DTGCredential = serde_json::from_value(ack_json).expect("ack round trips");
3730        assert!(ack.acknowledges(&grant).unwrap());
3731    }
3732
3733    #[test]
3734    fn test_iso8601_format_option() {
3735        let now: DateTime<Utc> = DateTime::parse_from_rfc3339(
3736            &Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
3737        )
3738        .unwrap()
3739        .to_utc();
3740        let vrc = |valid_until| {
3741            DTGCredential::new_vrc(
3742                "did:example:issuer".to_string(),
3743                IssuerScope::Pairwise,
3744                "did:example:subject".to_string(),
3745                now,
3746                valid_until,
3747            )
3748        };
3749
3750        let value = serde_json::to_value(vrc(Some(now + chrono::Duration::days(1)))).unwrap();
3751        let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
3752        assert_eq!(cred2.valid_until, Some(now + chrono::Duration::days(1)));
3753
3754        let value = serde_json::to_value(vrc(None)).unwrap();
3755        let cred2: DTGCommon = serde_json::from_value(value.clone()).unwrap();
3756        assert_eq!(cred2.valid_until, None);
3757    }
3758
3759    #[cfg(feature = "affinidi-signing")]
3760    #[tokio::test]
3761    async fn test_signing() {
3762        use affinidi_secrets_resolver::secrets::Secret;
3763
3764        let secret = Secret::generate_ed25519(None, None);
3765
3766        let mut cred = DTGCredential::new_vrc(
3767            "did:example:issuer".to_string(),
3768            IssuerScope::Pairwise,
3769            "did:example:subject".to_string(),
3770            Utc::now(),
3771            None,
3772        );
3773
3774        assert!(cred.sign(&secret, Some(Utc::now())).await.is_ok());
3775
3776        assert!(
3777            cred.verify_proof_with_public_key(secret.get_public_bytes())
3778                .is_ok()
3779        );
3780
3781        let secret2 = Secret::generate_ed25519(None, None);
3782        assert!(
3783            cred.verify_proof_with_public_key(secret2.get_public_bytes())
3784                .is_err()
3785        );
3786    }
3787
3788    /// The proof covers `id`, so it must be set *before* signing.
3789    ///
3790    /// This is the property that makes [DTGCredential::with_id]'s "set it before signing"
3791    /// caveat load-bearing rather than advisory: a credential signed without an identifier
3792    /// cannot be given one afterwards to satisfy a verifier that requires it, because the
3793    /// document that was signed did not contain it. Tampering with `id` after the fact is
3794    /// the same operation, and must fail the same way.
3795    #[cfg(feature = "affinidi-signing")]
3796    #[tokio::test]
3797    async fn test_id_is_covered_by_the_proof() {
3798        use affinidi_secrets_resolver::secrets::Secret;
3799
3800        let secret = Secret::generate_ed25519(None, None);
3801
3802        let mut cred = DTGCredential::new_vrc(
3803            "did:example:issuer".to_string(),
3804            IssuerScope::Pairwise,
3805            "did:example:subject".to_string(),
3806            Utc::now(),
3807            None,
3808        )
3809        .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
3810
3811        cred.sign(&secret, Some(Utc::now()))
3812            .await
3813            .expect("signing a credential that carries an id");
3814        assert!(
3815            cred.verify_proof_with_public_key(secret.get_public_bytes())
3816                .is_ok(),
3817            "an id set before signing verifies"
3818        );
3819
3820        // Changing the id after signing — which is what "splice an id into the JSON on the
3821        // way out" amounts to — invalidates the proof.
3822        cred.set_id("urn:uuid:00000000-0000-0000-0000-000000000000");
3823        assert!(
3824            cred.verify_proof_with_public_key(secret.get_public_bytes())
3825                .is_err(),
3826            "an id changed after signing must break the proof"
3827        );
3828    }
3829
3830    #[cfg(feature = "affinidi-signing")]
3831    #[tokio::test]
3832    async fn test_signing_error() {
3833        use affinidi_secrets_resolver::secrets::Secret;
3834
3835        let secret = Secret::generate_x25519(None, None).unwrap();
3836
3837        let mut cred = DTGCredential::new_vrc(
3838            "did:example:issuer".to_string(),
3839            IssuerScope::Pairwise,
3840            "did:example:subject".to_string(),
3841            Utc::now(),
3842            None,
3843        );
3844
3845        assert!(cred.sign(&secret, Some(Utc::now())).await.is_err());
3846    }
3847
3848    #[cfg(feature = "affinidi-signing")]
3849    #[test]
3850    fn test_signing_no_proof() {
3851        use crate::DTGCredentialError;
3852        use affinidi_secrets_resolver::secrets::Secret;
3853
3854        let cred = DTGCredential::new_vrc(
3855            "did:example:issuer".to_string(),
3856            IssuerScope::Pairwise,
3857            "did:example:subject".to_string(),
3858            Utc::now(),
3859            None,
3860        );
3861
3862        let secret = Secret::generate_ed25519(None, None);
3863        match cred.verify_proof_with_public_key(secret.get_public_bytes()) {
3864            Err(DTGCredentialError::NotSigned) => {
3865                // Good
3866            }
3867            _ => panic!("Expected NotSigned error!"),
3868        }
3869    }
3870
3871    /// The constructors that return a plain `Self` have no way to refuse a malformed
3872    /// window, so `validate` is where one is caught for them.
3873    #[test]
3874    fn validate_refuses_an_inverted_window() {
3875        let from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3876            .unwrap()
3877            .with_timezone(&Utc);
3878        let vmc = |valid_from, valid_until| {
3879            DTGCredential::new_vmc(
3880                "did:example:community".to_string(),
3881                "did:example:member".to_string(),
3882                valid_from,
3883                valid_until,
3884                false,
3885            )
3886        };
3887
3888        assert!(matches!(
3889            vmc(from, Some(from - chrono::Duration::hours(1))).validate(),
3890            Err(DTGCredentialError::InvalidValidityWindow { .. })
3891        ));
3892        assert!(matches!(
3893            vmc(from, Some(from)).validate(),
3894            Err(DTGCredentialError::InvalidValidityWindow { .. })
3895        ));
3896
3897        // Open-ended, and backdated, are both well formed.
3898        assert!(vmc(from, None).validate().is_ok());
3899        assert!(
3900            vmc(from - chrono::Duration::days(3650), Some(from))
3901                .validate()
3902                .is_ok()
3903        );
3904    }
3905
3906    #[test]
3907    fn new_member_vmc_refuses_an_inverted_window() {
3908        let from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
3909            .unwrap()
3910            .with_timezone(&Utc);
3911        let grant = DTGCredential::new_vmc(
3912            "did:example:community".to_string(),
3913            "did:example:member".to_string(),
3914            from,
3915            None,
3916            false,
3917        );
3918
3919        assert!(matches!(
3920            DTGCredential::new_member_vmc_for(
3921                &wire(&grant),
3922                "did:example:member",
3923                IssuerScope::Directed,
3924                from,
3925                Some(from - chrono::Duration::hours(1)),
3926            ),
3927            Err(DTGCredentialError::InvalidValidityWindow { .. })
3928        ));
3929    }
3930
3931    /// `sign` runs `validate` first, so this library never puts a proof on a credential
3932    /// whose window is never open.
3933    #[cfg(feature = "affinidi-signing")]
3934    #[tokio::test]
3935    async fn sign_refuses_an_inverted_window() {
3936        use affinidi_secrets_resolver::secrets::Secret;
3937
3938        let secret = Secret::generate_ed25519(None, None);
3939        let mut vrc = DTGCredential::new_vrc(
3940            "did:example:issuer".to_string(),
3941            IssuerScope::Pairwise,
3942            "did:example:subject".to_string(),
3943            Utc::now(),
3944            Some(Utc::now() - chrono::Duration::days(1)),
3945        );
3946
3947        assert!(matches!(
3948            vrc.sign(&secret, None).await,
3949            Err(DTGCredentialError::InvalidValidityWindow { .. })
3950        ));
3951        assert!(!vrc.signed(), "a refused credential must not carry a proof");
3952    }
3953}