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}