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