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, SubsecRound, Utc};
13use serde_json::Value;
14
15/// Refuses a validity window that closes before, or at the instant, it opens.
16///
17/// Only the ordering is checked. A `valid_from` in the past is legitimate — backdating is
18/// how a re-issued credential keeps the date the original took effect — and whether a
19/// window is current is a question about an instant the verifier chooses.
20///
21/// Both ends are compared at whole seconds, because that is all the wire form carries: a
22/// window a few hundred milliseconds wide in memory serializes as an empty one.
23pub(crate) fn check_window(
24 valid_from: DateTime<Utc>,
25 valid_until: Option<DateTime<Utc>>,
26) -> Result<(), DTGCredentialError> {
27 match valid_until {
28 Some(valid_until) if valid_until.trunc_subsecs(0) <= valid_from.trunc_subsecs(0) => {
29 Err(DTGCredentialError::InvalidValidityWindow {
30 valid_from,
31 valid_until,
32 })
33 }
34 _ => Ok(()),
35 }
36}
37
38/// The `type` prefix every version of `witness/session` shares.
39const WITNESS_SESSION_TYPE_PREFIX: &str = "https://trusttasks.org/spec/witness/session/";
40
41/// Refuses anything but the `witness/session` document that opened a witness session.
42///
43/// Two checks, both about naming the right exchange. The `type` must be
44/// `witness/session/<major>.<minor>` exactly — `witness/session/submit/0.1` shares the
45/// prefix and is the likeliest wrong document to hold, and a `#response` fragment is the
46/// witness's answer, not the opening document. And `threadId` must equal `id`, which
47/// `witness/session` Conformance, item 1, requires of the opening document.
48pub(crate) fn check_witness_session(session: &Value) -> Result<(), DTGCredentialError> {
49 let object = session
50 .as_object()
51 .ok_or_else(|| DTGCredentialError::MalformedTaskDocument("not a JSON object".into()))?;
52 let id = object
53 .get("id")
54 .and_then(Value::as_str)
55 .ok_or_else(|| DTGCredentialError::MalformedTaskDocument("no string `id`".into()))?;
56
57 let type_ = object
58 .get("type")
59 .and_then(Value::as_str)
60 .unwrap_or_default();
61 let is_version = |v: &str| {
62 v.split_once('.').is_some_and(|(major, minor)| {
63 !major.is_empty()
64 && !minor.is_empty()
65 && major.bytes().all(|b| b.is_ascii_digit())
66 && minor.bytes().all(|b| b.is_ascii_digit())
67 })
68 };
69 if !type_
70 .strip_prefix(WITNESS_SESSION_TYPE_PREFIX)
71 .is_some_and(is_version)
72 {
73 return Err(DTGCredentialError::NotAWitnessSession(format!(
74 "`type` is `{type_}`"
75 )));
76 }
77
78 match object.get("threadId").and_then(Value::as_str) {
79 Some(thread_id) if thread_id == id => Ok(()),
80 Some(thread_id) => Err(DTGCredentialError::NotAWitnessSession(format!(
81 "`threadId` `{thread_id}` is not the document's own `id` `{id}`"
82 ))),
83 None => Err(DTGCredentialError::NotAWitnessSession(
84 "no `threadId`; the opening document names its own thread".into(),
85 )),
86 }
87}
88
89/// The issuer of a credential in its wire form: a string, or an object carrying an `id`, per
90/// the W3C data model.
91pub(crate) fn issuer_of(credential: &serde_json::Map<String, Value>) -> Option<String> {
92 credential.get("issuer").and_then(|issuer| {
93 issuer
94 .as_str()
95 .or_else(|| issuer.get("id").and_then(Value::as_str))
96 .map(str::to_string)
97 })
98}
99
100/// Reads an RFC 3339 timestamp off a credential in its wire form, under its W3C VC 2.0 name
101/// or its 1.1 alias.
102///
103/// `Ok(None)` when neither is present. A value that is present but unreadable is an error
104/// rather than an absence: an expiry that cannot be read must not be treated as no expiry.
105pub(crate) fn read_timestamp(
106 credential: &serde_json::Map<String, Value>,
107 name: &str,
108 alias: &str,
109) -> Result<Option<DateTime<Utc>>, String> {
110 let Some(value) = credential.get(name).or_else(|| credential.get(alias)) else {
111 return Ok(None);
112 };
113 value
114 .as_str()
115 .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
116 .map(|t| Some(t.with_timezone(&Utc)))
117 .ok_or_else(|| format!("`{name}` is not an RFC 3339 timestamp"))
118}
119
120/// The `validUntil` of a grant in its wire form, if it has one.
121fn grant_valid_until(grant: &Value) -> Result<Option<DateTime<Utc>>, String> {
122 match grant.as_object() {
123 Some(object) => read_timestamp(object, "validUntil", "expirationDate"),
124 None => Ok(None),
125 }
126}
127
128/// Refuses an answer to a grant — an acknowledgement or an acceptance — that would remain
129/// valid after the grant it answers has expired.
130///
131/// Open-ended counts as outliving a grant that expires. Compared at whole seconds, the
132/// precision the wire form carries.
133fn check_within_grant(
134 valid_until: Option<DateTime<Utc>>,
135 grant_valid_until: Option<DateTime<Utc>>,
136) -> Result<(), DTGCredentialError> {
137 let Some(grant_valid_until) = grant_valid_until else {
138 return Ok(());
139 };
140 match valid_until {
141 Some(until) if until.trunc_subsecs(0) <= grant_valid_until.trunc_subsecs(0) => Ok(()),
142 _ => Err(DTGCredentialError::OutlivesGrant {
143 valid_until,
144 grant_valid_until,
145 }),
146 }
147}
148
149impl DTGCredential {
150 /// Creates a new community-issued Verifiable Membership Credential (VMC) — the
151 /// membership **grant**, the community → member half of a membership edge.
152 ///
153 /// A membership edge is a *pair* of VMCs, and this is only one of them. The member
154 /// answers with [DTGCredential::new_member_vmc_for], and the edge is not complete until
155 /// they have: a community can always issue a credential naming somebody as a member,
156 /// but it cannot produce the acknowledgement without that party's signature. The pair
157 /// is what makes an unconsented membership claim unprovable.
158 ///
159 /// The grant MUST NOT carry a `digestMultibase` — that property is what marks the
160 /// other direction — and this constructor does not set one.
161 ///
162 /// issuer: The identifier of the VTC or VTN granting membership
163 /// subject: The member's identifier, or the member VTC's own for VTN membership
164 /// valid_from: The datetime from which this credential is valid
165 /// valid_until: Optional: The datetime this credential is valid until
166 /// personhood: Whether this VMC can be used as a form of Personhood Credential
167 /// - Adds PersonhoodCredential to the type array if true
168 ///
169 /// # Give it an `id`
170 ///
171 /// Chain [DTGCredential::with_id] on: the member stores the grant under its `id`, and
172 /// re-issuing is only recognisable as a renewal rather than a duplicate if there is one.
173 pub fn new_vmc(
174 issuer: String,
175 subject: String,
176 valid_from: DateTime<Utc>,
177 valid_until: Option<DateTime<Utc>>,
178 personhood: bool,
179 ) -> Self {
180 let mut vmc = DTGCommon {
181 issuer,
182 valid_from,
183 valid_until,
184 credential_subject: CredentialSubject::Membership(CredentialSubjectMembership {
185 id: subject,
186 digest_multibase: None,
187 }),
188 ..Default::default()
189 };
190
191 vmc.type_.push(DTGCredentialType::Membership.to_string());
192
193 if personhood {
194 vmc.type_.push("PersonhoodCredential".to_string());
195 }
196
197 DTGCredential {
198 credential: vmc,
199 type_: DTGCredentialType::Membership,
200 version: crate::W3CVCVersion::V2_0,
201 }
202 }
203
204 /// Creates a new member-issued Verifiable Membership Credential (VMC) — the membership
205 /// **acknowledgement**, the member → community half of a membership edge.
206 ///
207 /// The roles of [DTGCredential::new_vmc] are reversed (the member issues, the community
208 /// is the subject) and the subject carries a `digestMultibase` of the grant being
209 /// acknowledged.
210 /// That digest is what binds the two halves into one edge: an acknowledgement whose
211 /// digest matches no valid grant does not complete anything, and the binding forces an
212 /// order — the grant must exist before this can reference it.
213 ///
214 /// This is the member's consent artifact. Because the member is its issuer, withdrawing
215 /// consent needs no cooperation from the community.
216 ///
217 /// # Takes the grant in its wire form, deliberately
218 ///
219 /// `grant` is the JSON the community sent, not a parsed [DTGCredential]. The digest has
220 /// to cover the document the community will recompute it over, and this library does
221 /// not model every member a credential may carry — `credentialStatus`, which every VMC
222 /// issued against a status list carries, is dropped by a parse-then-re-serialise round
223 /// trip. Building the acknowledgement from a parsed grant would produce a digest that
224 /// verifies nowhere, and would do it silently.
225 ///
226 /// So: keep the bytes you were given, and pass them here.
227 ///
228 /// member: The member acknowledging — the party whose key will sign this. Refused unless
229 /// the grant names exactly this identifier as its subject.
230 /// valid_from: The datetime from which this credential is valid
231 /// valid_until: Optional: The datetime this credential is valid until. Must not be later
232 /// than the grant's own `validUntil`, and must be set if the grant's is.
233 ///
234 /// # Errors
235 ///
236 /// [DTGCredentialError::NotAMembershipGrant] if `grant` is not a JSON object, does not
237 /// carry `MembershipCredential` in its `type`, has no `issuer` or
238 /// `credentialSubject.id`, or already carries a `digest` — that last is an
239 /// acknowledgement, and acknowledging one does not form an edge.
240 ///
241 /// It is also [DTGCredentialError::NotAMembershipGrant] if the grant carries a
242 /// `validUntil` that is not an RFC 3339 timestamp.
243 ///
244 /// [DTGCredentialError::NotTheGrantSubject] if the grant's `credentialSubject.id` is not
245 /// `member`.
246 ///
247 /// [DTGCredentialError::OutlivesGrant] if the grant expires and `valid_until` is later
248 /// than it, or absent.
249 ///
250 /// [DTGCredentialError::InvalidValidityWindow] if `valid_until` is not after
251 /// `valid_from`, and [DTGCredentialError::JsonTooDeep] if the grant is nested more deeply
252 /// than [crate::MAX_JSON_DEPTH].
253 ///
254 /// # Give it an `id`
255 ///
256 /// Chain [DTGCredential::with_id] on before signing. A community keys a member's VMC by
257 /// `id` to tell a re-send from a renewal.
258 ///
259 /// # Security
260 ///
261 /// The result is binding evidence, not membership. This constructor does not verify the
262 /// grant's proof, so it builds an acknowledgement of a grant nobody signed as readily as
263 /// of one the community did. Verify the grant first — with
264 /// `verify_grant_with_public_key` under the `affinidi-signing` feature, or against your
265 /// own resolver — and treat the edge as complete only once both proofs and both windows
266 /// have verified.
267 ///
268 /// Pass as `member` the identity whose key will sign the acknowledgement, established
269 /// independently of the grant. An identifier read out of the grant would make the check
270 /// compare the grant with itself.
271 pub fn new_member_vmc_for(
272 grant: &Value,
273 member: &str,
274 valid_from: DateTime<Utc>,
275 valid_until: Option<DateTime<Utc>>,
276 ) -> Result<Self, DTGCredentialError> {
277 check_window(valid_from, valid_until)?;
278
279 let (found, community) = Self::read_membership_grant(grant)?;
280 if found != member {
281 return Err(DTGCredentialError::NotTheGrantSubject {
282 expected: member.to_string(),
283 found,
284 });
285 }
286 let grant_valid_until =
287 grant_valid_until(grant).map_err(DTGCredentialError::NotAMembershipGrant)?;
288 check_within_grant(valid_until, grant_valid_until)?;
289
290 Self::assemble_member_vmc(grant, found, community, valid_from, valid_until)
291 }
292
293 /// Creates a member-issued VMC without checking who the grant names or when it expires.
294 ///
295 /// Identical to [DTGCredential::new_member_vmc_for] except that the member is taken from
296 /// the grant with nothing to compare it against, and the grant's `validUntil` is not
297 /// consulted.
298 #[deprecated(
299 since = "0.10.0",
300 note = "Takes the member from the grant without comparing it to anything, and lets \
301 the acknowledgement outlive the grant. Use DTGCredential::new_member_vmc_for, \
302 which takes the member you expect and refuses a grant naming anyone else. \
303 This constructor will be removed in a future release."
304 )]
305 pub fn new_member_vmc(
306 grant: &Value,
307 valid_from: DateTime<Utc>,
308 valid_until: Option<DateTime<Utc>>,
309 ) -> Result<Self, DTGCredentialError> {
310 check_window(valid_from, valid_until)?;
311
312 let (member, community) = Self::read_membership_grant(grant)?;
313 Self::assemble_member_vmc(grant, member, community, valid_from, valid_until)
314 }
315
316 /// Reads the member and the community off a membership grant in its wire form, refusing
317 /// anything that is not a community-issued grant.
318 fn read_membership_grant(grant: &Value) -> Result<(String, String), DTGCredentialError> {
319 let object = grant
320 .as_object()
321 .ok_or_else(|| DTGCredentialError::NotAMembershipGrant("not a JSON object".into()))?;
322
323 let is_membership = object
324 .get("type")
325 .and_then(Value::as_array)
326 .is_some_and(|types| {
327 types
328 .iter()
329 .filter_map(Value::as_str)
330 .any(|t| t == "MembershipCredential")
331 });
332 if !is_membership {
333 return Err(DTGCredentialError::NotAMembershipGrant(
334 "`type` does not include `MembershipCredential`".into(),
335 ));
336 }
337
338 let subject = object
339 .get("credentialSubject")
340 .and_then(Value::as_object)
341 .ok_or_else(|| {
342 DTGCredentialError::NotAMembershipGrant("no `credentialSubject`".into())
343 })?;
344
345 // Both spellings: `digestMultibase` is the Working Draft 02 name, `digest` the
346 // Working Draft 01 one this library also accepts on the wire. Probing only the
347 // current name would let an acknowledgement issued against the older draft be
348 // acknowledged in turn, which forms no edge.
349 if subject.contains_key("digestMultibase") || subject.contains_key("digest") {
350 return Err(DTGCredentialError::NotAMembershipGrant(
351 "the credential carries a digest of another credential, so it is itself a \
352 member-issued acknowledgement rather than a community-issued grant"
353 .into(),
354 ));
355 }
356
357 // The member is the grant's subject and the community its issuer: reading both off
358 // the grant is what keeps the two halves naming the same pair. Taking them as
359 // parameters would let a caller acknowledge one grant while naming the parties of
360 // another, which verifies as a digest match and means nothing.
361 let member = subject
362 .get("id")
363 .and_then(Value::as_str)
364 .ok_or_else(|| {
365 DTGCredentialError::NotAMembershipGrant("no `credentialSubject.id`".into())
366 })?
367 .to_string();
368
369 let community = issuer_of(object)
370 .ok_or_else(|| DTGCredentialError::NotAMembershipGrant("no `issuer`".into()))?;
371
372 Ok((member, community))
373 }
374
375 /// Assembles the acknowledgement once the grant has been read and every check has passed.
376 fn assemble_member_vmc(
377 grant: &Value,
378 member: String,
379 community: String,
380 valid_from: DateTime<Utc>,
381 valid_until: Option<DateTime<Utc>>,
382 ) -> Result<Self, DTGCredentialError> {
383 let mut vmc = DTGCommon {
384 issuer: member,
385 valid_from,
386 valid_until,
387 credential_subject: CredentialSubject::Membership(CredentialSubjectMembership {
388 id: community,
389 digest_multibase: Some(crate::digest_multibase_json(grant)?),
390 }),
391 ..Default::default()
392 };
393
394 vmc.type_.push(DTGCredentialType::Membership.to_string());
395
396 Ok(DTGCredential {
397 credential: vmc,
398 type_: DTGCredentialType::Membership,
399 version: crate::W3CVCVersion::V2_0,
400 })
401 }
402
403 /// Creates a new Verified Relationship Credential (VRC)
404 /// issuer: The issuer DID of the credential
405 /// subject: The DID of the subject of this credential
406 /// valid_from: The datetime from which this credential is valid
407 /// valid_until: Optional: The datetime this credential is valid until
408 pub fn new_vrc(
409 issuer: String,
410 subject: String,
411 valid_from: DateTime<Utc>,
412 valid_until: Option<DateTime<Utc>>,
413 ) -> Self {
414 let mut vrc = DTGCommon {
415 issuer,
416 valid_from,
417 valid_until,
418 credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
419 ..Default::default()
420 };
421
422 vrc.type_.push(DTGCredentialType::Relationship.to_string());
423
424 DTGCredential {
425 credential: vrc,
426 type_: DTGCredentialType::Relationship,
427 version: crate::W3CVCVersion::V2_0,
428 }
429 }
430
431 /// Creates a new Verified Invitation Credential (VIC)
432 /// issuer: The issuer DID of the credential
433 /// subject: The DID of the subject of this credential
434 /// valid_from: The datetime from which this credential is valid
435 /// valid_until: Optional: The datetime this credential is valid until
436 pub fn new_vic(
437 issuer: String,
438 subject: String,
439 valid_from: DateTime<Utc>,
440 valid_until: Option<DateTime<Utc>>,
441 ) -> Self {
442 let mut vic = DTGCommon {
443 issuer,
444 valid_from,
445 valid_until,
446 credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
447 ..Default::default()
448 };
449
450 vic.type_.push(DTGCredentialType::Invitation.to_string());
451
452 DTGCredential {
453 credential: vic,
454 type_: DTGCredentialType::Invitation,
455 version: crate::W3CVCVersion::V2_0,
456 }
457 }
458
459 /// Creates a new Verifiable Authority Credential (VAC) — a chain root.
460 ///
461 /// The issuer is the party governing `scope`. To derive a narrower VAC from one you
462 /// already hold, use [DTGCredential::attenuate] instead: a chain root is a grant made
463 /// by the governing party, and minting one directly is how a self-issued grant of
464 /// arbitrary authority gets in.
465 ///
466 /// `actions` MUST NOT be empty — an empty list confers nothing rather than everything.
467 ///
468 /// # `valid_until` is required
469 ///
470 /// Not optional, unlike the base structure and unlike every other `new_*` constructor
471 /// here. Nothing about the subject's current standing is consulted when a VAC is
472 /// verified, so authority that does not expire is authority nobody can withdraw by
473 /// waiting.
474 ///
475 /// # Errors
476 ///
477 /// [DTGCredentialError::InvalidValidityWindow] if `valid_until` is not after
478 /// `valid_from`, and [DTGCredentialError::EmptyAuthorityActions] if `actions` is empty.
479 pub fn new_vac(
480 issuer: String,
481 subject: String,
482 scope: String,
483 actions: Vec<String>,
484 valid_from: DateTime<Utc>,
485 valid_until: DateTime<Utc>,
486 ) -> Result<Self, DTGCredentialError> {
487 check_window(valid_from, Some(valid_until))?;
488
489 if actions.is_empty() {
490 return Err(DTGCredentialError::EmptyAuthorityActions);
491 }
492 let mut vac = DTGCommon {
493 issuer,
494 valid_from,
495 valid_until: Some(valid_until),
496 credential_subject: CredentialSubject::Authority(CredentialSubjectAuthority {
497 id: subject,
498 authority: AuthorityGrant {
499 scope,
500 actions,
501 parent: None,
502 },
503 }),
504 ..Default::default()
505 };
506
507 vac.type_.push(DTGCredentialType::Authority.to_string());
508
509 Ok(DTGCredential {
510 credential: vac,
511 type_: DTGCredentialType::Authority,
512 version: crate::W3CVCVersion::V2_0,
513 })
514 }
515
516 /// Derive a narrower VAC from one this holder already holds.
517 ///
518 /// This is what lets a member equip an agent, a device, or a short-lived session with
519 /// only the authority that task needs, rather than lending it their own. The derived
520 /// credential is issued by the *holder*, not by the party governing the scope, and
521 /// carries `parent` — the **digest** of the credential it narrows — so a verifier can
522 /// walk back to a root.
523 ///
524 /// Refuses anything that would widen. The checks here mirror
525 /// [crate::authority::verify_chain] on purpose: a holder should be unable to *build* a
526 /// chain a verifier would reject, so the failure surfaces at issue time rather than at
527 /// use — but the verifier's checks remain authoritative, because nothing stops a
528 /// different implementation constructing the JSON by hand.
529 ///
530 /// - `self` must be a VAC.
531 /// - `actions` must be a subset of what `self` confers.
532 /// - `valid_until` must not exceed `self`'s.
533 ///
534 /// # Binding the derivative to the agent is `subject`, not a separate field
535 ///
536 /// A VAC is not a bearer credential: [crate::authority::verify_chain] requires the
537 /// party presenting the leaf to be its subject. So equipping an agent means naming the
538 /// agent in `subject`, and there is nothing further to bind. An earlier version of this
539 /// method took an `audience` for that job; it was removed with the property.
540 ///
541 /// # Digests the model
542 ///
543 /// The `parent` digest is computed with [DTGCredential::digest_multibase], which hashes
544 /// this in-memory credential. That is right for a VAC this process built and signed.
545 /// For one that **arrived from a counterparty**, use
546 /// [DTGCredential::attenuate_from_json] and give it the bytes you received — the same
547 /// distinction [DTGCredential::new_member_vmc_for] draws, and for the same reason.
548 pub fn attenuate(
549 &self,
550 subject: String,
551 actions: Vec<String>,
552 valid_from: DateTime<Utc>,
553 valid_until: DateTime<Utc>,
554 ) -> Result<Self, DTGCredentialError> {
555 let parent_grant = self
556 .credential()
557 .authority()
558 .ok_or(DTGCredentialError::NotAnAuthorityCredential)?;
559
560 Self::attenuate_inner(
561 parent_grant.clone(),
562 self.credential().subject().to_string(),
563 self.credential().valid_until(),
564 self.digest_multibase()?,
565 subject,
566 actions,
567 valid_from,
568 valid_until,
569 )
570 }
571
572 /// Derive a narrower VAC from a parent in its **wire form**.
573 ///
574 /// Identical to [DTGCredential::attenuate] except that the parent is the JSON a
575 /// counterparty sent rather than a parsed credential, so the `parent` digest covers
576 /// the document the verifier will recompute it over. Use this whenever the VAC being
577 /// narrowed came from somewhere else.
578 ///
579 /// # Errors
580 ///
581 /// [DTGCredentialError::NotAnAuthorityCredential] if `parent` is not a JSON object
582 /// carrying `AuthorityCredential` in its `type` and a well-formed
583 /// `credentialSubject.authority`, and the same widening errors as
584 /// [DTGCredential::attenuate].
585 pub fn attenuate_from_json(
586 parent: &Value,
587 subject: String,
588 actions: Vec<String>,
589 valid_from: DateTime<Utc>,
590 valid_until: DateTime<Utc>,
591 ) -> Result<Self, DTGCredentialError> {
592 // Before any member is read out: reading clones one, and cloning recurses.
593 crate::check_json_depth(parent)?;
594
595 let object = parent
596 .as_object()
597 .ok_or(DTGCredentialError::NotAnAuthorityCredential)?;
598
599 let is_authority = object
600 .get("type")
601 .and_then(Value::as_array)
602 .is_some_and(|types| {
603 types
604 .iter()
605 .filter_map(Value::as_str)
606 .any(|t| t == "AuthorityCredential")
607 });
608 if !is_authority {
609 return Err(DTGCredentialError::NotAnAuthorityCredential);
610 }
611
612 let parent_subject = object
613 .get("credentialSubject")
614 .and_then(Value::as_object)
615 .ok_or(DTGCredentialError::NotAnAuthorityCredential)?;
616
617 // The holder attenuating is the parent's subject; reading it off the parent is what
618 // keeps a derived VAC from citing a chain its issuer never held.
619 let holder = parent_subject
620 .get("id")
621 .and_then(Value::as_str)
622 .ok_or(DTGCredentialError::NotAnAuthorityCredential)?
623 .to_string();
624
625 let parent_grant: AuthorityGrant = parent_subject
626 .get("authority")
627 .ok_or(DTGCredentialError::NotAnAuthorityCredential)
628 .and_then(|a| {
629 serde_json::from_value(a.clone())
630 .map_err(|_| DTGCredentialError::NotAnAuthorityCredential)
631 })?;
632
633 let parent_until = object
634 .get("validUntil")
635 .or_else(|| object.get("expirationDate"))
636 .and_then(Value::as_str)
637 .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
638 .map(|t| t.with_timezone(&Utc));
639
640 Self::attenuate_inner(
641 parent_grant,
642 holder,
643 parent_until,
644 crate::digest_multibase_json(parent)?,
645 subject,
646 actions,
647 valid_from,
648 valid_until,
649 )
650 }
651
652 /// The narrowing checks and the assembly, shared by both attenuation entry points.
653 #[allow(clippy::too_many_arguments)]
654 fn attenuate_inner(
655 parent_grant: AuthorityGrant,
656 holder: String,
657 parent_until: Option<DateTime<Utc>>,
658 parent_digest: String,
659 subject: String,
660 actions: Vec<String>,
661 valid_from: DateTime<Utc>,
662 valid_until: DateTime<Utc>,
663 ) -> Result<Self, DTGCredentialError> {
664 check_window(valid_from, Some(valid_until))?;
665
666 if actions.is_empty() {
667 return Err(DTGCredentialError::EmptyAuthorityActions);
668 }
669 for action in &actions {
670 if !parent_grant.actions.contains(action) {
671 return Err(DTGCredentialError::AttenuationWidens(format!(
672 "action `{action}` is not conferred by the parent"
673 )));
674 }
675 }
676 if let Some(parent_until) = parent_until
677 && valid_until > parent_until
678 {
679 return Err(DTGCredentialError::AttenuationWidens(format!(
680 "validUntil {valid_until} is beyond the parent's {parent_until}"
681 )));
682 }
683
684 let mut vac = DTGCommon {
685 // The holder issues: they are the subject of the parent grant.
686 issuer: holder,
687 valid_from,
688 valid_until: Some(valid_until),
689 credential_subject: CredentialSubject::Authority(CredentialSubjectAuthority {
690 id: subject,
691 authority: AuthorityGrant {
692 // Scope never changes down a chain.
693 scope: parent_grant.scope.clone(),
694 actions,
695 parent: Some(parent_digest),
696 },
697 }),
698 ..Default::default()
699 };
700
701 vac.type_.push(DTGCredentialType::Authority.to_string());
702
703 Ok(DTGCredential {
704 credential: vac,
705 type_: DTGCredentialType::Authority,
706 version: crate::W3CVCVersion::V2_0,
707 })
708 }
709
710 /// Creates a new Verifiable Delegation Credential (VDC) — the delegation **grant**,
711 /// the delegator → delegate half of a delegation edge.
712 ///
713 /// Establishes that `subject` may act **in the issuer's name**, for the acts named in
714 /// `scope`, until `valid_until`. Within that scope what the delegate does is
715 /// attributable to the delegator.
716 ///
717 /// # This is not authority
718 ///
719 /// A VDC never supplies permission the delegator did not itself hold. A verifier
720 /// substitutes the delegator for the delegate and then asks the permission question it
721 /// would have asked of the delegator directly — so withdrawing the delegator's own
722 /// permission ends the delegate's ability to act immediately, without revoking
723 /// anything. See [DTGCredential::new_vac] for the credential that answers that
724 /// question.
725 ///
726 /// # The edge is not complete without the acceptance
727 ///
728 /// This is one half. The delegate answers with [DTGCredential::new_delegate_vdc_for], and
729 /// a verifier MUST obtain and verify that half before accepting any party as acting
730 /// under the delegation: a grant alone establishes what the delegator appointed, not
731 /// what the delegate agreed to. Same consent rule as a membership edge, and for the
732 /// same reason — a delegator can always name someone as its delegate, but cannot
733 /// produce the countersignature.
734 ///
735 /// `scope` MUST NOT be empty: a VDC cannot express an unbounded appointment by
736 /// omitting it.
737 ///
738 /// `max_depth` is the number of further re-delegations permitted below this one.
739 /// `None` and `Some(0)` both prohibit re-delegation — the default is a single hop, and
740 /// setting it above zero is the delegator's explicit authorisation, of which there is
741 /// no other kind.
742 ///
743 /// # `valid_until` is required
744 ///
745 /// An appointment with no expiry cannot be reasoned about by a verifier that cannot
746 /// reach the delegator.
747 ///
748 /// # Errors
749 ///
750 /// [DTGCredentialError::MalformedDelegation] if `scope` is empty, and
751 /// [DTGCredentialError::InvalidValidityWindow] if `valid_until` is not after
752 /// `valid_from`.
753 pub fn new_vdc(
754 issuer: String,
755 subject: String,
756 valid_from: DateTime<Utc>,
757 valid_until: DateTime<Utc>,
758 scope: Vec<String>,
759 max_depth: Option<u32>,
760 ) -> Result<Self, DTGCredentialError> {
761 check_window(valid_from, Some(valid_until))?;
762
763 if scope.is_empty() {
764 return Err(DTGCredentialError::MalformedDelegation(
765 "a grant MUST carry at least one `scope` entry — a VDC cannot express an \
766 unbounded appointment by emptying it"
767 .into(),
768 ));
769 }
770
771 let mut vdc = DTGCommon {
772 issuer,
773 valid_from,
774 valid_until: Some(valid_until),
775 credential_subject: CredentialSubject::Delegation(CredentialSubjectDelegation {
776 id: subject,
777 delegation: DelegationGrant {
778 scope: Some(scope),
779 parent: None,
780 max_depth,
781 accepts: None,
782 },
783 }),
784 ..Default::default()
785 };
786
787 vdc.type_.push(DTGCredentialType::Delegation.to_string());
788
789 Ok(DTGCredential {
790 credential: vdc,
791 type_: DTGCredentialType::Delegation,
792 version: crate::W3CVCVersion::V2_0,
793 })
794 }
795
796 /// Derive a further VDC from one this delegate already holds — a **re-delegation**.
797 ///
798 /// Only permitted where the held VDC sets `maxDepth` above zero, and only for a subset
799 /// of the acts it was itself appointed for. The default is a single hop: a delegate
800 /// that needs a further delegate and is not authorised to re-delegate asks the
801 /// principal, who issues a fresh root delegation directly — so that the principal
802 /// always holds the complete register of who may speak in its name.
803 ///
804 /// The derived VDC carries `parent`, the digest of the VDC it derives from, and a
805 /// `maxDepth` one less than its parent's.
806 ///
807 /// Like [DTGCredential::attenuate], this digests the in-memory model; for a grant that
808 /// arrived from a counterparty, use [DTGCredential::redelegate_from_json].
809 ///
810 /// # Errors
811 ///
812 /// [DTGCredentialError::MalformedDelegation] if `self` is not a delegation grant, if
813 /// it does not permit re-delegation, if `scope` is empty or not a subset of the
814 /// parent's, or if `valid_until` is later than the parent's.
815 /// [DTGCredentialError::InvalidValidityWindow] if `valid_until` is not after
816 /// `valid_from`.
817 pub fn redelegate(
818 &self,
819 subject: String,
820 scope: Vec<String>,
821 valid_from: DateTime<Utc>,
822 valid_until: DateTime<Utc>,
823 ) -> Result<Self, DTGCredentialError> {
824 let parent = self.credential().delegation().ok_or_else(|| {
825 DTGCredentialError::MalformedDelegation("not a DelegationCredential".into())
826 })?;
827
828 Self::redelegate_inner(
829 parent.clone(),
830 self.credential().subject().to_string(),
831 self.credential().valid_until(),
832 self.digest_multibase()?,
833 subject,
834 scope,
835 valid_from,
836 valid_until,
837 )
838 }
839
840 /// Derive a further VDC from a parent grant in its **wire form**.
841 ///
842 /// Identical to [DTGCredential::redelegate] except that the parent is the JSON the
843 /// delegator sent, so the `parent` digest covers the document a verifier will
844 /// recompute it over.
845 pub fn redelegate_from_json(
846 parent: &Value,
847 subject: String,
848 scope: Vec<String>,
849 valid_from: DateTime<Utc>,
850 valid_until: DateTime<Utc>,
851 ) -> Result<Self, DTGCredentialError> {
852 let (delegate, grant, parent_until) = Self::read_delegation_json(parent)?;
853
854 Self::redelegate_inner(
855 grant,
856 delegate,
857 parent_until,
858 crate::digest_multibase_json(parent)?,
859 subject,
860 scope,
861 valid_from,
862 valid_until,
863 )
864 }
865
866 /// The narrowing checks and the assembly, shared by both re-delegation entry points.
867 #[allow(clippy::too_many_arguments)]
868 fn redelegate_inner(
869 parent_grant: DelegationGrant,
870 holder: String,
871 parent_until: Option<DateTime<Utc>>,
872 parent_digest: String,
873 subject: String,
874 scope: Vec<String>,
875 valid_from: DateTime<Utc>,
876 valid_until: DateTime<Utc>,
877 ) -> Result<Self, DTGCredentialError> {
878 check_window(valid_from, Some(valid_until))?;
879
880 if parent_grant.accepts.is_some() {
881 return Err(DTGCredentialError::MalformedDelegation(
882 "the parent is an acceptance, not a grant — an acceptance appoints nobody \
883 and cannot be re-delegated from"
884 .into(),
885 ));
886 }
887
888 // Absence prohibits re-delegation just as `0` does. This is the opposite default
889 // from a VAC, deliberately: a delegate speaks in the principal's name, so the
890 // principal keeps the register of who may do so.
891 let parent_depth = parent_grant.max_depth.unwrap_or(0);
892 if parent_depth == 0 {
893 return Err(DTGCredentialError::MalformedDelegation(
894 "the parent does not permit re-delegation — `maxDepth` is absent or zero, \
895 and setting it above zero is the delegator's only way to authorise one"
896 .into(),
897 ));
898 }
899
900 if scope.is_empty() {
901 return Err(DTGCredentialError::MalformedDelegation(
902 "a grant MUST carry at least one `scope` entry".into(),
903 ));
904 }
905 let parent_scope = parent_grant.scope.as_deref().unwrap_or(&[]);
906 for act in &scope {
907 if !parent_scope.contains(act) {
908 return Err(DTGCredentialError::MalformedDelegation(format!(
909 "`{act}` is not in the scope this delegation derives from"
910 )));
911 }
912 }
913 if let Some(parent_until) = parent_until
914 && valid_until > parent_until
915 {
916 return Err(DTGCredentialError::MalformedDelegation(format!(
917 "validUntil {valid_until} is beyond the parent's {parent_until}"
918 )));
919 }
920
921 let mut vdc = DTGCommon {
922 issuer: holder,
923 valid_from,
924 valid_until: Some(valid_until),
925 credential_subject: CredentialSubject::Delegation(CredentialSubjectDelegation {
926 id: subject,
927 delegation: DelegationGrant {
928 scope: Some(scope),
929 parent: Some(parent_digest),
930 max_depth: Some(parent_depth - 1),
931 accepts: None,
932 },
933 }),
934 ..Default::default()
935 };
936
937 vdc.type_.push(DTGCredentialType::Delegation.to_string());
938
939 Ok(DTGCredential {
940 credential: vdc,
941 type_: DTGCredentialType::Delegation,
942 version: crate::W3CVCVersion::V2_0,
943 })
944 }
945
946 /// Creates the delegate-issued half of a delegation edge — the **acceptance**.
947 ///
948 /// The roles of [DTGCredential::new_vdc] are reversed (the delegate issues, the
949 /// delegator is the subject) and the subject carries `accepts`, the digest of the
950 /// grant being taken on. That digest is what binds the two halves into one edge.
951 ///
952 /// An acceptance carries no `scope` of its own. What the delegate consented to is the
953 /// scope of the grant it names, which a verifier holds in any case; restating it would
954 /// require an equality check across the two credentials that cannot be satisfied under
955 /// selective disclosure of either.
956 ///
957 /// This is the delegate's consent artifact, and its accountability for acting in
958 /// another's name. Because a delegator cannot produce it, a party holding only the
959 /// delegate's key cannot manufacture appointments either.
960 ///
961 /// # Takes the grant in its wire form, deliberately
962 ///
963 /// Same reasoning as [DTGCredential::new_member_vmc_for]: the digest has to cover the
964 /// document the delegator will recompute it over. Keep the bytes you were given and
965 /// pass them here.
966 ///
967 /// # Errors
968 ///
969 /// [DTGCredentialError::NotADelegationGrant] if `grant` is not a JSON object carrying
970 /// `DelegationCredential` in its `type`, has no `issuer` or `credentialSubject.id`, or
971 /// already carries `accepts` — that last is itself an acceptance, and accepting one
972 /// forms no edge.
973 ///
974 /// It is also [DTGCredentialError::NotADelegationGrant] if the grant carries a
975 /// `validUntil` that is not an RFC 3339 timestamp.
976 ///
977 /// [DTGCredentialError::NotTheGrantSubject] if the grant's `credentialSubject.id` is not
978 /// `delegate`.
979 ///
980 /// [DTGCredentialError::OutlivesGrant] if `valid_until` is later than the grant's.
981 ///
982 /// [DTGCredentialError::InvalidValidityWindow] if `valid_until` is not after
983 /// `valid_from`, and [DTGCredentialError::JsonTooDeep] if the grant is nested more deeply
984 /// than [crate::MAX_JSON_DEPTH].
985 ///
986 /// # Security
987 ///
988 /// The result is binding evidence, not an appointment. This constructor does not verify
989 /// the grant's proof, so it builds an acceptance of a grant nobody signed as readily as of
990 /// one the delegator did. Verify the grant first — with `verify_grant_with_public_key`
991 /// under the `affinidi-signing` feature, or against your own resolver — and treat the edge
992 /// as complete only once both proofs and both windows have verified.
993 ///
994 /// Pass as `delegate` the identity whose key will sign the acceptance, established
995 /// independently of the grant. An identifier read out of the grant would make the check
996 /// compare the grant with itself.
997 pub fn new_delegate_vdc_for(
998 grant: &Value,
999 delegate: &str,
1000 valid_from: DateTime<Utc>,
1001 valid_until: DateTime<Utc>,
1002 ) -> Result<Self, DTGCredentialError> {
1003 check_window(valid_from, Some(valid_until))?;
1004
1005 let (found, delegator) = Self::read_delegation_grant(grant)?;
1006 if found != delegate {
1007 return Err(DTGCredentialError::NotTheGrantSubject {
1008 expected: delegate.to_string(),
1009 found,
1010 });
1011 }
1012 let grant_valid_until =
1013 grant_valid_until(grant).map_err(DTGCredentialError::NotADelegationGrant)?;
1014 check_within_grant(Some(valid_until), grant_valid_until)?;
1015
1016 Self::assemble_delegate_vdc(grant, found, delegator, valid_from, valid_until)
1017 }
1018
1019 /// Creates a delegate-issued acceptance without checking who the grant appoints or when it
1020 /// expires.
1021 ///
1022 /// Identical to [DTGCredential::new_delegate_vdc_for] except that the delegate is taken
1023 /// from the grant with nothing to compare it against, and the grant's `validUntil` is not
1024 /// consulted.
1025 #[deprecated(
1026 since = "0.10.0",
1027 note = "Takes the delegate from the grant without comparing it to anything, and lets \
1028 the acceptance outlive the grant. Use DTGCredential::new_delegate_vdc_for, \
1029 which takes the delegate you expect and refuses a grant appointing anyone \
1030 else. This constructor will be removed in a future release."
1031 )]
1032 pub fn new_delegate_vdc(
1033 grant: &Value,
1034 valid_from: DateTime<Utc>,
1035 valid_until: DateTime<Utc>,
1036 ) -> Result<Self, DTGCredentialError> {
1037 check_window(valid_from, Some(valid_until))?;
1038
1039 let (delegate, delegator) = Self::read_delegation_grant(grant)?;
1040 Self::assemble_delegate_vdc(grant, delegate, delegator, valid_from, valid_until)
1041 }
1042
1043 /// Reads the delegate and the delegator off a delegation grant in its wire form, refusing
1044 /// anything that is not a grant.
1045 fn read_delegation_grant(grant: &Value) -> Result<(String, String), DTGCredentialError> {
1046 let object = grant
1047 .as_object()
1048 .ok_or_else(|| DTGCredentialError::NotADelegationGrant("not a JSON object".into()))?;
1049
1050 let is_delegation = object
1051 .get("type")
1052 .and_then(Value::as_array)
1053 .is_some_and(|types| {
1054 types
1055 .iter()
1056 .filter_map(Value::as_str)
1057 .any(|t| t == "DelegationCredential")
1058 });
1059 if !is_delegation {
1060 return Err(DTGCredentialError::NotADelegationGrant(
1061 "`type` does not include `DelegationCredential`".into(),
1062 ));
1063 }
1064
1065 let subject = object
1066 .get("credentialSubject")
1067 .and_then(Value::as_object)
1068 .ok_or_else(|| {
1069 DTGCredentialError::NotADelegationGrant("no `credentialSubject`".into())
1070 })?;
1071
1072 let delegation = subject
1073 .get("delegation")
1074 .and_then(Value::as_object)
1075 .ok_or_else(|| {
1076 DTGCredentialError::NotADelegationGrant("no `credentialSubject.delegation`".into())
1077 })?;
1078
1079 if delegation.contains_key("accepts") {
1080 return Err(DTGCredentialError::NotADelegationGrant(
1081 "the credential carries `accepts`, so it is itself an acceptance rather \
1082 than a grant"
1083 .into(),
1084 ));
1085 }
1086 if !delegation.contains_key("scope") {
1087 return Err(DTGCredentialError::NotADelegationGrant(
1088 "the grant carries no `scope`, so there is no appointment to accept".into(),
1089 ));
1090 }
1091
1092 // The delegate is the grant's subject and the delegator its issuer. Reading both
1093 // off the grant is what keeps the two halves naming the same pair — taking them as
1094 // parameters would let a caller accept one grant while naming the parties of
1095 // another, which verifies as a digest match and means nothing.
1096 let delegate = subject
1097 .get("id")
1098 .and_then(Value::as_str)
1099 .ok_or_else(|| {
1100 DTGCredentialError::NotADelegationGrant("no `credentialSubject.id`".into())
1101 })?
1102 .to_string();
1103
1104 let delegator = issuer_of(object)
1105 .ok_or_else(|| DTGCredentialError::NotADelegationGrant("no `issuer`".into()))?;
1106
1107 Ok((delegate, delegator))
1108 }
1109
1110 /// Assembles the acceptance once the grant has been read and every check has passed.
1111 fn assemble_delegate_vdc(
1112 grant: &Value,
1113 delegate: String,
1114 delegator: String,
1115 valid_from: DateTime<Utc>,
1116 valid_until: DateTime<Utc>,
1117 ) -> Result<Self, DTGCredentialError> {
1118 let mut vdc = DTGCommon {
1119 issuer: delegate,
1120 valid_from,
1121 valid_until: Some(valid_until),
1122 credential_subject: CredentialSubject::Delegation(CredentialSubjectDelegation {
1123 id: delegator,
1124 delegation: DelegationGrant {
1125 scope: None,
1126 parent: None,
1127 max_depth: None,
1128 accepts: Some(crate::digest_multibase_json(grant)?),
1129 },
1130 }),
1131 ..Default::default()
1132 };
1133
1134 vdc.type_.push(DTGCredentialType::Delegation.to_string());
1135
1136 Ok(DTGCredential {
1137 credential: vdc,
1138 type_: DTGCredentialType::Delegation,
1139 version: crate::W3CVCVersion::V2_0,
1140 })
1141 }
1142
1143 /// Reads the delegate, the grant, and the parent's expiry off a VDC in its wire form.
1144 fn read_delegation_json(
1145 doc: &Value,
1146 ) -> Result<(String, DelegationGrant, Option<DateTime<Utc>>), DTGCredentialError> {
1147 // Before any member is read out: reading clones one, and cloning recurses.
1148 crate::check_json_depth(doc)?;
1149
1150 let object = doc
1151 .as_object()
1152 .ok_or_else(|| DTGCredentialError::MalformedDelegation("not a JSON object".into()))?;
1153
1154 let is_delegation = object
1155 .get("type")
1156 .and_then(Value::as_array)
1157 .is_some_and(|types| {
1158 types
1159 .iter()
1160 .filter_map(Value::as_str)
1161 .any(|t| t == "DelegationCredential")
1162 });
1163 if !is_delegation {
1164 return Err(DTGCredentialError::MalformedDelegation(
1165 "`type` does not include `DelegationCredential`".into(),
1166 ));
1167 }
1168
1169 let subject = object
1170 .get("credentialSubject")
1171 .and_then(Value::as_object)
1172 .ok_or_else(|| {
1173 DTGCredentialError::MalformedDelegation("no `credentialSubject`".into())
1174 })?;
1175
1176 let delegate = subject
1177 .get("id")
1178 .and_then(Value::as_str)
1179 .ok_or_else(|| {
1180 DTGCredentialError::MalformedDelegation("no `credentialSubject.id`".into())
1181 })?
1182 .to_string();
1183
1184 let grant: DelegationGrant = subject
1185 .get("delegation")
1186 .ok_or_else(|| {
1187 DTGCredentialError::MalformedDelegation("no `credentialSubject.delegation`".into())
1188 })
1189 .and_then(|d| {
1190 serde_json::from_value(d.clone()).map_err(|e| {
1191 DTGCredentialError::MalformedDelegation(format!("malformed `delegation`: {e}"))
1192 })
1193 })?;
1194
1195 let until = object
1196 .get("validUntil")
1197 .or_else(|| object.get("expirationDate"))
1198 .and_then(Value::as_str)
1199 .and_then(|t| DateTime::parse_from_rfc3339(t).ok())
1200 .map(|t| t.with_timezone(&Utc));
1201
1202 Ok((delegate, grant, until))
1203 }
1204
1205 /// Creates a new Verified Persona Credential (VPC)
1206 /// issuer: The issuer DID of the credential
1207 /// subject: The DID of the subject of this credential
1208 /// valid_from: The datetime from which this credential is valid
1209 /// valid_until: Optional: The datetime this credential is valid until
1210 pub fn new_vpc(
1211 issuer: String,
1212 subject: String,
1213 valid_from: DateTime<Utc>,
1214 valid_until: Option<DateTime<Utc>>,
1215 ) -> Self {
1216 let mut vpc = DTGCommon {
1217 issuer,
1218 valid_from,
1219 valid_until,
1220 credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }),
1221 ..Default::default()
1222 };
1223
1224 vpc.type_.push(DTGCredentialType::Persona.to_string());
1225
1226 DTGCredential {
1227 credential: vpc,
1228 type_: DTGCredentialType::Persona,
1229 version: crate::W3CVCVersion::V2_0,
1230 }
1231 }
1232
1233 /// Creates a new Verified Endorsement Credential (VEC)
1234 /// issuer: The issuer DID of the credential
1235 /// subject: The DID of the subject of this credential
1236 /// valid_from: The datetime from which this credential is valid
1237 /// valid_until: Optional: The datetime this credential is valid until
1238 /// endorsement: The endorsement details for this credential
1239 ///
1240 /// # Security
1241 ///
1242 /// `endorsement` is embedded verbatim. The specification does not define its content,
1243 /// so its shape is the issuer's to choose and this library checks nothing about it but
1244 /// its depth. A VEC says only that its issuer said this: a consumer must verify the
1245 /// proof, *and* establish that the issuer is one whose endorsements it accepts for this
1246 /// purpose, before relying on any member of it.
1247 ///
1248 /// Build it from input you control. Nesting past [crate::MAX_JSON_DEPTH] is refused
1249 /// when the credential is validated, digested or signed rather than here, because this
1250 /// constructor cannot return an error.
1251 pub fn new_vec(
1252 issuer: String,
1253 subject: String,
1254 valid_from: DateTime<Utc>,
1255 valid_until: Option<DateTime<Utc>>,
1256 endorsement: Value,
1257 ) -> Self {
1258 let mut vec = DTGCommon {
1259 issuer,
1260 valid_from,
1261 valid_until,
1262 credential_subject: CredentialSubject::Endorsement(CredentialSubjectEndorsement {
1263 id: subject,
1264 endorsement,
1265 }),
1266 ..Default::default()
1267 };
1268
1269 vec.type_.push(DTGCredentialType::Endorsement.to_string());
1270
1271 DTGCredential {
1272 credential: vec,
1273 type_: DTGCredentialType::Endorsement,
1274 version: crate::W3CVCVersion::V2_0,
1275 }
1276 }
1277
1278 /// Creates a Verifiable Witness Credential (VWC) for a `witness/session`, citing the
1279 /// session by `taskContext` **and** `taskDigestMultibase`.
1280 ///
1281 /// This is the constructor for the only VWC-issuing flow the specifications define:
1282 /// the witness, answering `witness/session/submit` on the party's session, delivers the
1283 /// VWC in the response. That specification's Conformance, item 1, requires the VWC's
1284 /// `taskContext` to be the `id` of the `witness/session` document that opened the
1285 /// session and its `taskDigestMultibase` to be that document's task digest. Both are
1286 /// read from `session` here, so the pair cannot disagree.
1287 ///
1288 /// - `issuer`: the witness's DID — a member's identifier, or the DID of a VTA acting
1289 /// according to VTC policy.
1290 /// - `subject`: the DID of the party observed **issuing** the edge credential that
1291 /// `digest` names, i.e. that credential's `issuer`. One VWC per direction.
1292 /// - `session`: the `witness/session` document, as received. Its top-level `proof`, if
1293 /// any, is excluded from the digest, so a signed and an unsigned copy give one value.
1294 /// - `digest`: the witnessed edge credential's digest, from
1295 /// [DTGCredential::digest_multibase] or [crate::digest_multibase_json]. REQUIRED
1296 /// here, unlike [DTGCredential::new_vwc]: a VWC without it does not say which edge
1297 /// was witnessed.
1298 ///
1299 /// # Errors
1300 ///
1301 /// - [DTGCredentialError::MalformedTaskDocument] if `session` is not an object with a
1302 /// string `id`.
1303 /// - [DTGCredentialError::NotAWitnessSession] if its `type` is not a `witness/session`
1304 /// version, or its `threadId` is not its own `id`. `witness/session` requires the
1305 /// opening document to name its own thread, so a document whose `threadId` differs
1306 /// is a later document of some exchange, not the one that opened this one. Both
1307 /// checks exist to catch citing the wrong exchange: the `submit` document, or the
1308 /// relationship exchange the session is nested in.
1309 /// - [DTGCredentialError::InvalidValidityWindow] for a window that closes before it
1310 /// opens, and [DTGCredentialError::JsonTooDeep] for a `session` nested past
1311 /// [crate::MAX_JSON_DEPTH].
1312 ///
1313 /// # Security
1314 ///
1315 /// Pass the session document the witness itself received and answered, not one the
1316 /// party supplies alongside its submission. The digest is load-bearing because the
1317 /// witness signs it: it is how a verifier later tells this session from a counterfeit
1318 /// reusing its `id`.
1319 pub fn new_vwc_for_session(
1320 issuer: String,
1321 subject: String,
1322 valid_from: DateTime<Utc>,
1323 valid_until: Option<DateTime<Utc>>,
1324 session: &Value,
1325 digest: String,
1326 witness_context: Option<WitnessContext>,
1327 ) -> Result<Self, DTGCredentialError> {
1328 check_window(valid_from, valid_until)?;
1329 check_witness_session(session)?;
1330
1331 #[allow(deprecated)]
1332 let vwc = Self::new_vwc(
1333 issuer,
1334 subject,
1335 valid_from,
1336 valid_until,
1337 String::new(),
1338 Some(digest),
1339 witness_context,
1340 );
1341 vwc.with_task_citation(session)
1342 }
1343
1344 /// Creates a new Verified Witness Credential (VWC)
1345 ///
1346 /// Carries no `taskDigestMultibase`, so the VWC names its session by `id` alone. Use
1347 /// [DTGCredential::new_vwc_for_session], which reads both halves of the citation from
1348 /// the session document.
1349 ///
1350 /// issuer: The issuer DID of the credential - a member's identifier, or the DID of a
1351 /// VTA acting according to VTC policy
1352 /// subject: The DID of the observed party. For a witnessed bi-directional exchange this
1353 /// MUST be the issuer of the VRC that this VWC attests (the VRC referenced by
1354 /// `digestMultibase`), so that the two VWCs of an exchange are unambiguously bound to
1355 /// their respective directions. The witness should issue one VWC per direction.
1356 /// valid_from: The datetime from which this credential is valid
1357 /// valid_until: Optional: The datetime this credential is valid until
1358 /// task_context: Required `threadId` of the trust task exchange the witnessing occurred in
1359 /// digest: Cryptographic hash of the witnessed edge credential, binding this VWC to the
1360 /// specific edge. Produce it with [DTGCredential::digest_multibase] on that
1361 /// credential, or [crate::digest_multibase_json] on the bytes you received.
1362 /// REQUIRED by the specification; `Option` here because a VWC that predates the
1363 /// requirement still has to deserialize. A VWC without one identifies the
1364 /// observed party and the exchange, but not which edge was witnessed.
1365 /// witness_context: Optional Semantic context for the witness
1366 #[deprecated(
1367 since = "0.11.0",
1368 note = "A VWC issued through witness/session MUST carry taskDigestMultibase, the \
1369 task digest of the session document, beside taskContext \
1370 (witness/session/submit Conformance, item 1), and this constructor cannot \
1371 set it. Use DTGCredential::new_vwc_for_session."
1372 )]
1373 pub fn new_vwc(
1374 issuer: String,
1375 subject: String,
1376 valid_from: DateTime<Utc>,
1377 valid_until: Option<DateTime<Utc>>,
1378 task_context: String,
1379 digest: Option<String>,
1380 witness_context: Option<WitnessContext>,
1381 ) -> Self {
1382 let mut vwc = DTGCommon {
1383 issuer,
1384 valid_from,
1385 valid_until,
1386 task_context: Some(task_context),
1387 credential_subject: CredentialSubject::Witness(CredentialSubjectWitness {
1388 id: subject,
1389 digest_multibase: digest,
1390 witness_context,
1391 }),
1392 ..Default::default()
1393 };
1394
1395 vwc.type_.push(DTGCredentialType::Witness.to_string());
1396
1397 DTGCredential {
1398 credential: vwc,
1399 type_: DTGCredentialType::Witness,
1400 version: crate::W3CVCVersion::V2_0,
1401 }
1402 }
1403
1404 /// Creates a new Verified RCard Credential (VWC)
1405 /// issuer: The issuer DID of the credential
1406 /// subject: The DID of the subject of this credential
1407 /// valid_from: The datetime from which this credential is valid
1408 /// valid_until: Optional: The datetime this credential is valid until
1409 /// card: JSON Value representing a Jcard (RFC 7095) format
1410 #[deprecated(
1411 since = "0.2.0",
1412 note = "The r-card is a verifiable data structure (VDS), not a DTGCredential subtype. \
1413 It was removed from the DTG Core Credentials specification in Working Draft 01 \
1414 and will be defined by the planned DTG Verifiable Data Structures specification. \
1415 This constructor will be removed in a future release."
1416 )]
1417 #[allow(deprecated)]
1418 pub fn new_rcard(
1419 issuer: String,
1420 subject: String,
1421 valid_from: DateTime<Utc>,
1422 valid_until: Option<DateTime<Utc>>,
1423 card: Value,
1424 ) -> Self {
1425 let mut rcard = DTGCommon {
1426 issuer,
1427 valid_from,
1428 valid_until,
1429 credential_subject: CredentialSubject::RCard(CredentialSubjectRCard {
1430 id: subject,
1431 card,
1432 }),
1433 ..Default::default()
1434 };
1435
1436 rcard.type_.push(DTGCredentialType::RCard.to_string());
1437
1438 DTGCredential {
1439 credential: rcard,
1440 type_: DTGCredentialType::RCard,
1441 version: crate::W3CVCVersion::V2_0,
1442 }
1443 }
1444
1445 /// Sets this credential's own identifier, consuming and returning it so it chains onto
1446 /// any of the `new_*` constructors above.
1447 ///
1448 /// `id` MUST be a single URL per the W3C VC Data Model; `urn:uuid:<uuid>` is the usual
1449 /// choice for a credential with no dereferenceable home. This crate does not validate it.
1450 ///
1451 /// ```
1452 /// # use chrono::Utc;
1453 /// # use dtg_credentials::DTGCredential;
1454 /// let vmc = DTGCredential::new_vmc(
1455 /// "did:example:member".to_string(),
1456 /// "did:example:community".to_string(),
1457 /// Utc::now(),
1458 /// None,
1459 /// false,
1460 /// )
1461 /// .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52");
1462 /// assert_eq!(vmc.id(), Some("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52"));
1463 /// ```
1464 ///
1465 /// # Set it before signing
1466 ///
1467 /// A Data Integrity proof covers the credential minus its `proof`, so `id` is part of what
1468 /// is signed. Chain this onto the constructor, before [DTGCredential::sign] — adding an id
1469 /// to an already-signed credential leaves a document whose proof no longer verifies.
1470 pub fn with_id(mut self, id: impl Into<String>) -> Self {
1471 self.credential.id = Some(id.into());
1472 self
1473 }
1474
1475 /// Sets this credential's own identifier in place.
1476 ///
1477 /// The non-consuming form of [DTGCredential::with_id]; the same "before signing" caveat
1478 /// applies.
1479 pub fn set_id(&mut self, id: impl Into<String>) {
1480 self.credential.id = Some(id.into());
1481 }
1482
1483 /// Cites a Trust Task document: sets `taskContext` to its `id` and
1484 /// `taskDigestMultibase` to its task digest, together.
1485 ///
1486 /// Use it for any credential whose meaning depends on an exchange — a VWC, or a
1487 /// statement a `vetting/session` produces. Name the **innermost** exchange that attests
1488 /// what the credential states, by the document that initiated it (Trust Tasks §4.9.1).
1489 /// Setting both halves from one document is the point: the `id` locates the exchange
1490 /// and the digest binds the credential to it, and a pair taken from two places binds
1491 /// nothing. See [crate::task_digest_multibase_json] for how the digest is computed.
1492 ///
1493 /// Replaces a `taskContext` and `taskDigestMultibase` already set.
1494 ///
1495 /// # Set it before signing
1496 ///
1497 /// Both members are covered by the credential's proof, as for [DTGCredential::with_id].
1498 ///
1499 /// # Errors
1500 ///
1501 /// [DTGCredentialError::MalformedTaskDocument] if `document` is not an object with a
1502 /// string `id`; [DTGCredentialError::JsonTooDeep] if it is nested past
1503 /// [crate::MAX_JSON_DEPTH].
1504 pub fn with_task_citation(mut self, document: &Value) -> Result<Self, DTGCredentialError> {
1505 self.set_task_citation(document)?;
1506 Ok(self)
1507 }
1508
1509 /// Cites a Trust Task document in place.
1510 ///
1511 /// The non-consuming form of [DTGCredential::with_task_citation]; the same caveats
1512 /// apply. On error, the credential is left unchanged.
1513 pub fn set_task_citation(&mut self, document: &Value) -> Result<(), DTGCredentialError> {
1514 let id = document
1515 .as_object()
1516 .ok_or_else(|| DTGCredentialError::MalformedTaskDocument("not a JSON object".into()))?
1517 .get("id")
1518 .and_then(Value::as_str)
1519 .ok_or_else(|| DTGCredentialError::MalformedTaskDocument("no string `id`".into()))?
1520 .to_string();
1521 let digest = crate::task_digest_multibase_json(document)?;
1522
1523 self.credential.task_context = Some(id);
1524 self.credential.task_digest_multibase = Some(digest);
1525 Ok(())
1526 }
1527
1528 /// Attaches the status mechanism through which a verifier determines whether this
1529 /// credential has been revoked.
1530 ///
1531 /// The entry is opaque to this library: the mechanism is chosen by the governing VTC
1532 /// or VTN, and nothing here selects one or resolves it. `BitstringStatusListEntry` is
1533 /// the common choice.
1534 ///
1535 /// ```
1536 /// # use chrono::{Duration, Utc};
1537 /// # use dtg_credentials::DTGCredential;
1538 /// # use serde_json::json;
1539 /// let vdc = DTGCredential::new_vdc(
1540 /// "did:example:delegator".to_string(),
1541 /// "did:example:delegate".to_string(),
1542 /// Utc::now(),
1543 /// Utc::now() + Duration::days(90),
1544 /// vec!["sign:invoices".to_string()],
1545 /// None,
1546 /// )
1547 /// .unwrap()
1548 /// .with_credential_status(json!({
1549 /// "id": "https://example.com/status/3#94567",
1550 /// "type": "BitstringStatusListEntry",
1551 /// "statusPurpose": "revocation",
1552 /// "statusListIndex": "94567",
1553 /// "statusListCredential": "https://example.com/status/3"
1554 /// }));
1555 /// assert!(vdc.credential().credential_status.is_some());
1556 /// ```
1557 ///
1558 /// # When a VDC needs one
1559 ///
1560 /// CONDITIONAL, not required. A verifier MUST be able to establish that an appointment
1561 /// is in force without contacting the delegator, and either of two things satisfies
1562 /// that: a `validUntil` short enough that expiry alone bounds the exposure, or a status
1563 /// entry the verifier can check. A VDC MUST carry one where its validity period exceeds
1564 /// the freshness window the governing VTC or VTN defines for delegations, and MAY omit
1565 /// it otherwise.
1566 ///
1567 /// That window is governance this library does not know, so it cannot decide for a
1568 /// caller which side of the condition a given VDC falls on — hence a setter rather than
1569 /// a constructor parameter. Prefer short validity and re-issuance wherever the
1570 /// delegator is reachable: a status check is a live lookup that reveals the
1571 /// verification event to whoever hosts the status list. A long-lived appointment made
1572 /// in advance of a delegator's unavailability is the case this exists for.
1573 ///
1574 /// # Set it before signing
1575 ///
1576 /// Same caveat as [DTGCredential::with_id] — a Data Integrity proof covers the
1577 /// credential minus its `proof`, so attaching a status entry to an already-signed
1578 /// credential leaves a document whose proof no longer verifies.
1579 ///
1580 /// # This library does not check it
1581 ///
1582 /// Neither [`crate::delegation::verify_chain`] nor [`crate::authority::verify_chain`]
1583 /// resolves a status entry; both verify structure, scope and validity only. Revocation
1584 /// is a live lookup the caller performs.
1585 ///
1586 /// # Security
1587 ///
1588 /// The entry is embedded verbatim and nothing about it is checked when it is attached.
1589 /// It tells a verifier where to look, so a verifier must check the credential's proof
1590 /// before following it, and should apply its own policy to where it leads. Nesting past
1591 /// [crate::MAX_JSON_DEPTH] is refused when the credential is validated, digested or
1592 /// signed, because this setter cannot return an error.
1593 pub fn with_credential_status(mut self, status: Value) -> Self {
1594 self.credential.credential_status = Some(status);
1595 self
1596 }
1597
1598 /// Attaches a revocation status mechanism in place.
1599 ///
1600 /// The non-consuming form of [DTGCredential::with_credential_status]; the same "before
1601 /// signing" caveat, the same CONDITIONAL rule and the same security notes apply.
1602 pub fn set_credential_status(&mut self, status: Value) {
1603 self.credential.credential_status = Some(status);
1604 }
1605}
1606
1607#[cfg(test)]
1608#[allow(deprecated)]
1609mod tests {
1610 use crate::{DTGCredential, WitnessContext};
1611 use chrono::{DateTime, Utc};
1612 use serde_json::json;
1613
1614 #[test]
1615 fn test_vmc_serialization() {
1616 let vmc = DTGCredential::new_vmc(
1617 "did:example:issuer".to_string(),
1618 "did:example:subject".to_string(),
1619 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1620 .unwrap()
1621 .with_timezone(&Utc),
1622 None,
1623 false,
1624 );
1625
1626 let txt = serde_json::to_string_pretty(&vmc).unwrap();
1627 let sample = r#"{
1628 "@context": [
1629 "https://www.w3.org/ns/credentials/v2",
1630 "https://firstperson.network/credentials/dtg/v1"
1631 ],
1632 "type": [
1633 "VerifiableCredential",
1634 "DTGCredential",
1635 "MembershipCredential"
1636 ],
1637 "issuer": "did:example:issuer",
1638 "validFrom": "2025-12-11T00:00:00Z",
1639 "credentialSubject": {
1640 "id": "did:example:subject"
1641 }
1642}"#;
1643
1644 assert_eq!(txt, sample);
1645 }
1646
1647 #[test]
1648 fn test_vmc_phc_serialization() {
1649 let vmc = DTGCredential::new_vmc(
1650 "did:example:issuer".to_string(),
1651 "did:example:subject".to_string(),
1652 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1653 .unwrap()
1654 .with_timezone(&Utc),
1655 None,
1656 true,
1657 );
1658
1659 let txt = serde_json::to_string_pretty(&vmc).unwrap();
1660 let sample = r#"{
1661 "@context": [
1662 "https://www.w3.org/ns/credentials/v2",
1663 "https://firstperson.network/credentials/dtg/v1"
1664 ],
1665 "type": [
1666 "VerifiableCredential",
1667 "DTGCredential",
1668 "MembershipCredential",
1669 "PersonhoodCredential"
1670 ],
1671 "issuer": "did:example:issuer",
1672 "validFrom": "2025-12-11T00:00:00Z",
1673 "credentialSubject": {
1674 "id": "did:example:subject"
1675 }
1676}"#;
1677
1678 assert_eq!(txt, sample);
1679 }
1680 /// `id` is OPTIONAL, and a credential that was never given one must keep serializing the
1681 /// shape it always did — no `"id": null`, no empty string.
1682 #[test]
1683 fn test_vmc_without_id_omits_the_property() {
1684 let vmc = DTGCredential::new_vmc(
1685 "did:example:issuer".to_string(),
1686 "did:example:subject".to_string(),
1687 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1688 .unwrap()
1689 .with_timezone(&Utc),
1690 None,
1691 false,
1692 );
1693
1694 assert_eq!(vmc.id(), None);
1695 let value: serde_json::Value = serde_json::to_value(&vmc).unwrap();
1696 assert!(
1697 value.get("id").is_none(),
1698 "an unset id must not appear on the wire at all: {value}"
1699 );
1700 }
1701
1702 /// `with_id` puts the identifier at the top level of the credential — a sibling of
1703 /// `issuer`, not something nested under `credentialSubject` (which carries the *subject's*
1704 /// id, a different thing entirely).
1705 #[test]
1706 fn test_vmc_with_id_serialization() {
1707 let vmc = DTGCredential::new_vmc(
1708 "did:example:issuer".to_string(),
1709 "did:example:subject".to_string(),
1710 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1711 .unwrap()
1712 .with_timezone(&Utc),
1713 None,
1714 false,
1715 )
1716 .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
1717
1718 let txt = serde_json::to_string_pretty(&vmc).unwrap();
1719 let sample = r#"{
1720 "@context": [
1721 "https://www.w3.org/ns/credentials/v2",
1722 "https://firstperson.network/credentials/dtg/v1"
1723 ],
1724 "type": [
1725 "VerifiableCredential",
1726 "DTGCredential",
1727 "MembershipCredential"
1728 ],
1729 "id": "urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff",
1730 "issuer": "did:example:issuer",
1731 "validFrom": "2025-12-11T00:00:00Z",
1732 "credentialSubject": {
1733 "id": "did:example:subject"
1734 }
1735}"#;
1736
1737 assert_eq!(txt, sample);
1738 }
1739
1740 /// The identifier has to survive a round trip. It arrives on the wire and is read back
1741 /// through `TryFrom<DTGCommon>`, which is where `taskContext` was previously being dropped
1742 /// — a field that deserializes into nothing breaks signing and verification silently.
1743 #[test]
1744 fn test_id_round_trips_through_deserialization() {
1745 let vmc = DTGCredential::new_vmc(
1746 "did:example:issuer".to_string(),
1747 "did:example:subject".to_string(),
1748 Utc::now(),
1749 None,
1750 false,
1751 )
1752 .with_id("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff");
1753
1754 let txt = serde_json::to_string(&vmc).unwrap();
1755 let parsed: DTGCredential = serde_json::from_str(&txt).unwrap();
1756 assert_eq!(
1757 parsed.id(),
1758 Some("urn:uuid:1e2d3c4b-5a69-4788-9099-aabbccddeeff")
1759 );
1760 }
1761
1762 /// A credential with no `id` still deserializes — the property is OPTIONAL, and every
1763 /// credential issued before this field existed has none.
1764 #[test]
1765 fn test_missing_id_deserializes_as_none() {
1766 let parsed: DTGCredential = serde_json::from_str(
1767 r#"{
1768 "@context": ["https://www.w3.org/ns/credentials/v2"],
1769 "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"],
1770 "issuer": "did:example:issuer",
1771 "validFrom": "2025-12-11T00:00:00Z",
1772 "credentialSubject": { "id": "did:example:subject" }
1773 }"#,
1774 )
1775 .unwrap();
1776 assert_eq!(parsed.id(), None);
1777 }
1778
1779 /// `set_id` is the in-place form of `with_id`; both write the same property.
1780 #[test]
1781 fn test_set_id_matches_with_id() {
1782 let build = || {
1783 DTGCredential::new_vrc(
1784 "did:example:issuer".to_string(),
1785 "did:example:subject".to_string(),
1786 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1787 .unwrap()
1788 .with_timezone(&Utc),
1789 None,
1790 )
1791 };
1792 let mut in_place = build();
1793 in_place.set_id("urn:uuid:abc");
1794 assert_eq!(
1795 serde_json::to_value(&in_place).unwrap(),
1796 serde_json::to_value(build().with_id("urn:uuid:abc")).unwrap()
1797 );
1798 }
1799
1800 #[test]
1801 fn test_vrc_serialization() {
1802 let vrc = DTGCredential::new_vrc(
1803 "did:example:issuer".to_string(),
1804 "did:example:subject".to_string(),
1805 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1806 .unwrap()
1807 .with_timezone(&Utc),
1808 None,
1809 );
1810
1811 let txt = serde_json::to_string_pretty(&vrc).unwrap();
1812 let sample = r#"{
1813 "@context": [
1814 "https://www.w3.org/ns/credentials/v2",
1815 "https://firstperson.network/credentials/dtg/v1"
1816 ],
1817 "type": [
1818 "VerifiableCredential",
1819 "DTGCredential",
1820 "RelationshipCredential"
1821 ],
1822 "issuer": "did:example:issuer",
1823 "validFrom": "2025-12-11T00:00:00Z",
1824 "credentialSubject": {
1825 "id": "did:example:subject"
1826 }
1827}"#;
1828
1829 assert_eq!(txt, sample);
1830 }
1831
1832 #[test]
1833 fn test_vic_serialization() {
1834 let vic = DTGCredential::new_vic(
1835 "did:example:issuer".to_string(),
1836 "did:example:subject".to_string(),
1837 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1838 .unwrap()
1839 .with_timezone(&Utc),
1840 None,
1841 );
1842
1843 let txt = serde_json::to_string_pretty(&vic).unwrap();
1844 let sample = r#"{
1845 "@context": [
1846 "https://www.w3.org/ns/credentials/v2",
1847 "https://firstperson.network/credentials/dtg/v1"
1848 ],
1849 "type": [
1850 "VerifiableCredential",
1851 "DTGCredential",
1852 "InvitationCredential"
1853 ],
1854 "issuer": "did:example:issuer",
1855 "validFrom": "2025-12-11T00:00:00Z",
1856 "credentialSubject": {
1857 "id": "did:example:subject"
1858 }
1859}"#;
1860
1861 assert_eq!(txt, sample);
1862 }
1863
1864 #[test]
1865 fn test_vpc_serialization() {
1866 let vpc = DTGCredential::new_vpc(
1867 "did:example:issuer".to_string(),
1868 "did:example:subject".to_string(),
1869 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1870 .unwrap()
1871 .with_timezone(&Utc),
1872 None,
1873 );
1874
1875 let txt = serde_json::to_string_pretty(&vpc).unwrap();
1876 let sample = r#"{
1877 "@context": [
1878 "https://www.w3.org/ns/credentials/v2",
1879 "https://firstperson.network/credentials/dtg/v1"
1880 ],
1881 "type": [
1882 "VerifiableCredential",
1883 "DTGCredential",
1884 "PersonaCredential"
1885 ],
1886 "issuer": "did:example:issuer",
1887 "validFrom": "2025-12-11T00:00:00Z",
1888 "credentialSubject": {
1889 "id": "did:example:subject"
1890 }
1891}"#;
1892
1893 assert_eq!(txt, sample);
1894 }
1895
1896 #[test]
1897 fn test_vec_serialization() {
1898 let vec = DTGCredential::new_vec(
1899 "did:example:issuer".to_string(),
1900 "did:example:subject".to_string(),
1901 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1902 .unwrap()
1903 .with_timezone(&Utc),
1904 None,
1905 json!({
1906 "type": "SkillEndorsement",
1907 "name": "Software Development",
1908 "competencyLevel": "expert"
1909 }),
1910 );
1911
1912 let txt = serde_json::to_string_pretty(&vec).unwrap();
1913 let sample = r#"{
1914 "@context": [
1915 "https://www.w3.org/ns/credentials/v2",
1916 "https://firstperson.network/credentials/dtg/v1"
1917 ],
1918 "type": [
1919 "VerifiableCredential",
1920 "DTGCredential",
1921 "EndorsementCredential"
1922 ],
1923 "issuer": "did:example:issuer",
1924 "validFrom": "2025-12-11T00:00:00Z",
1925 "credentialSubject": {
1926 "id": "did:example:subject",
1927 "endorsement": {
1928 "competencyLevel": "expert",
1929 "name": "Software Development",
1930 "type": "SkillEndorsement"
1931 }
1932 }
1933}"#;
1934
1935 assert_eq!(txt, sample);
1936 }
1937
1938 #[test]
1939 fn test_vwc_serialization() {
1940 let vwc = DTGCredential::new_vwc(
1941 "did:example:issuer".to_string(),
1942 "did:example:subject".to_string(),
1943 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1944 .unwrap()
1945 .with_timezone(&Utc),
1946 None,
1947 "thread-abc-123".to_string(),
1948 Some("zQmbGXRT3v1RmfWkQ7Y3Z5Uj9pKq2NcXhLd8sVtA4eB6nMw".to_string()),
1949 Some(WitnessContext {
1950 event: Some("EthDenver 2024".to_string()),
1951 session_id: Some("session-8822-nonce".to_string()),
1952 method: Some("in-person-proximity".to_string()),
1953 }),
1954 );
1955
1956 let txt = serde_json::to_string_pretty(&vwc).unwrap();
1957
1958 let sample = r#"{
1959 "@context": [
1960 "https://www.w3.org/ns/credentials/v2",
1961 "https://firstperson.network/credentials/dtg/v1"
1962 ],
1963 "type": [
1964 "VerifiableCredential",
1965 "DTGCredential",
1966 "WitnessCredential"
1967 ],
1968 "issuer": "did:example:issuer",
1969 "validFrom": "2025-12-11T00:00:00Z",
1970 "taskContext": "thread-abc-123",
1971 "credentialSubject": {
1972 "id": "did:example:subject",
1973 "digestMultibase": "zQmbGXRT3v1RmfWkQ7Y3Z5Uj9pKq2NcXhLd8sVtA4eB6nMw",
1974 "witnessContext": {
1975 "event": "EthDenver 2024",
1976 "sessionId": "session-8822-nonce",
1977 "method": "in-person-proximity"
1978 }
1979 }
1980}"#;
1981
1982 assert_eq!(txt, sample);
1983 }
1984
1985 #[test]
1986 fn test_rcard_serialization() {
1987 let rcard = DTGCredential::new_rcard(
1988 "did:example:issuer".to_string(),
1989 "did:example:subject".to_string(),
1990 DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z")
1991 .unwrap()
1992 .with_timezone(&Utc),
1993 None,
1994 json!([
1995 "vcard",
1996 [
1997 ["fn", {}, "text", "Alice Smith"],
1998 ["email", {}, "text", "alice@example.com"]
1999 ]
2000 ]),
2001 );
2002
2003 let txt = serde_json::to_string_pretty(&rcard).unwrap();
2004
2005 let sample = r#"{
2006 "@context": [
2007 "https://www.w3.org/ns/credentials/v2",
2008 "https://firstperson.network/credentials/dtg/v1"
2009 ],
2010 "type": [
2011 "VerifiableCredential",
2012 "DTGCredential",
2013 "RCardCredential"
2014 ],
2015 "issuer": "did:example:issuer",
2016 "validFrom": "2025-12-11T00:00:00Z",
2017 "credentialSubject": {
2018 "id": "did:example:subject",
2019 "card": [
2020 "vcard",
2021 [
2022 [
2023 "fn",
2024 {},
2025 "text",
2026 "Alice Smith"
2027 ],
2028 [
2029 "email",
2030 {},
2031 "text",
2032 "alice@example.com"
2033 ]
2034 ]
2035 ]
2036 }
2037}"#;
2038
2039 assert_eq!(txt, sample);
2040 }
2041}