dtg_credentials/create.rs
1/*!
2* Builder methods for creating new entities.
3*/
4
5#[allow(deprecated)]
6use crate::{
7 AuthorityGrant, CredentialSubject, CredentialSubjectAuthority, CredentialSubjectBasic,
8 CredentialSubjectDelegation, CredentialSubjectEndorsement, CredentialSubjectMembership,
9 CredentialSubjectRCard, CredentialSubjectWitness, DTGCommon, DTGCredential, DTGCredentialError,
10 DTGCredentialType, DelegationGrant, WitnessContext,
11};
12use chrono::{DateTime, Utc};
13use serde_json::Value;
14
15impl DTGCredential {
16 /// Creates a new community-issued Verifiable Membership Credential (VMC) — the
17 /// membership **grant**, the community → member half of a membership edge.
18 ///
19 /// A membership edge is a *pair* of VMCs, and this is only one of them. The member
20 /// answers with [DTGCredential::new_member_vmc], and the edge is not complete until
21 /// they have: a community can always issue a credential naming somebody as a member,
22 /// but it cannot produce the acknowledgement without that party's signature. The pair
23 /// is what makes an unconsented membership claim unprovable.
24 ///
25 /// The grant MUST NOT carry a `digestMultibase` — that property is what marks the
26 /// other direction — and this constructor does not set one.
27 ///
28 /// issuer: The identifier of the VTC or VTN granting membership
29 /// subject: The member's identifier, or the member VTC's own for VTN membership
30 /// valid_from: The datetime from which this credential is valid
31 /// valid_until: Optional: The datetime this credential is valid until
32 /// personhood: Whether this VMC can be used as a form of Personhood Credential
33 /// - Adds PersonhoodCredential to the type array if true
34 ///
35 /// # Give it an `id`
36 ///
37 /// Chain [DTGCredential::with_id] on: the member stores the grant under its `id`, and
38 /// re-issuing is only recognisable as a renewal rather than a duplicate if there is one.
39 pub fn new_vmc(
40 issuer: String,
41 subject: String,
42 valid_from: DateTime<Utc>,
43 valid_until: Option<DateTime<Utc>>,
44 personhood: bool,
45 ) -> Self {
46 let mut vmc = DTGCommon {
47 issuer,
48 valid_from,
49 valid_until,
50 credential_subject: CredentialSubject::Membership(CredentialSubjectMembership {
51 id: subject,
52 digest_multibase: None,
53 }),
54 ..Default::default()
55 };
56
57 vmc.type_.push(DTGCredentialType::Membership.to_string());
58
59 if personhood {
60 vmc.type_.push("PersonhoodCredential".to_string());
61 }
62
63 DTGCredential {
64 credential: vmc,
65 type_: DTGCredentialType::Membership,
66 version: crate::W3CVCVersion::V2_0,
67 }
68 }
69
70 /// Creates a new member-issued Verifiable Membership Credential (VMC) — the membership
71 /// **acknowledgement**, the member → community half of a membership edge.
72 ///
73 /// The roles of [DTGCredential::new_vmc] are reversed (the member issues, the community
74 /// is the subject) and the subject carries a `digestMultibase` of the grant being
75 /// acknowledged.
76 /// That digest is what binds the two halves into one edge: an acknowledgement whose
77 /// digest matches no valid grant does not complete anything, and the binding forces an
78 /// order — the grant must exist before this can reference it.
79 ///
80 /// This is the member's consent artifact. Because the member is its issuer, withdrawing
81 /// consent needs no cooperation from the community.
82 ///
83 /// # Takes the grant in its wire form, deliberately
84 ///
85 /// `grant` is the JSON the community sent, not a parsed [DTGCredential]. The digest has
86 /// to cover the document the community will recompute it over, and this library does
87 /// not model every member a credential may carry — `credentialStatus`, which every VMC
88 /// issued against a status list carries, is dropped by a parse-then-re-serialise round
89 /// trip. Building the acknowledgement from a parsed grant would produce a digest that
90 /// verifies nowhere, and would do it silently.
91 ///
92 /// So: keep the bytes you were given, and pass them here.
93 ///
94 /// valid_from: The datetime from which this credential is valid
95 /// valid_until: Optional: The datetime this credential is valid until
96 ///
97 /// # Errors
98 ///
99 /// [DTGCredentialError::NotAMembershipGrant] if `grant` is not a JSON object, does not
100 /// carry `MembershipCredential` in its `type`, has no `issuer` or
101 /// `credentialSubject.id`, or already carries a `digest` — that last is an
102 /// acknowledgement, and acknowledging one does not form an edge.
103 ///
104 /// # Give it an `id`
105 ///
106 /// Chain [DTGCredential::with_id] on before signing. A community keys a member's VMC by
107 /// `id` to tell a re-send from a renewal.
108 pub fn new_member_vmc(
109 grant: &Value,
110 valid_from: DateTime<Utc>,
111 valid_until: Option<DateTime<Utc>>,
112 ) -> Result<Self, DTGCredentialError> {
113 let object = grant
114 .as_object()
115 .ok_or_else(|| DTGCredentialError::NotAMembershipGrant("not a JSON object".into()))?;
116
117 let is_membership = object
118 .get("type")
119 .and_then(Value::as_array)
120 .is_some_and(|types| {
121 types
122 .iter()
123 .filter_map(Value::as_str)
124 .any(|t| t == "MembershipCredential")
125 });
126 if !is_membership {
127 return Err(DTGCredentialError::NotAMembershipGrant(
128 "`type` does not include `MembershipCredential`".into(),
129 ));
130 }
131
132 let subject = object
133 .get("credentialSubject")
134 .and_then(Value::as_object)
135 .ok_or_else(|| {
136 DTGCredentialError::NotAMembershipGrant("no `credentialSubject`".into())
137 })?;
138
139 // Both spellings: `digestMultibase` is the Working Draft 02 name, `digest` the
140 // Working Draft 01 one this library also accepts on the wire. Probing only the
141 // current name would let an acknowledgement issued against the older draft be
142 // acknowledged in turn, which forms no edge.
143 if subject.contains_key("digestMultibase") || subject.contains_key("digest") {
144 return Err(DTGCredentialError::NotAMembershipGrant(
145 "the credential carries a digest of another credential, so it is itself a \
146 member-issued acknowledgement rather than a community-issued grant"
147 .into(),
148 ));
149 }
150
151 // The member is the grant's subject and the community its issuer: reading both off
152 // the grant is what keeps the two halves naming the same pair. Taking them as
153 // parameters would let a caller acknowledge one grant while naming the parties of
154 // another, which verifies as a digest match and means nothing.
155 let member = subject
156 .get("id")
157 .and_then(Value::as_str)
158 .ok_or_else(|| {
159 DTGCredentialError::NotAMembershipGrant("no `credentialSubject.id`".into())
160 })?
161 .to_string();
162
163 // `issuer` is a string or an object with an `id`, per the W3C data model.
164 let community = object
165 .get("issuer")
166 .and_then(|i| {
167 i.as_str()
168 .map(str::to_string)
169 .or_else(|| i.get("id").and_then(Value::as_str).map(str::to_string))
170 })
171 .ok_or_else(|| DTGCredentialError::NotAMembershipGrant("no `issuer`".into()))?;
172
173 let mut vmc = DTGCommon {
174 issuer: member,
175 valid_from,
176 valid_until,
177 credential_subject: CredentialSubject::Membership(CredentialSubjectMembership {
178 id: community,
179 digest_multibase: Some(crate::digest_multibase_json(grant)?),
180 }),
181 ..Default::default()
182 };
183
184 vmc.type_.push(DTGCredentialType::Membership.to_string());
185
186 Ok(DTGCredential {
187 credential: vmc,
188 type_: DTGCredentialType::Membership,
189 version: crate::W3CVCVersion::V2_0,
190 })
191 }
192
193 /// Creates a new Verified Relationship Credential (VRC)
194 /// issuer: The issuer DID of the credential
195 /// subject: The DID of the subject of this credential
196 /// valid_from: The datetime from which this credential is valid
197 /// valid_until: Optional: The datetime this credential is valid until
198 pub fn new_vrc(
199 issuer: String,
200 subject: String,
201 valid_from: DateTime<Utc>,
202 valid_until: Option<DateTime<Utc>>,
203 ) -> Self {
204 let mut vrc = DTGCommon {
205 issuer,
206 valid_from,
207 valid_until,
208 credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
209 ..Default::default()
210 };
211
212 vrc.type_.push(DTGCredentialType::Relationship.to_string());
213
214 DTGCredential {
215 credential: vrc,
216 type_: DTGCredentialType::Relationship,
217 version: crate::W3CVCVersion::V2_0,
218 }
219 }
220
221 /// Creates a new Verified Invitation Credential (VIC)
222 /// issuer: The issuer DID of the credential
223 /// subject: The DID of the subject of this credential
224 /// valid_from: The datetime from which this credential is valid
225 /// valid_until: Optional: The datetime this credential is valid until
226 pub fn new_vic(
227 issuer: String,
228 subject: String,
229 valid_from: DateTime<Utc>,
230 valid_until: Option<DateTime<Utc>>,
231 ) -> Self {
232 let mut vic = DTGCommon {
233 issuer,
234 valid_from,
235 valid_until,
236 credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
237 ..Default::default()
238 };
239
240 vic.type_.push(DTGCredentialType::Invitation.to_string());
241
242 DTGCredential {
243 credential: vic,
244 type_: DTGCredentialType::Invitation,
245 version: crate::W3CVCVersion::V2_0,
246 }
247 }
248
249 /// Creates a new Verifiable Authority Credential (VAC) — a chain root.
250 ///
251 /// The issuer is the party governing `scope`. To derive a narrower VAC from one you
252 /// already hold, use [DTGCredential::attenuate] instead: a chain root is a grant made
253 /// by the governing party, and minting one directly is how a self-issued grant of
254 /// arbitrary authority gets in.
255 ///
256 /// `actions` MUST NOT be empty — an empty list confers nothing rather than everything.
257 ///
258 /// # `valid_until` is required
259 ///
260 /// Not optional, unlike the base structure and unlike every other `new_*` constructor
261 /// here. Nothing about the subject's current standing is consulted when a VAC is
262 /// verified, so authority that does not expire is authority nobody can withdraw by
263 /// waiting.
264 pub fn new_vac(
265 issuer: String,
266 subject: String,
267 scope: String,
268 actions: Vec<String>,
269 valid_from: DateTime<Utc>,
270 valid_until: DateTime<Utc>,
271 ) -> Result<Self, DTGCredentialError> {
272 if actions.is_empty() {
273 return Err(DTGCredentialError::EmptyAuthorityActions);
274 }
275 let mut vac = DTGCommon {
276 issuer,
277 valid_from,
278 valid_until: Some(valid_until),
279 credential_subject: CredentialSubject::Authority(CredentialSubjectAuthority {
280 id: subject,
281 authority: AuthorityGrant {
282 scope,
283 actions,
284 parent: None,
285 },
286 }),
287 ..Default::default()
288 };
289
290 vac.type_.push(DTGCredentialType::Authority.to_string());
291
292 Ok(DTGCredential {
293 credential: vac,
294 type_: DTGCredentialType::Authority,
295 version: crate::W3CVCVersion::V2_0,
296 })
297 }
298
299 /// Derive a narrower VAC from one this holder already holds.
300 ///
301 /// This is what lets a member equip an agent, a device, or a short-lived session with
302 /// only the authority that task needs, rather than lending it their own. The derived
303 /// credential is issued by the *holder*, not by the party governing the scope, and
304 /// carries `parent` — the **digest** of the credential it narrows — so a verifier can
305 /// walk back to a root.
306 ///
307 /// Refuses anything that would widen. The checks here mirror
308 /// [crate::authority::verify_chain] on purpose: a holder should be unable to *build* a
309 /// chain a verifier would reject, so the failure surfaces at issue time rather than at
310 /// use — but the verifier's checks remain authoritative, because nothing stops a
311 /// different implementation constructing the JSON by hand.
312 ///
313 /// - `self` must be a VAC.
314 /// - `actions` must be a subset of what `self` confers.
315 /// - `valid_until` must not exceed `self`'s.
316 ///
317 /// # Binding the derivative to the agent is `subject`, not a separate field
318 ///
319 /// A VAC is not a bearer credential: [crate::authority::verify_chain] requires the
320 /// party presenting the leaf to be its subject. So equipping an agent means naming the
321 /// agent in `subject`, and there is nothing further to bind. An earlier version of this
322 /// method took an `audience` for that job; it was removed with the property.
323 ///
324 /// # Digests the model
325 ///
326 /// The `parent` digest is computed with [DTGCredential::digest_multibase], which hashes
327 /// this in-memory credential. That is right for a VAC this process built and signed.
328 /// For one that **arrived from a counterparty**, use
329 /// [DTGCredential::attenuate_from_json] and give it the bytes you received — the same
330 /// distinction [DTGCredential::new_member_vmc] draws, and for the same reason.
331 pub fn attenuate(
332 &self,
333 subject: String,
334 actions: Vec<String>,
335 valid_from: DateTime<Utc>,
336 valid_until: DateTime<Utc>,
337 ) -> Result<Self, DTGCredentialError> {
338 let parent_grant = self
339 .credential()
340 .authority()
341 .ok_or(DTGCredentialError::NotAnAuthorityCredential)?;
342
343 Self::attenuate_inner(
344 parent_grant.clone(),
345 self.credential().subject().to_string(),
346 self.credential().valid_until(),
347 self.digest_multibase()?,
348 subject,
349 actions,
350 valid_from,
351 valid_until,
352 )
353 }
354
355 /// Derive a narrower VAC from a parent in its **wire form**.
356 ///
357 /// Identical to [DTGCredential::attenuate] except that the parent is the JSON a
358 /// counterparty sent rather than a parsed credential, so the `parent` digest covers
359 /// the document the verifier will recompute it over. Use this whenever the VAC being
360 /// narrowed came from somewhere else.
361 ///
362 /// # Errors
363 ///
364 /// [DTGCredentialError::NotAnAuthorityCredential] if `parent` is not a JSON object
365 /// carrying `AuthorityCredential` in its `type` and a well-formed
366 /// `credentialSubject.authority`, and the same widening errors as
367 /// [DTGCredential::attenuate].
368 pub fn attenuate_from_json(
369 parent: &Value,
370 subject: String,
371 actions: Vec<String>,
372 valid_from: DateTime<Utc>,
373 valid_until: DateTime<Utc>,
374 ) -> Result<Self, DTGCredentialError> {
375 let object = parent
376 .as_object()
377 .ok_or(DTGCredentialError::NotAnAuthorityCredential)?;
378
379 let is_authority = object
380 .get("type")
381 .and_then(Value::as_array)
382 .is_some_and(|types| {
383 types
384 .iter()
385 .filter_map(Value::as_str)
386 .any(|t| t == "AuthorityCredential")
387 });
388 if !is_authority {
389 return Err(DTGCredentialError::NotAnAuthorityCredential);
390 }
391
392 let parent_subject = object
393 .get("credentialSubject")
394 .and_then(Value::as_object)
395 .ok_or(DTGCredentialError::NotAnAuthorityCredential)?;
396
397 // The holder attenuating is the parent's subject; reading it off the parent is what
398 // keeps a derived VAC from citing a chain its issuer never held.
399 let holder = parent_subject
400 .get("id")
401 .and_then(Value::as_str)
402 .ok_or(DTGCredentialError::NotAnAuthorityCredential)?
403 .to_string();
404
405 let parent_grant: AuthorityGrant = parent_subject
406 .get("authority")
407 .ok_or(DTGCredentialError::NotAnAuthorityCredential)
408 .and_then(|a| {
409 serde_json::from_value(a.clone())
410 .map_err(|_| DTGCredentialError::NotAnAuthorityCredential)
411 })?;
412
413 let parent_until = object
414 .get("validUntil")
415 .or_else(|| object.get("expirationDate"))
416 .and_then(Value::as_str)
417 .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
418 .map(|t| t.with_timezone(&Utc));
419
420 Self::attenuate_inner(
421 parent_grant,
422 holder,
423 parent_until,
424 crate::digest_multibase_json(parent)?,
425 subject,
426 actions,
427 valid_from,
428 valid_until,
429 )
430 }
431
432 /// The narrowing checks and the assembly, shared by both attenuation entry points.
433 #[allow(clippy::too_many_arguments)]
434 fn attenuate_inner(
435 parent_grant: AuthorityGrant,
436 holder: String,
437 parent_until: Option<DateTime<Utc>>,
438 parent_digest: String,
439 subject: String,
440 actions: Vec<String>,
441 valid_from: DateTime<Utc>,
442 valid_until: DateTime<Utc>,
443 ) -> Result<Self, DTGCredentialError> {
444 if actions.is_empty() {
445 return Err(DTGCredentialError::EmptyAuthorityActions);
446 }
447 for action in &actions {
448 if !parent_grant.actions.contains(action) {
449 return Err(DTGCredentialError::AttenuationWidens(format!(
450 "action `{action}` is not conferred by the parent"
451 )));
452 }
453 }
454 if let Some(parent_until) = parent_until
455 && valid_until > parent_until
456 {
457 return Err(DTGCredentialError::AttenuationWidens(format!(
458 "validUntil {valid_until} is beyond the parent's {parent_until}"
459 )));
460 }
461
462 let mut vac = DTGCommon {
463 // The holder issues: they are the subject of the parent grant.
464 issuer: holder,
465 valid_from,
466 valid_until: Some(valid_until),
467 credential_subject: CredentialSubject::Authority(CredentialSubjectAuthority {
468 id: subject,
469 authority: AuthorityGrant {
470 // Scope never changes down a chain.
471 scope: parent_grant.scope.clone(),
472 actions,
473 parent: Some(parent_digest),
474 },
475 }),
476 ..Default::default()
477 };
478
479 vac.type_.push(DTGCredentialType::Authority.to_string());
480
481 Ok(DTGCredential {
482 credential: vac,
483 type_: DTGCredentialType::Authority,
484 version: crate::W3CVCVersion::V2_0,
485 })
486 }
487
488 /// Creates a new Verifiable Delegation Credential (VDC) — the delegation **grant**,
489 /// the delegator → delegate half of a delegation edge.
490 ///
491 /// Establishes that `subject` may act **in the issuer's name**, for the acts named in
492 /// `scope`, until `valid_until`. Within that scope what the delegate does is
493 /// attributable to the delegator.
494 ///
495 /// # This is not authority
496 ///
497 /// A VDC never supplies permission the delegator did not itself hold. A verifier
498 /// substitutes the delegator for the delegate and then asks the permission question it
499 /// would have asked of the delegator directly — so withdrawing the delegator's own
500 /// permission ends the delegate's ability to act immediately, without revoking
501 /// anything. See [DTGCredential::new_vac] for the credential that answers that
502 /// question.
503 ///
504 /// # The edge is not complete without the acceptance
505 ///
506 /// This is one half. The delegate answers with [DTGCredential::new_delegate_vdc], and
507 /// a verifier MUST obtain and verify that half before accepting any party as acting
508 /// under the delegation: a grant alone establishes what the delegator appointed, not
509 /// what the delegate agreed to. Same consent rule as a membership edge, and for the
510 /// same reason — a delegator can always name someone as its delegate, but cannot
511 /// produce the countersignature.
512 ///
513 /// `scope` MUST NOT be empty: a VDC cannot express an unbounded appointment by
514 /// omitting it.
515 ///
516 /// `max_depth` is the number of further re-delegations permitted below this one.
517 /// `None` and `Some(0)` both prohibit re-delegation — the default is a single hop, and
518 /// setting it above zero is the delegator's explicit authorisation, of which there is
519 /// no other kind.
520 ///
521 /// # `valid_until` is required
522 ///
523 /// An appointment with no expiry cannot be reasoned about by a verifier that cannot
524 /// reach the delegator.
525 ///
526 /// # Errors
527 ///
528 /// [DTGCredentialError::MalformedDelegation] if `scope` is empty.
529 pub fn new_vdc(
530 issuer: String,
531 subject: String,
532 valid_from: DateTime<Utc>,
533 valid_until: DateTime<Utc>,
534 scope: Vec<String>,
535 max_depth: Option<u32>,
536 ) -> Result<Self, DTGCredentialError> {
537 if scope.is_empty() {
538 return Err(DTGCredentialError::MalformedDelegation(
539 "a grant MUST carry at least one `scope` entry — a VDC cannot express an \
540 unbounded appointment by emptying it"
541 .into(),
542 ));
543 }
544
545 let mut vdc = DTGCommon {
546 issuer,
547 valid_from,
548 valid_until: Some(valid_until),
549 credential_subject: CredentialSubject::Delegation(CredentialSubjectDelegation {
550 id: subject,
551 delegation: DelegationGrant {
552 scope: Some(scope),
553 parent: None,
554 max_depth,
555 accepts: None,
556 },
557 }),
558 ..Default::default()
559 };
560
561 vdc.type_.push(DTGCredentialType::Delegation.to_string());
562
563 Ok(DTGCredential {
564 credential: vdc,
565 type_: DTGCredentialType::Delegation,
566 version: crate::W3CVCVersion::V2_0,
567 })
568 }
569
570 /// Derive a further VDC from one this delegate already holds — a **re-delegation**.
571 ///
572 /// Only permitted where the held VDC sets `maxDepth` above zero, and only for a subset
573 /// of the acts it was itself appointed for. The default is a single hop: a delegate
574 /// that needs a further delegate and is not authorised to re-delegate asks the
575 /// principal, who issues a fresh root delegation directly — so that the principal
576 /// always holds the complete register of who may speak in its name.
577 ///
578 /// The derived VDC carries `parent`, the digest of the VDC it derives from, and a
579 /// `maxDepth` one less than its parent's.
580 ///
581 /// Like [DTGCredential::attenuate], this digests the in-memory model; for a grant that
582 /// arrived from a counterparty, use [DTGCredential::redelegate_from_json].
583 ///
584 /// # Errors
585 ///
586 /// [DTGCredentialError::MalformedDelegation] if `self` is not a delegation grant, if
587 /// it does not permit re-delegation, if `scope` is empty or not a subset of the
588 /// parent's, or if `valid_until` is later than the parent's.
589 pub fn redelegate(
590 &self,
591 subject: String,
592 scope: Vec<String>,
593 valid_from: DateTime<Utc>,
594 valid_until: DateTime<Utc>,
595 ) -> Result<Self, DTGCredentialError> {
596 let parent = self.credential().delegation().ok_or_else(|| {
597 DTGCredentialError::MalformedDelegation("not a DelegationCredential".into())
598 })?;
599
600 Self::redelegate_inner(
601 parent.clone(),
602 self.credential().subject().to_string(),
603 self.credential().valid_until(),
604 self.digest_multibase()?,
605 subject,
606 scope,
607 valid_from,
608 valid_until,
609 )
610 }
611
612 /// Derive a further VDC from a parent grant in its **wire form**.
613 ///
614 /// Identical to [DTGCredential::redelegate] except that the parent is the JSON the
615 /// delegator sent, so the `parent` digest covers the document a verifier will
616 /// recompute it over.
617 pub fn redelegate_from_json(
618 parent: &Value,
619 subject: String,
620 scope: Vec<String>,
621 valid_from: DateTime<Utc>,
622 valid_until: DateTime<Utc>,
623 ) -> Result<Self, DTGCredentialError> {
624 let (delegate, grant, parent_until) = Self::read_delegation_json(parent)?;
625
626 Self::redelegate_inner(
627 grant,
628 delegate,
629 parent_until,
630 crate::digest_multibase_json(parent)?,
631 subject,
632 scope,
633 valid_from,
634 valid_until,
635 )
636 }
637
638 /// The narrowing checks and the assembly, shared by both re-delegation entry points.
639 #[allow(clippy::too_many_arguments)]
640 fn redelegate_inner(
641 parent_grant: DelegationGrant,
642 holder: String,
643 parent_until: Option<DateTime<Utc>>,
644 parent_digest: String,
645 subject: String,
646 scope: Vec<String>,
647 valid_from: DateTime<Utc>,
648 valid_until: DateTime<Utc>,
649 ) -> Result<Self, DTGCredentialError> {
650 if parent_grant.accepts.is_some() {
651 return Err(DTGCredentialError::MalformedDelegation(
652 "the parent is an acceptance, not a grant — an acceptance appoints nobody \
653 and cannot be re-delegated from"
654 .into(),
655 ));
656 }
657
658 // Absence prohibits re-delegation just as `0` does. This is the opposite default
659 // from a VAC, deliberately: a delegate speaks in the principal's name, so the
660 // principal keeps the register of who may do so.
661 let parent_depth = parent_grant.max_depth.unwrap_or(0);
662 if parent_depth == 0 {
663 return Err(DTGCredentialError::MalformedDelegation(
664 "the parent does not permit re-delegation — `maxDepth` is absent or zero, \
665 and setting it above zero is the delegator's only way to authorise one"
666 .into(),
667 ));
668 }
669
670 if scope.is_empty() {
671 return Err(DTGCredentialError::MalformedDelegation(
672 "a grant MUST carry at least one `scope` entry".into(),
673 ));
674 }
675 let parent_scope = parent_grant.scope.as_deref().unwrap_or(&[]);
676 for act in &scope {
677 if !parent_scope.contains(act) {
678 return Err(DTGCredentialError::MalformedDelegation(format!(
679 "`{act}` is not in the scope this delegation derives from"
680 )));
681 }
682 }
683 if let Some(parent_until) = parent_until
684 && valid_until > parent_until
685 {
686 return Err(DTGCredentialError::MalformedDelegation(format!(
687 "validUntil {valid_until} is beyond the parent's {parent_until}"
688 )));
689 }
690
691 let mut vdc = DTGCommon {
692 issuer: holder,
693 valid_from,
694 valid_until: Some(valid_until),
695 credential_subject: CredentialSubject::Delegation(CredentialSubjectDelegation {
696 id: subject,
697 delegation: DelegationGrant {
698 scope: Some(scope),
699 parent: Some(parent_digest),
700 max_depth: Some(parent_depth - 1),
701 accepts: None,
702 },
703 }),
704 ..Default::default()
705 };
706
707 vdc.type_.push(DTGCredentialType::Delegation.to_string());
708
709 Ok(DTGCredential {
710 credential: vdc,
711 type_: DTGCredentialType::Delegation,
712 version: crate::W3CVCVersion::V2_0,
713 })
714 }
715
716 /// Creates the delegate-issued half of a delegation edge — the **acceptance**.
717 ///
718 /// The roles of [DTGCredential::new_vdc] are reversed (the delegate issues, the
719 /// delegator is the subject) and the subject carries `accepts`, the digest of the
720 /// grant being taken on. That digest is what binds the two halves into one edge.
721 ///
722 /// An acceptance carries no `scope` of its own. What the delegate consented to is the
723 /// scope of the grant it names, which a verifier holds in any case; restating it would
724 /// require an equality check across the two credentials that cannot be satisfied under
725 /// selective disclosure of either.
726 ///
727 /// This is the delegate's consent artifact, and its accountability for acting in
728 /// another's name. Because a delegator cannot produce it, a party holding only the
729 /// delegate's key cannot manufacture appointments either.
730 ///
731 /// # Takes the grant in its wire form, deliberately
732 ///
733 /// Same reasoning as [DTGCredential::new_member_vmc]: the digest has to cover the
734 /// document the delegator will recompute it over. Keep the bytes you were given and
735 /// pass them here.
736 ///
737 /// # Errors
738 ///
739 /// [DTGCredentialError::NotADelegationGrant] if `grant` is not a JSON object carrying
740 /// `DelegationCredential` in its `type`, has no `issuer` or `credentialSubject.id`, or
741 /// already carries `accepts` — that last is itself an acceptance, and accepting one
742 /// forms no edge.
743 pub fn new_delegate_vdc(
744 grant: &Value,
745 valid_from: DateTime<Utc>,
746 valid_until: DateTime<Utc>,
747 ) -> Result<Self, DTGCredentialError> {
748 let object = grant
749 .as_object()
750 .ok_or_else(|| DTGCredentialError::NotADelegationGrant("not a JSON object".into()))?;
751
752 let is_delegation = object
753 .get("type")
754 .and_then(Value::as_array)
755 .is_some_and(|types| {
756 types
757 .iter()
758 .filter_map(Value::as_str)
759 .any(|t| t == "DelegationCredential")
760 });
761 if !is_delegation {
762 return Err(DTGCredentialError::NotADelegationGrant(
763 "`type` does not include `DelegationCredential`".into(),
764 ));
765 }
766
767 let subject = object
768 .get("credentialSubject")
769 .and_then(Value::as_object)
770 .ok_or_else(|| {
771 DTGCredentialError::NotADelegationGrant("no `credentialSubject`".into())
772 })?;
773
774 let delegation = subject
775 .get("delegation")
776 .and_then(Value::as_object)
777 .ok_or_else(|| {
778 DTGCredentialError::NotADelegationGrant("no `credentialSubject.delegation`".into())
779 })?;
780
781 if delegation.contains_key("accepts") {
782 return Err(DTGCredentialError::NotADelegationGrant(
783 "the credential carries `accepts`, so it is itself an acceptance rather \
784 than a grant"
785 .into(),
786 ));
787 }
788 if !delegation.contains_key("scope") {
789 return Err(DTGCredentialError::NotADelegationGrant(
790 "the grant carries no `scope`, so there is no appointment to accept".into(),
791 ));
792 }
793
794 // The delegate is the grant's subject and the delegator its issuer. Reading both
795 // off the grant is what keeps the two halves naming the same pair — taking them as
796 // parameters would let a caller accept one grant while naming the parties of
797 // another, which verifies as a digest match and means nothing.
798 let delegate = subject
799 .get("id")
800 .and_then(Value::as_str)
801 .ok_or_else(|| {
802 DTGCredentialError::NotADelegationGrant("no `credentialSubject.id`".into())
803 })?
804 .to_string();
805
806 // `issuer` is a string or an object with an `id`, per the W3C data model.
807 let delegator = object
808 .get("issuer")
809 .and_then(|i| {
810 i.as_str()
811 .map(str::to_string)
812 .or_else(|| i.get("id").and_then(Value::as_str).map(str::to_string))
813 })
814 .ok_or_else(|| DTGCredentialError::NotADelegationGrant("no `issuer`".into()))?;
815
816 let mut vdc = DTGCommon {
817 issuer: delegate,
818 valid_from,
819 valid_until: Some(valid_until),
820 credential_subject: CredentialSubject::Delegation(CredentialSubjectDelegation {
821 id: delegator,
822 delegation: DelegationGrant {
823 scope: None,
824 parent: None,
825 max_depth: None,
826 accepts: Some(crate::digest_multibase_json(grant)?),
827 },
828 }),
829 ..Default::default()
830 };
831
832 vdc.type_.push(DTGCredentialType::Delegation.to_string());
833
834 Ok(DTGCredential {
835 credential: vdc,
836 type_: DTGCredentialType::Delegation,
837 version: crate::W3CVCVersion::V2_0,
838 })
839 }
840
841 /// Reads the delegate, the grant, and the parent's expiry off a VDC in its wire form.
842 fn read_delegation_json(
843 doc: &Value,
844 ) -> Result<(String, DelegationGrant, Option<DateTime<Utc>>), DTGCredentialError> {
845 let object = doc
846 .as_object()
847 .ok_or_else(|| DTGCredentialError::MalformedDelegation("not a JSON object".into()))?;
848
849 let is_delegation = object
850 .get("type")
851 .and_then(Value::as_array)
852 .is_some_and(|types| {
853 types
854 .iter()
855 .filter_map(Value::as_str)
856 .any(|t| t == "DelegationCredential")
857 });
858 if !is_delegation {
859 return Err(DTGCredentialError::MalformedDelegation(
860 "`type` does not include `DelegationCredential`".into(),
861 ));
862 }
863
864 let subject = object
865 .get("credentialSubject")
866 .and_then(Value::as_object)
867 .ok_or_else(|| {
868 DTGCredentialError::MalformedDelegation("no `credentialSubject`".into())
869 })?;
870
871 let delegate = subject
872 .get("id")
873 .and_then(Value::as_str)
874 .ok_or_else(|| {
875 DTGCredentialError::MalformedDelegation("no `credentialSubject.id`".into())
876 })?
877 .to_string();
878
879 let grant: DelegationGrant = subject
880 .get("delegation")
881 .ok_or_else(|| {
882 DTGCredentialError::MalformedDelegation("no `credentialSubject.delegation`".into())
883 })
884 .and_then(|d| {
885 serde_json::from_value(d.clone()).map_err(|e| {
886 DTGCredentialError::MalformedDelegation(format!("malformed `delegation`: {e}"))
887 })
888 })?;
889
890 let until = object
891 .get("validUntil")
892 .or_else(|| object.get("expirationDate"))
893 .and_then(Value::as_str)
894 .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
895 .map(|t| t.with_timezone(&Utc));
896
897 Ok((delegate, grant, until))
898 }
899
900 /// Creates a new Verified Persona Credential (VPC)
901 /// issuer: The issuer DID of the credential
902 /// subject: The DID of the subject of this credential
903 /// valid_from: The datetime from which this credential is valid
904 /// valid_until: Optional: The datetime this credential is valid until
905 pub fn new_vpc(
906 issuer: String,
907 subject: String,
908 valid_from: DateTime<Utc>,
909 valid_until: Option<DateTime<Utc>>,
910 ) -> Self {
911 let mut vpc = DTGCommon {
912 issuer,
913 valid_from,
914 valid_until,
915 credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
916 ..Default::default()
917 };
918
919 vpc.type_.push(DTGCredentialType::Persona.to_string());
920
921 DTGCredential {
922 credential: vpc,
923 type_: DTGCredentialType::Persona,
924 version: crate::W3CVCVersion::V2_0,
925 }
926 }
927
928 /// Creates a new Verified Endorsement Credential (VEC)
929 /// issuer: The issuer DID of the credential
930 /// subject: The DID of the subject of this credential
931 /// valid_from: The datetime from which this credential is valid
932 /// valid_until: Optional: The datetime this credential is valid until
933 /// endorsement: The endorsement details for this credential
934 pub fn new_vec(
935 issuer: String,
936 subject: String,
937 valid_from: DateTime<Utc>,
938 valid_until: Option<DateTime<Utc>>,
939 endorsement: Value,
940 ) -> Self {
941 let mut vec = DTGCommon {
942 issuer,
943 valid_from,
944 valid_until,
945 credential_subject: CredentialSubject::Endorsement(CredentialSubjectEndorsement {
946 id: subject,
947 endorsement,
948 }),
949 ..Default::default()
950 };
951
952 vec.type_.push(DTGCredentialType::Endorsement.to_string());
953
954 DTGCredential {
955 credential: vec,
956 type_: DTGCredentialType::Endorsement,
957 version: crate::W3CVCVersion::V2_0,
958 }
959 }
960
961 /// Creates a new Verified Witness Credential (VWC)
962 /// issuer: The issuer DID of the credential - a member's identifier, or the DID of a
963 /// VTA acting according to VTC policy
964 /// subject: The DID of the observed party. For a witnessed bi-directional exchange this
965 /// MUST be the issuer of the VRC that this VWC attests (the VRC referenced by
966 /// `digestMultibase`), so that the two VWCs of an exchange are unambiguously bound to
967 /// their respective directions. The witness should issue one VWC per direction.
968 /// valid_from: The datetime from which this credential is valid
969 /// valid_until: Optional: The datetime this credential is valid until
970 /// task_context: Required `threadId` of the trust task exchange the witnessing occurred in
971 /// digest: Cryptographic hash of the witnessed edge credential, binding this VWC to the
972 /// specific edge. Produce it with [DTGCredential::digest_multibase] on that
973 /// credential, or [crate::digest_multibase_json] on the bytes you received.
974 /// REQUIRED by the specification; `Option` here because a VWC that predates the
975 /// requirement still has to deserialize. A VWC without one identifies the
976 /// observed party and the exchange, but not which edge was witnessed.
977 /// witness_context: Optional Semantic context for the witness
978 pub fn new_vwc(
979 issuer: String,
980 subject: String,
981 valid_from: DateTime<Utc>,
982 valid_until: Option<DateTime<Utc>>,
983 task_context: String,
984 digest: Option<String>,
985 witness_context: Option<WitnessContext>,
986 ) -> Self {
987 let mut vwc = DTGCommon {
988 issuer,
989 valid_from,
990 valid_until,
991 task_context: Some(task_context),
992 credential_subject: CredentialSubject::Witness(CredentialSubjectWitness {
993 id: subject,
994 digest_multibase: digest,
995 witness_context,
996 }),
997 ..Default::default()
998 };
999
1000 vwc.type_.push(DTGCredentialType::Witness.to_string());
1001
1002 DTGCredential {
1003 credential: vwc,
1004 type_: DTGCredentialType::Witness,
1005 version: crate::W3CVCVersion::V2_0,
1006 }
1007 }
1008
1009 /// Creates a new Verified RCard Credential (VWC)
1010 /// issuer: The issuer DID of the credential
1011 /// subject: The DID of the subject of this credential
1012 /// valid_from: The datetime from which this credential is valid
1013 /// valid_until: Optional: The datetime this credential is valid until
1014 /// card: JSON Value representing a Jcard (RFC 7095) format
1015 #[deprecated(
1016 since = "0.2.0",
1017 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
1018 It was removed from the DTG Core Credentials specification in Working Draft 01 \
1019 and will be defined by the planned DTG Verifiable Data Structures specification. \
1020 This constructor will be removed in a future release."
1021 )]
1022 #[allow(deprecated)]
1023 pub fn new_rcard(
1024 issuer: String,
1025 subject: String,
1026 valid_from: DateTime<Utc>,
1027 valid_until: Option<DateTime<Utc>>,
1028 card: Value,
1029 ) -> Self {
1030 let mut rcard = DTGCommon {
1031 issuer,
1032 valid_from,
1033 valid_until,
1034 credential_subject: CredentialSubject::RCard(CredentialSubjectRCard {
1035 id: subject,
1036 card,
1037 }),
1038 ..Default::default()
1039 };
1040
1041 rcard.type_.push(DTGCredentialType::RCard.to_string());
1042
1043 DTGCredential {
1044 credential: rcard,
1045 type_: DTGCredentialType::RCard,
1046 version: crate::W3CVCVersion::V2_0,
1047 }
1048 }
1049
1050 /// Sets this credential's own identifier, consuming and returning it so it chains onto
1051 /// any of the `new_*` constructors above.
1052 ///
1053 /// `id` MUST be a single URL per the W3C VC Data Model; `urn:uuid:<uuid>` is the usual
1054 /// choice for a credential with no dereferenceable home. This crate does not validate it.
1055 ///
1056 /// ```
1057 /// # use chrono::Utc;
1058 /// # use dtg_credentials::DTGCredential;
1059 /// let vmc = DTGCredential::new_vmc(
1060 /// "did:example:member".to_string(),
1061 /// "did:example:community".to_string(),
1062 /// Utc::now(),
1063 /// None,
1064 /// false,
1065 /// )
1066 /// .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
1067 /// assert_eq!(vmc.id(), Some("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52"));
1068 /// ```
1069 ///
1070 /// # Set it before signing
1071 ///
1072 /// A Data Integrity proof covers the credential minus its `proof`, so `id` is part of what
1073 /// is signed. Chain this onto the constructor, before [DTGCredential::sign] — adding an id
1074 /// to an already-signed credential leaves a document whose proof no longer verifies.
1075 pub fn with_id(mut self, id: impl Into<String>) -> Self {
1076 self.credential.id = Some(id.into());
1077 self
1078 }
1079
1080 /// Sets this credential's own identifier in place.
1081 ///
1082 /// The non-consuming form of [DTGCredential::with_id]; the same "before signing" caveat
1083 /// applies.
1084 pub fn set_id(&mut self, id: impl Into<String>) {
1085 self.credential.id = Some(id.into());
1086 }
1087
1088 /// Attaches the status mechanism through which a verifier determines whether this
1089 /// credential has been revoked.
1090 ///
1091 /// The entry is opaque to this library: the mechanism is chosen by the governing VTC
1092 /// or VTN, and nothing here selects one or resolves it. `BitstringStatusListEntry` is
1093 /// the common choice.
1094 ///
1095 /// ```
1096 /// # use chrono::{Duration, Utc};
1097 /// # use dtg_credentials::DTGCredential;
1098 /// # use serde_json::json;
1099 /// let vdc = DTGCredential::new_vdc(
1100 /// "did:example:delegator".to_string(),
1101 /// "did:example:delegate".to_string(),
1102 /// Utc::now(),
1103 /// Utc::now() + Duration::days(90),
1104 /// vec!["sign:invoices".to_string()],
1105 /// None,
1106 /// )
1107 /// .unwrap()
1108 /// .with_credential_status(json!({
1109 /// "id": "https://example.com/status/3#94567",
1110 /// "type": "BitstringStatusListEntry",
1111 /// "statusPurpose": "revocation",
1112 /// "statusListIndex": "94567",
1113 /// "statusListCredential": "https://example.com/status/3"
1114 /// }));
1115 /// assert!(vdc.credential().credential_status.is_some());
1116 /// ```
1117 ///
1118 /// # When a VDC needs one
1119 ///
1120 /// CONDITIONAL, not required. A verifier MUST be able to establish that an appointment
1121 /// is in force without contacting the delegator, and either of two things satisfies
1122 /// that: a `validUntil` short enough that expiry alone bounds the exposure, or a status
1123 /// entry the verifier can check. A VDC MUST carry one where its validity period exceeds
1124 /// the freshness window the governing VTC or VTN defines for delegations, and MAY omit
1125 /// it otherwise.
1126 ///
1127 /// That window is governance this library does not know, so it cannot decide for a
1128 /// caller which side of the condition a given VDC falls on — hence a setter rather than
1129 /// a constructor parameter. Prefer short validity and re-issuance wherever the
1130 /// delegator is reachable: a status check is a live lookup that reveals the
1131 /// verification event to whoever hosts the status list. A long-lived appointment made
1132 /// in advance of a delegator's unavailability is the case this exists for.
1133 ///
1134 /// # Set it before signing
1135 ///
1136 /// Same caveat as [DTGCredential::with_id] — a Data Integrity proof covers the
1137 /// credential minus its `proof`, so attaching a status entry to an already-signed
1138 /// credential leaves a document whose proof no longer verifies.
1139 ///
1140 /// # This library does not check it
1141 ///
1142 /// Neither [`crate::delegation::verify_chain`] nor [`crate::authority::verify_chain`]
1143 /// resolves a status entry; both verify structure, scope and validity only. Revocation
1144 /// is a live lookup the caller performs.
1145 pub fn with_credential_status(mut self, status: Value) -> Self {
1146 self.credential.credential_status = Some(status);
1147 self
1148 }
1149
1150 /// Attaches a revocation status mechanism in place.
1151 ///
1152 /// The non-consuming form of [DTGCredential::with_credential_status]; the same "before
1153 /// signing" caveat and the same CONDITIONAL rule apply.
1154 pub fn set_credential_status(&mut self, status: Value) {
1155 self.credential.credential_status = Some(status);
1156 }
1157}
1158
1159#[cfg(test)]
1160#[allow(deprecated)]
1161mod tests {
1162 use crate::{DTGCredential, WitnessContext};
1163 use chrono::{DateTime, Utc};
1164 use serde_json::json;
1165
1166 #[test]
1167 fn test_vmc_serialization() {
1168 let vmc = DTGCredential::new_vmc(
1169 "did:example:issuer".to_string(),
1170 "did:example:subject".to_string(),
1171 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1172 .unwrap()
1173 .with_timezone(&Utc),
1174 None,
1175 false,
1176 );
1177
1178 let txt = serde_json::to_string_pretty(&vmc).unwrap();
1179 let sample = r#"{
1180 "@context": [
1181 "https://www.w3.org/ns/credentials/v2",
1182 "https://firstperson.network/credentials/dtg/v1"
1183 ],
1184 "type": [
1185 "VerifiableCredential",
1186 "DTGCredential",
1187 "MembershipCredential"
1188 ],
1189 "issuer": "did:example:issuer",
1190 "validFrom": "2025-12-11T00:00:00Z",
1191 "credentialSubject": {
1192 "id": "did:example:subject"
1193 }
1194}"#;
1195
1196 assert_eq!(txt, sample);
1197 }
1198
1199 #[test]
1200 fn test_vmc_phc_serialization() {
1201 let vmc = DTGCredential::new_vmc(
1202 "did:example:issuer".to_string(),
1203 "did:example:subject".to_string(),
1204 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1205 .unwrap()
1206 .with_timezone(&Utc),
1207 None,
1208 true,
1209 );
1210
1211 let txt = serde_json::to_string_pretty(&vmc).unwrap();
1212 let sample = r#"{
1213 "@context": [
1214 "https://www.w3.org/ns/credentials/v2",
1215 "https://firstperson.network/credentials/dtg/v1"
1216 ],
1217 "type": [
1218 "VerifiableCredential",
1219 "DTGCredential",
1220 "MembershipCredential",
1221 "PersonhoodCredential"
1222 ],
1223 "issuer": "did:example:issuer",
1224 "validFrom": "2025-12-11T00:00:00Z",
1225 "credentialSubject": {
1226 "id": "did:example:subject"
1227 }
1228}"#;
1229
1230 assert_eq!(txt, sample);
1231 }
1232 /// `id` is OPTIONAL, and a credential that was never given one must keep serializing the
1233 /// shape it always did — no `"id": null`, no empty string.
1234 #[test]
1235 fn test_vmc_without_id_omits_the_property() {
1236 let vmc = DTGCredential::new_vmc(
1237 "did:example:issuer".to_string(),
1238 "did:example:subject".to_string(),
1239 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1240 .unwrap()
1241 .with_timezone(&Utc),
1242 None,
1243 false,
1244 );
1245
1246 assert_eq!(vmc.id(), None);
1247 let value: serde_json::Value = serde_json::to_value(&vmc).unwrap();
1248 assert!(
1249 value.get("id").is_none(),
1250 "an unset id must not appear on the wire at all: {value}"
1251 );
1252 }
1253
1254 /// `with_id` puts the identifier at the top level of the credential — a sibling of
1255 /// `issuer`, not something nested under `credentialSubject` (which carries the *subject's*
1256 /// id, a different thing entirely).
1257 #[test]
1258 fn test_vmc_with_id_serialization() {
1259 let vmc = DTGCredential::new_vmc(
1260 "did:example:issuer".to_string(),
1261 "did:example:subject".to_string(),
1262 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1263 .unwrap()
1264 .with_timezone(&Utc),
1265 None,
1266 false,
1267 )
1268 .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
1269
1270 let txt = serde_json::to_string_pretty(&vmc).unwrap();
1271 let sample = r#"{
1272 "@context": [
1273 "https://www.w3.org/ns/credentials/v2",
1274 "https://firstperson.network/credentials/dtg/v1"
1275 ],
1276 "type": [
1277 "VerifiableCredential",
1278 "DTGCredential",
1279 "MembershipCredential"
1280 ],
1281 "id": "urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff",
1282 "issuer": "did:example:issuer",
1283 "validFrom": "2025-12-11T00:00:00Z",
1284 "credentialSubject": {
1285 "id": "did:example:subject"
1286 }
1287}"#;
1288
1289 assert_eq!(txt, sample);
1290 }
1291
1292 /// The identifier has to survive a round trip. It arrives on the wire and is read back
1293 /// through `TryFrom<DTGCommon>`, which is where `taskContext` was previously being dropped
1294 /// — a field that deserializes into nothing breaks signing and verification silently.
1295 #[test]
1296 fn test_id_round_trips_through_deserialization() {
1297 let vmc = DTGCredential::new_vmc(
1298 "did:example:issuer".to_string(),
1299 "did:example:subject".to_string(),
1300 Utc::now(),
1301 None,
1302 false,
1303 )
1304 .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
1305
1306 let txt = serde_json::to_string(&vmc).unwrap();
1307 let parsed: DTGCredential = serde_json::from_str(&txt).unwrap();
1308 assert_eq!(
1309 parsed.id(),
1310 Some("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff")
1311 );
1312 }
1313
1314 /// A credential with no `id` still deserializes — the property is OPTIONAL, and every
1315 /// credential issued before this field existed has none.
1316 #[test]
1317 fn test_missing_id_deserializes_as_none() {
1318 let parsed: DTGCredential = serde_json::from_str(
1319 r#"{
1320 "@context": ["https://www.w3.org/ns/credentials/v2"],
1321 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1322 "issuer": "did:example:issuer",
1323 "validFrom": "2025-12-11T00:00:00Z",
1324 "credentialSubject": { "id": "did:example:subject" }
1325 }"#,
1326 )
1327 .unwrap();
1328 assert_eq!(parsed.id(), None);
1329 }
1330
1331 /// `set_id` is the in-place form of `with_id`; both write the same property.
1332 #[test]
1333 fn test_set_id_matches_with_id() {
1334 let build = || {
1335 DTGCredential::new_vrc(
1336 "did:example:issuer".to_string(),
1337 "did:example:subject".to_string(),
1338 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1339 .unwrap()
1340 .with_timezone(&Utc),
1341 None,
1342 )
1343 };
1344 let mut in_place = build();
1345 in_place.set_id("urn:uuid:abc");
1346 assert_eq!(
1347 serde_json::to_value(&in_place).unwrap(),
1348 serde_json::to_value(build().with_id("urn:uuid:abc")).unwrap()
1349 );
1350 }
1351
1352 #[test]
1353 fn test_vrc_serialization() {
1354 let vrc = DTGCredential::new_vrc(
1355 "did:example:issuer".to_string(),
1356 "did:example:subject".to_string(),
1357 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1358 .unwrap()
1359 .with_timezone(&Utc),
1360 None,
1361 );
1362
1363 let txt = serde_json::to_string_pretty(&vrc).unwrap();
1364 let sample = r#"{
1365 "@context": [
1366 "https://www.w3.org/ns/credentials/v2",
1367 "https://firstperson.network/credentials/dtg/v1"
1368 ],
1369 "type": [
1370 "VerifiableCredential",
1371 "DTGCredential",
1372 "RelationshipCredential"
1373 ],
1374 "issuer": "did:example:issuer",
1375 "validFrom": "2025-12-11T00:00:00Z",
1376 "credentialSubject": {
1377 "id": "did:example:subject"
1378 }
1379}"#;
1380
1381 assert_eq!(txt, sample);
1382 }
1383
1384 #[test]
1385 fn test_vic_serialization() {
1386 let vic = DTGCredential::new_vic(
1387 "did:example:issuer".to_string(),
1388 "did:example:subject".to_string(),
1389 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1390 .unwrap()
1391 .with_timezone(&Utc),
1392 None,
1393 );
1394
1395 let txt = serde_json::to_string_pretty(&vic).unwrap();
1396 let sample = r#"{
1397 "@context": [
1398 "https://www.w3.org/ns/credentials/v2",
1399 "https://firstperson.network/credentials/dtg/v1"
1400 ],
1401 "type": [
1402 "VerifiableCredential",
1403 "DTGCredential",
1404 "InvitationCredential"
1405 ],
1406 "issuer": "did:example:issuer",
1407 "validFrom": "2025-12-11T00:00:00Z",
1408 "credentialSubject": {
1409 "id": "did:example:subject"
1410 }
1411}"#;
1412
1413 assert_eq!(txt, sample);
1414 }
1415
1416 #[test]
1417 fn test_vpc_serialization() {
1418 let vpc = DTGCredential::new_vpc(
1419 "did:example:issuer".to_string(),
1420 "did:example:subject".to_string(),
1421 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1422 .unwrap()
1423 .with_timezone(&Utc),
1424 None,
1425 );
1426
1427 let txt = serde_json::to_string_pretty(&vpc).unwrap();
1428 let sample = r#"{
1429 "@context": [
1430 "https://www.w3.org/ns/credentials/v2",
1431 "https://firstperson.network/credentials/dtg/v1"
1432 ],
1433 "type": [
1434 "VerifiableCredential",
1435 "DTGCredential",
1436 "PersonaCredential"
1437 ],
1438 "issuer": "did:example:issuer",
1439 "validFrom": "2025-12-11T00:00:00Z",
1440 "credentialSubject": {
1441 "id": "did:example:subject"
1442 }
1443}"#;
1444
1445 assert_eq!(txt, sample);
1446 }
1447
1448 #[test]
1449 fn test_vec_serialization() {
1450 let vec = DTGCredential::new_vec(
1451 "did:example:issuer".to_string(),
1452 "did:example:subject".to_string(),
1453 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1454 .unwrap()
1455 .with_timezone(&Utc),
1456 None,
1457 json!({
1458 "type": "SkillEndorsement",
1459 "name": "Software Development",
1460 "competencyLevel": "expert"
1461 }),
1462 );
1463
1464 let txt = serde_json::to_string_pretty(&vec).unwrap();
1465 let sample = r#"{
1466 "@context": [
1467 "https://www.w3.org/ns/credentials/v2",
1468 "https://firstperson.network/credentials/dtg/v1"
1469 ],
1470 "type": [
1471 "VerifiableCredential",
1472 "DTGCredential",
1473 "EndorsementCredential"
1474 ],
1475 "issuer": "did:example:issuer",
1476 "validFrom": "2025-12-11T00:00:00Z",
1477 "credentialSubject": {
1478 "id": "did:example:subject",
1479 "endorsement": {
1480 "competencyLevel": "expert",
1481 "name": "Software Development",
1482 "type": "SkillEndorsement"
1483 }
1484 }
1485}"#;
1486
1487 assert_eq!(txt, sample);
1488 }
1489
1490 #[test]
1491 fn test_vwc_serialization() {
1492 let vwc = DTGCredential::new_vwc(
1493 "did:example:issuer".to_string(),
1494 "did:example:subject".to_string(),
1495 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1496 .unwrap()
1497 .with_timezone(&Utc),
1498 None,
1499 "thread-abc-123".to_string(),
1500 Some("zQmbGXRT3v1RmfWkQ7Y3Z5Uj9pKq2NcXhLd8sVtA4eB6nMw".to_string()),
1501 Some(WitnessContext {
1502 event: Some("EthDenver 2024".to_string()),
1503 session_id: Some("session-8822-nonce".to_string()),
1504 method: Some("in-person-proximity".to_string()),
1505 }),
1506 );
1507
1508 let txt = serde_json::to_string_pretty(&vwc).unwrap();
1509
1510 let sample = r#"{
1511 "@context": [
1512 "https://www.w3.org/ns/credentials/v2",
1513 "https://firstperson.network/credentials/dtg/v1"
1514 ],
1515 "type": [
1516 "VerifiableCredential",
1517 "DTGCredential",
1518 "WitnessCredential"
1519 ],
1520 "issuer": "did:example:issuer",
1521 "validFrom": "2025-12-11T00:00:00Z",
1522 "taskContext": "thread-abc-123",
1523 "credentialSubject": {
1524 "id": "did:example:subject",
1525 "digestMultibase": "zQmbGXRT3v1RmfWkQ7Y3Z5Uj9pKq2NcXhLd8sVtA4eB6nMw",
1526 "witnessContext": {
1527 "event": "EthDenver 2024",
1528 "sessionId": "session-8822-nonce",
1529 "method": "in-person-proximity"
1530 }
1531 }
1532}"#;
1533
1534 assert_eq!(txt, sample);
1535 }
1536
1537 #[test]
1538 fn test_rcard_serialization() {
1539 let rcard = DTGCredential::new_rcard(
1540 "did:example:issuer".to_string(),
1541 "did:example:subject".to_string(),
1542 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1543 .unwrap()
1544 .with_timezone(&Utc),
1545 None,
1546 json!([
1547 "vcard",
1548 [
1549 ["fn", {}, "text", "Alice Smith"],
1550 ["email", {}, "text", "alice@example.com"]
1551 ]
1552 ]),
1553 );
1554
1555 let txt = serde_json::to_string_pretty(&rcard).unwrap();
1556
1557 let sample = r#"{
1558 "@context": [
1559 "https://www.w3.org/ns/credentials/v2",
1560 "https://firstperson.network/credentials/dtg/v1"
1561 ],
1562 "type": [
1563 "VerifiableCredential",
1564 "DTGCredential",
1565 "RCardCredential"
1566 ],
1567 "issuer": "did:example:issuer",
1568 "validFrom": "2025-12-11T00:00:00Z",
1569 "credentialSubject": {
1570 "id": "did:example:subject",
1571 "card": [
1572 "vcard",
1573 [
1574 [
1575 "fn",
1576 {},
1577 "text",
1578 "Alice Smith"
1579 ],
1580 [
1581 "email",
1582 {},
1583 "text",
1584 "alice@example.com"
1585 ]
1586 ]
1587 ]
1588 }
1589}"#;
1590
1591 assert_eq!(txt, sample);
1592 }
1593}