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