dtg_credentials/statement.rs
1//! Verifiable Statement Credentials (VSC): one type, many predicates.
2//!
3//! A VSC is the DTG's general-purpose claim — *I witnessed this party issue this
4//! credential*, *I endorse this party's skill*, *I vetted this party's identity*. Each is a
5//! statement a verifier reads and either believes or does not. Rather than give each
6//! predicate a credential type of its own, DTG Core Credentials defines one type,
7//! `StatementCredential`, and lets a **predicate profile** fix the constraints of each
8//! predicate.
9//!
10//! ```text
11//! credentialSubject: { id, predicate, object: { id | digestMultibase | value }, ...profile members }
12//! ```
13//!
14//! # A predicate is an identifier, matched as one
15//!
16//! `predicate` is an absolute IRI in Unicode Normalization Form C, compared byte for byte.
17//! A compact form — a CURIE such as `dtg:witnessed`, a bare JSON-LD term — is malformed,
18//! not unknown: no expansion is performed, so matching never depends on a JSON-LD context.
19//! [check_predicate_iri] is the check.
20//!
21//! Recognizing a predicate is the verifier's configuration, never the credential's: see
22//! [crate::PredicateAcceptList], which fails closed.
23//!
24//! # The core profiles
25//!
26//! | Constant | `object` | `taskContext` | minimum `issuerScope` | Constructor |
27//! |---|---|---|---|---|
28//! | [ENDORSES_V1] | `value` | OPTIONAL | — | [DTGCredential::new_endorses_vsc] |
29//! | [WITNESSED_V1] | `digestMultibase` | REQUIRED | `directed` | [DTGCredential::new_witnessed_vsc] |
30//! | [VETTED_V1] | `value` | REQUIRED | `directed` | [DTGCredential::new_vetted_vsc] |
31//! | [PRESENTED_V1] | `digestMultibase` | REQUIRED | `directed` | [DTGCredential::new_presented_vsc] |
32//!
33//! A statement under one of these is checked against its profile when it is parsed,
34//! built, validated and signed. A statement under any other predicate is only checked for
35//! shape; whether it means anything is the verifier's accept-list to say.
36//!
37//! # A statement attests; it never establishes
38//!
39//! Whatever its predicate says, a VSC does not confer representation, authority,
40//! membership, admission or personhood, and is not proof that a trust task completed. A
41//! predicate named `mayActFor` is a string.
42
43use chrono::{DateTime, Utc};
44use serde::{Deserialize, Serialize};
45use serde_json::Value;
46
47use crate::create::{check_window, check_witness_session, issuer_of};
48use crate::{
49 CredentialSubject, DTGCommon, DTGCredential, DTGCredentialError, DTGCredentialType,
50 IssuerScope, WitnessContext,
51};
52
53/// `dtg:endorses` — the issuer asserts something favourable about the subject: a skill, a
54/// standing, a reputation. A VSC under this profile is a verifiable endorsement credential
55/// (VEC).
56///
57/// `object` is a `value` whose schema the governing community's endorsement vocabulary
58/// defines. `taskContext` is OPTIONAL and the issuer's scope unconstrained.
59pub const ENDORSES_V1: &str = "https://registry.trustoverip.org/dtg/vsc/endorses/1";
60
61/// `dtg:witnessed` — the issuer attests that it observed the subject **issue** the
62/// credential `object.digestMultibase` names, under the conditions of the trust task
63/// exchange `taskContext` names. A VSC under this profile is a verifiable witness
64/// credential (VWC).
65///
66/// `credentialSubject.id` MUST be that credential's `issuer`; `taskContext` and
67/// `taskDigestMultibase` are REQUIRED; `issuerScope` is `directed` at minimum. It MAY carry
68/// a `witnessContext`.
69pub const WITNESSED_V1: &str = "https://registry.trustoverip.org/dtg/vsc/witnessed/1";
70
71/// `dtg:vetted` — the issuer attests that it checked the subject's claimed identity in a
72/// vetting session, by the method and against the document classes `object.value` states.
73///
74/// The profile of DTG Core Credentials' identity-vetting worked example. `taskContext` and
75/// `taskDigestMultibase` are REQUIRED — the vetting exchange a dispute would examine — and
76/// `issuerScope` is `directed` at minimum. This library is generic about the payload: it
77/// is `object.value`, and its schema lives with the registry definition.
78pub const VETTED_V1: &str = "https://registry.trustoverip.org/dtg/vsc/vetted/1";
79
80/// `dtg:presented` — the issuer attests that it observed the subject **present** the
81/// credential `object.digestMultibase` names, in the exchange `taskContext` names.
82///
83/// The counterpart [WITNESSED_V1] points to: there the subject is the referenced
84/// credential's issuer, here it is its **subject** — `credentialSubject.id` MUST be the
85/// `credentialSubject.id` of the credential the digest names. `taskContext` and
86/// `taskDigestMultibase` are REQUIRED; `issuerScope` is `directed` at minimum.
87pub const PRESENTED_V1: &str = "https://registry.trustoverip.org/dtg/vsc/presented/1";
88
89/// Which of the three `object` members a statement carries.
90#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
91#[serde(rename_all = "camelCase")]
92pub enum ObjectKind {
93 /// `object.id` — a DID or other IRI, when the object is a party or a named thing.
94 Id,
95 /// `object.digestMultibase` — when the object is another credential.
96 DigestMultibase,
97 /// `object.value` — a literal or structured payload whose schema the profile states.
98 Value,
99}
100
101impl std::fmt::Display for ObjectKind {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 f.write_str(match self {
104 ObjectKind::Id => "id",
105 ObjectKind::DigestMultibase => "digestMultibase",
106 ObjectKind::Value => "value",
107 })
108 }
109}
110
111/// What a statement says about its subject: **exactly one** of `id`, `digestMultibase` or
112/// `value`.
113///
114/// An object carrying two of them, or none, or any other member, is refused at parse.
115#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
116#[serde(rename_all = "camelCase")]
117pub enum StatementObject {
118 /// A DID or other IRI naming a party or a thing.
119 Id(String),
120
121 /// The digest of another credential, as [crate::digest_multibase_json] computes it
122 /// over that credential excluding its top-level `proof`.
123 DigestMultibase(String),
124
125 /// A literal or structured payload. Any JSON, including `null`.
126 ///
127 /// Defined in the DTG context as `@json`, so it is an opaque JSON literal canonicalized
128 /// by JCS rather than a nested graph.
129 Value(Value),
130}
131
132impl StatementObject {
133 /// Which of the three members this is.
134 pub fn kind(&self) -> ObjectKind {
135 match self {
136 StatementObject::Id(_) => ObjectKind::Id,
137 StatementObject::DigestMultibase(_) => ObjectKind::DigestMultibase,
138 StatementObject::Value(_) => ObjectKind::Value,
139 }
140 }
141
142 /// `object.id`, if that is what this is.
143 pub fn id(&self) -> Option<&str> {
144 match self {
145 StatementObject::Id(id) => Some(id),
146 _ => None,
147 }
148 }
149
150 /// `object.digestMultibase`, if that is what this is.
151 pub fn digest_multibase(&self) -> Option<&str> {
152 match self {
153 StatementObject::DigestMultibase(digest) => Some(digest),
154 _ => None,
155 }
156 }
157
158 /// `object.value`, if that is what this is.
159 pub fn value(&self) -> Option<&Value> {
160 match self {
161 StatementObject::Value(value) => Some(value),
162 _ => None,
163 }
164 }
165}
166
167/// Verifiable Statement Credential subject.
168#[derive(Serialize, Deserialize, Debug, Clone)]
169#[serde(rename_all = "camelCase")]
170pub struct CredentialSubjectStatement {
171 /// DID of the node the statement is about.
172 pub id: String,
173
174 /// The absolute IRI fixing the statement's meaning. See [check_predicate_iri].
175 pub predicate: String,
176
177 /// What the statement says about the subject.
178 pub object: StatementObject,
179
180 /// Context of the witnessing event — the OPTIONAL additional member of [WITNESSED_V1].
181 ///
182 /// Modelled because the v1 context defines it and the core profile names it. Carried
183 /// through a round trip on any statement, and meaningful under `witnessed/1` only.
184 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub witness_context: Option<WitnessContext>,
186
187 /// Members a profile adds that this library does not model, preserved verbatim.
188 ///
189 /// A verifier MUST ignore additional members a profile does not define, so that a
190 /// profile can add optional ones without invalidating credentials for older verifiers.
191 /// They are kept rather than dropped so that a digest over a parsed statement agrees
192 /// with the one over its wire form.
193 #[serde(flatten)]
194 pub extra: serde_json::Map<String, Value>,
195}
196
197/// The machine-checkable constraints of one predicate profile.
198///
199/// The four core profiles are [PredicateProfile::core]. A community profile is expressed
200/// through [crate::PredicateAcceptList], whose registry entries carry the same constraints.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct PredicateProfile {
203 /// The predicate IRI.
204 pub iri: &'static str,
205 /// The `object` kinds the profile permits.
206 pub object_kinds: &'static [ObjectKind],
207 /// Whether `taskContext` — and with it `taskDigestMultibase` — is REQUIRED.
208 pub task_context_required: bool,
209 /// The narrowest `issuerScope` the issuer can truthfully declare, if the profile sets
210 /// one.
211 pub minimum_issuer_scope: Option<IssuerScope>,
212}
213
214const CORE_PROFILES: [PredicateProfile; 4] = [
215 PredicateProfile {
216 iri: ENDORSES_V1,
217 object_kinds: &[ObjectKind::Value],
218 task_context_required: false,
219 minimum_issuer_scope: None,
220 },
221 PredicateProfile {
222 iri: WITNESSED_V1,
223 object_kinds: &[ObjectKind::DigestMultibase],
224 task_context_required: true,
225 minimum_issuer_scope: Some(IssuerScope::Directed),
226 },
227 PredicateProfile {
228 iri: VETTED_V1,
229 object_kinds: &[ObjectKind::Value],
230 task_context_required: true,
231 minimum_issuer_scope: Some(IssuerScope::Directed),
232 },
233 PredicateProfile {
234 iri: PRESENTED_V1,
235 object_kinds: &[ObjectKind::DigestMultibase],
236 task_context_required: true,
237 minimum_issuer_scope: Some(IssuerScope::Directed),
238 },
239];
240
241impl PredicateProfile {
242 /// The core profile for `iri` — [ENDORSES_V1], [WITNESSED_V1], [VETTED_V1] or
243 /// [PRESENTED_V1] — matched byte for byte.
244 pub fn core(iri: &str) -> Option<&'static PredicateProfile> {
245 CORE_PROFILES.iter().find(|profile| profile.iri == iri)
246 }
247
248 /// Every core profile this library implements.
249 pub fn all_core() -> &'static [PredicateProfile] {
250 &CORE_PROFILES
251 }
252
253 /// Checks a statement against this profile's constraints.
254 ///
255 /// Does not check the subject–object relationship, which needs the credential the
256 /// object names: see [DTGCredential::witnesses_issuance_of] and
257 /// [DTGCredential::witnesses_presentation_of].
258 pub(crate) fn check(
259 &self,
260 common: &DTGCommon,
261 subject: &CredentialSubjectStatement,
262 ) -> Result<(), DTGCredentialError> {
263 check_constraints(
264 self.object_kinds,
265 self.task_context_required,
266 self.minimum_issuer_scope,
267 common,
268 subject,
269 )
270 }
271}
272
273/// The checks a profile or an accept-list entry applies, whichever holds them.
274pub(crate) fn check_constraints(
275 object_kinds: &[ObjectKind],
276 task_context_required: bool,
277 minimum_issuer_scope: Option<IssuerScope>,
278 common: &DTGCommon,
279 subject: &CredentialSubjectStatement,
280) -> Result<(), DTGCredentialError> {
281 let kind = subject.object.kind();
282 if !object_kinds.contains(&kind) {
283 return Err(DTGCredentialError::ProfileViolation(format!(
284 "`{}` does not permit an `object.{kind}`",
285 subject.predicate
286 )));
287 }
288 if let Some(minimum) = minimum_issuer_scope
289 && !common.issuer_scope.satisfies(minimum)
290 {
291 return Err(DTGCredentialError::IssuerScopeTooNarrow {
292 declared: common.issuer_scope,
293 minimum,
294 });
295 }
296 if task_context_required {
297 if common.task_context.is_none() {
298 return Err(DTGCredentialError::MissingTaskContext);
299 }
300 if common.task_digest_multibase.is_none() {
301 return Err(DTGCredentialError::MissingTaskDigest);
302 }
303 }
304 Ok(())
305}
306
307/// The statement checks a parse and [DTGCredential::validate] both make: a well-formed
308/// predicate and object, and the constraints of the predicate's core profile where it has
309/// one.
310pub(crate) fn check_statement(
311 common: &DTGCommon,
312 subject: &CredentialSubjectStatement,
313) -> Result<(), DTGCredentialError> {
314 check_predicate_iri(&subject.predicate)?;
315 if let StatementObject::Id(id) = &subject.object
316 && !has_iri_scheme(id)
317 {
318 return Err(DTGCredentialError::ProfileViolation(format!(
319 "`object.id` `{id}` is not a DID or other absolute IRI"
320 )));
321 }
322 match PredicateProfile::core(&subject.predicate) {
323 Some(profile) => profile.check(common, subject),
324 None => Ok(()),
325 }
326}
327
328/// Does `s` begin with an RFC 3986 scheme — `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` —
329/// followed by `:` and something?
330fn has_iri_scheme(s: &str) -> bool {
331 let Some((scheme, rest)) = s.split_once(':') else {
332 return false;
333 };
334 let mut chars = scheme.chars();
335 chars.next().is_some_and(|c| c.is_ascii_alphabetic())
336 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
337 && !rest.is_empty()
338}
339
340/// Checks that `predicate` is a well-formed predicate IRI: absolute, not a compact form,
341/// and in Unicode Normalization Form C.
342///
343/// A verifier MUST reject a credential whose `predicate` is not an absolute IRI, and no
344/// expansion is performed: a compact form is malformed, not unknown. This library applies
345/// that at parse, at issue and when checking against an accept-list.
346///
347/// # Telling an IRI from a CURIE
348///
349/// `dtg:witnessed` is syntactically an absolute IRI with the scheme `dtg`, so syntax alone
350/// cannot refuse it. The rule applied is structural: the IRI must be hierarchical with an
351/// authority (`scheme://authority…`), or use one of the two non-hierarchical schemes a
352/// predicate plausibly lives under, `urn:` and `did:`. A predicate MUST resolve to its
353/// definition, so this admits every predicate a registry or a community can publish, and
354/// refuses every `prefix:term` a JSON-LD context would have to expand.
355///
356/// # Normalization
357///
358/// Comparison is on the IRI as written, so two predicates that render identically but
359/// differ as byte strings are two predicates. An IRI that is not in NFC could never match
360/// its canonical spelling, and is refused rather than normalized.
361///
362/// # Errors
363///
364/// [DTGCredentialError::InvalidPredicate], naming what was wrong.
365pub fn check_predicate_iri(predicate: &str) -> Result<(), DTGCredentialError> {
366 let invalid = |why: &str| {
367 Err(DTGCredentialError::InvalidPredicate(format!(
368 "`{predicate}` {why}"
369 )))
370 };
371
372 if !unicode_normalization::is_nfc(predicate) {
373 return invalid("is not in Unicode Normalization Form C");
374 }
375 if let Some(c) = predicate
376 .chars()
377 .find(|c| c.is_whitespace() || c.is_control() || "<>\"{}|\\^`".contains(*c))
378 {
379 return invalid(&format!("contains {c:?}, which an IRI cannot"));
380 }
381 if !has_iri_scheme(predicate) {
382 return invalid(
383 "is not an absolute IRI — a relative reference or a bare JSON-LD term is never \
384 expanded",
385 );
386 }
387
388 let (scheme, rest) = predicate.split_once(':').expect("has a scheme");
389 let hierarchical = rest
390 .strip_prefix("//")
391 .is_some_and(|authority| !authority.is_empty() && !authority.starts_with('/'));
392 let opaque_scheme = scheme.eq_ignore_ascii_case("urn") || scheme.eq_ignore_ascii_case("did");
393 if !hierarchical && !opaque_scheme {
394 return invalid(
395 "is a compact IRI (CURIE); a predicate is always written as the absolute IRI \
396 and is never expanded against a context",
397 );
398 }
399 Ok(())
400}
401
402impl DTGCredential {
403 /// Creates a Verifiable Statement Credential (VSC) under any predicate.
404 ///
405 /// - `issuer` / `issuer_scope`: the party making the statement, and the correlation
406 /// scope it declares for that identifier.
407 /// - `subject`: the DID of the node the statement is about.
408 /// - `predicate`: the absolute IRI fixing the statement's meaning — one of the core
409 /// constants ([ENDORSES_V1], [WITNESSED_V1], [VETTED_V1], [PRESENTED_V1]) or a
410 /// predicate a community defines in a namespace it controls.
411 /// - `object`: exactly one of `id`, `digestMultibase` or `value`.
412 ///
413 /// Prefer the profile constructors for the core predicates — [Self::new_endorses_vsc],
414 /// [Self::new_witnessed_vsc], [Self::new_vetted_vsc], [Self::new_presented_vsc] — which
415 /// set the citation a profile requires and enforce its subject–object rule. This one
416 /// checks what it can without them: the predicate, the `object` kind and the minimum
417 /// `issuerScope` of a core profile. A profile that REQUIRES `taskContext` is completed
418 /// with [DTGCredential::with_task_citation], and [DTGCredential::validate] — so also
419 /// [DTGCredential::sign] — refuses it until it is.
420 ///
421 /// # Errors
422 ///
423 /// [DTGCredentialError::InvalidPredicate] for a predicate that is not an absolute NFC
424 /// IRI, [DTGCredentialError::ProfileViolation] for an `object` a core profile does not
425 /// permit, [DTGCredentialError::IssuerScopeTooNarrow] for a scope below a core
426 /// profile's minimum, [DTGCredentialError::InvalidValidityWindow] for a window that
427 /// closes before it opens, and [DTGCredentialError::JsonTooDeep] for an `object.value`
428 /// nested past [crate::MAX_JSON_DEPTH].
429 pub fn new_vsc(
430 issuer: String,
431 issuer_scope: IssuerScope,
432 subject: String,
433 predicate: impl Into<String>,
434 object: StatementObject,
435 valid_from: DateTime<Utc>,
436 valid_until: Option<DateTime<Utc>>,
437 ) -> Result<Self, DTGCredentialError> {
438 check_window(valid_from, valid_until)?;
439 let predicate = predicate.into();
440 check_predicate_iri(&predicate)?;
441
442 let vsc = Self::build(
443 DTGCredentialType::Statement,
444 issuer,
445 issuer_scope,
446 valid_from,
447 valid_until,
448 CredentialSubject::Statement(CredentialSubjectStatement {
449 id: subject,
450 predicate,
451 object,
452 witness_context: None,
453 extra: serde_json::Map::new(),
454 }),
455 );
456 vsc.credential.check_depth()?;
457
458 // Everything but the citation, which a caller adds afterwards. The profile check
459 // proper runs again in `validate`, once the citation is there to check.
460 let statement = vsc.statement().expect("built as a statement");
461 check_statement_before_citation(&vsc.credential, statement)?;
462 Ok(vsc)
463 }
464
465 /// Creates a VEC — a statement under [ENDORSES_V1] — endorsing `subject` with
466 /// `endorsement` as `object.value`.
467 ///
468 /// The endorsement's schema is the governing community's vocabulary, and nothing about
469 /// it is checked but its depth. A VEC says only that its issuer said this: a verifier
470 /// must establish that the issuer is one whose endorsements it accepts for this purpose
471 /// before relying on any member of it.
472 ///
473 /// Not a role grant. A role *confers* something, and conferring is a VAC's job: see
474 /// [DTGCredential::new_community_role_vac].
475 ///
476 /// # Errors
477 ///
478 /// As [DTGCredential::new_vsc].
479 pub fn new_endorses_vsc(
480 issuer: String,
481 issuer_scope: IssuerScope,
482 subject: String,
483 endorsement: Value,
484 valid_from: DateTime<Utc>,
485 valid_until: Option<DateTime<Utc>>,
486 ) -> Result<Self, DTGCredentialError> {
487 Self::new_vsc(
488 issuer,
489 issuer_scope,
490 subject,
491 ENDORSES_V1,
492 StatementObject::Value(endorsement),
493 valid_from,
494 valid_until,
495 )
496 }
497
498 /// Creates a VWC — a statement under [WITNESSED_V1] — attesting that the witness
499 /// observed a party issue `witnessed`, in the `witness/session` that `session` opened.
500 ///
501 /// Both halves of every binding are read from the documents themselves, so none of
502 /// them can disagree:
503 ///
504 /// - `credentialSubject.id` is `witnessed`'s `issuer` — the profile's subject–object
505 /// rule: the witness observed the subject *issue* it. One VWC per direction, so a
506 /// witnessed VRC pair takes two calls, one with each VRC.
507 /// - `object.digestMultibase` is the digest of `witnessed` in its wire form, top-level
508 /// `proof` excluded, as [crate::digest_multibase_json] computes it.
509 /// - `taskContext` is the `id` of `session`, and `taskDigestMultibase` its task digest
510 /// (`witness/session/submit` Conformance, item 1).
511 ///
512 /// `issuer` is the witness — a member, or a VTA acting under VTC policy — and must
513 /// declare at least `directed`: a witness's identifier must be recognizable to both
514 /// parties and to the community, so `pairwise` cannot describe it truthfully.
515 ///
516 /// # Errors
517 ///
518 /// - [DTGCredentialError::IssuerScopeTooNarrow] for `pairwise`.
519 /// - [DTGCredentialError::ProfileViolation] if `witnessed` is not a JSON object with an
520 /// `issuer`.
521 /// - [DTGCredentialError::MalformedTaskDocument] if `session` is not an object with a
522 /// string `id`; [DTGCredentialError::NotAWitnessSession] if its `type` is not a
523 /// `witness/session` version or its `threadId` is not its own `id` — which catches
524 /// citing the `submit` document, or the relationship exchange the session is nested
525 /// in.
526 /// - [DTGCredentialError::InvalidValidityWindow] and [DTGCredentialError::JsonTooDeep].
527 ///
528 /// # Security
529 ///
530 /// Pass the session document the witness itself received and answered, not one the
531 /// party supplies alongside its submission, and the edge credential the witness itself
532 /// observed. The digests are load-bearing because the witness signs them.
533 pub fn new_witnessed_vsc(
534 issuer: String,
535 issuer_scope: IssuerScope,
536 witnessed: &Value,
537 session: &Value,
538 valid_from: DateTime<Utc>,
539 valid_until: Option<DateTime<Utc>>,
540 witness_context: Option<WitnessContext>,
541 ) -> Result<Self, DTGCredentialError> {
542 check_window(valid_from, valid_until)?;
543 check_minimum_scope(WITNESSED_V1, issuer_scope)?;
544 check_witness_session(session)?;
545 crate::check_json_depth(witnessed)?;
546
547 let subject = witnessed.as_object().and_then(issuer_of).ok_or_else(|| {
548 DTGCredentialError::ProfileViolation(
549 "the witnessed credential has no `issuer`, so there is no party the \
550 witness observed issuing it"
551 .into(),
552 )
553 })?;
554
555 let mut vwc = Self::new_vsc(
556 issuer,
557 issuer_scope,
558 subject,
559 WITNESSED_V1,
560 StatementObject::DigestMultibase(crate::digest_multibase_json(witnessed)?),
561 valid_from,
562 valid_until,
563 )?;
564 if let Some(statement) = vwc.credential.statement_mut() {
565 statement.witness_context = witness_context;
566 }
567 vwc.with_task_citation(session)
568 }
569
570 /// Creates a vetting statement — a statement under [VETTED_V1] — recording that the
571 /// issuer checked `subject`'s claimed identity in the vetting exchange `session`
572 /// opened.
573 ///
574 /// `vetting` is `object.value`. This library is generic about it: the payload's schema
575 /// is the registry definition's, and a typed payload belongs with the code that fills
576 /// it in. `session` is the initiating document of the vetting exchange;
577 /// `taskContext` and `taskDigestMultibase` are both read from it.
578 ///
579 /// `issuer` is the vetter, issuing under the member identifier its VMC names, and must
580 /// declare at least `directed`. Whether it was *eligible* to vet is a fact about the
581 /// community, checked separately — a community-issued VAC is the credential that
582 /// answers it (see [DTGCredential::new_community_role_vac]).
583 ///
584 /// # Errors
585 ///
586 /// [DTGCredentialError::IssuerScopeTooNarrow] for `pairwise`,
587 /// [DTGCredentialError::MalformedTaskDocument] if `session` has no string `id`, and the
588 /// errors of [DTGCredential::new_vsc].
589 pub fn new_vetted_vsc(
590 issuer: String,
591 issuer_scope: IssuerScope,
592 subject: String,
593 vetting: Value,
594 session: &Value,
595 valid_from: DateTime<Utc>,
596 valid_until: Option<DateTime<Utc>>,
597 ) -> Result<Self, DTGCredentialError> {
598 check_window(valid_from, valid_until)?;
599 check_minimum_scope(VETTED_V1, issuer_scope)?;
600
601 Self::new_vsc(
602 issuer,
603 issuer_scope,
604 subject,
605 VETTED_V1,
606 StatementObject::Value(vetting),
607 valid_from,
608 valid_until,
609 )?
610 .with_task_citation(session)
611 }
612
613 /// Creates a statement under [PRESENTED_V1], attesting that the issuer observed a
614 /// party present `presented` in the exchange `session` opened.
615 ///
616 /// As for [DTGCredential::new_witnessed_vsc], both halves of each binding are read
617 /// from the documents: `credentialSubject.id` is `presented`'s own
618 /// `credentialSubject.id` — the party who holds it, and the profile's subject–object
619 /// rule — `object.digestMultibase` its digest, and the citation `session`'s `id` and
620 /// task digest.
621 ///
622 /// # Errors
623 ///
624 /// [DTGCredentialError::IssuerScopeTooNarrow] for `pairwise`,
625 /// [DTGCredentialError::ProfileViolation] if `presented` has no `credentialSubject.id`,
626 /// [DTGCredentialError::MalformedTaskDocument] if `session` has no string `id`, and the
627 /// errors of [DTGCredential::new_vsc].
628 pub fn new_presented_vsc(
629 issuer: String,
630 issuer_scope: IssuerScope,
631 presented: &Value,
632 session: &Value,
633 valid_from: DateTime<Utc>,
634 valid_until: Option<DateTime<Utc>>,
635 ) -> Result<Self, DTGCredentialError> {
636 check_window(valid_from, valid_until)?;
637 check_minimum_scope(PRESENTED_V1, issuer_scope)?;
638 crate::check_json_depth(presented)?;
639
640 let subject = subject_of(presented).ok_or_else(|| {
641 DTGCredentialError::ProfileViolation(
642 "the presented credential has no `credentialSubject.id`, so there is no \
643 holder to name"
644 .into(),
645 )
646 })?;
647
648 Self::new_vsc(
649 issuer,
650 issuer_scope,
651 subject,
652 PRESENTED_V1,
653 StatementObject::DigestMultibase(crate::digest_multibase_json(presented)?),
654 valid_from,
655 valid_until,
656 )?
657 .with_task_citation(session)
658 }
659
660 /// Is this a [WITNESSED_V1] statement about `credential` — the edge credential in its
661 /// wire form — and does the profile's subject–object rule hold?
662 ///
663 /// True when the predicate is `witnessed/1`, `object.digestMultibase` matches
664 /// `credential`'s recomputed digest (decoded bytes, not strings), and
665 /// `credentialSubject.id` is `credential`'s `issuer`. A verifier holding the referenced
666 /// credential MUST check both, and this is those two checks.
667 ///
668 /// # What this does not check
669 ///
670 /// The statement's proof, its window, whether its issuer is a witness the verifier
671 /// trusts, its `taskContext` against the session (see [DTGCredential::cites_task]), and
672 /// whether `credential` is itself valid: a witness attestation is about claims at the
673 /// moment of witnessing, not about their being current.
674 ///
675 /// # Errors
676 ///
677 /// [DTGCredentialError::InvalidDigest] or [DTGCredentialError::UnsupportedDigestAlgorithm]
678 /// if the carried digest cannot be read, rather than `Ok(false)`.
679 pub fn witnesses_issuance_of(&self, credential: &Value) -> Result<bool, DTGCredentialError> {
680 let named = credential.as_object().and_then(issuer_of);
681 self.statement_names(WITNESSED_V1, credential, named)
682 }
683
684 /// Is this a [PRESENTED_V1] statement about `credential` in its wire form, with
685 /// `credentialSubject.id` equal to `credential`'s own `credentialSubject.id`?
686 ///
687 /// The [PRESENTED_V1] counterpart of [DTGCredential::witnesses_issuance_of], with the
688 /// same caveats.
689 pub fn witnesses_presentation_of(
690 &self,
691 credential: &Value,
692 ) -> Result<bool, DTGCredentialError> {
693 self.statement_names(PRESENTED_V1, credential, subject_of(credential))
694 }
695
696 /// The shared half of the two subject–object checks: the predicate, the digest, and
697 /// the party the statement is about.
698 fn statement_names(
699 &self,
700 predicate: &str,
701 credential: &Value,
702 expected_subject: Option<String>,
703 ) -> Result<bool, DTGCredentialError> {
704 let Some(statement) = self.statement() else {
705 return Ok(false);
706 };
707 let Some(carried) = statement.object.digest_multibase() else {
708 return Ok(false);
709 };
710 if statement.predicate != predicate
711 || expected_subject.as_deref() != Some(statement.id.as_str())
712 {
713 return Ok(false);
714 }
715 crate::digests_match(carried, &crate::digest_multibase_json(credential)?)
716 }
717}
718
719/// The checks [DTGCredential::new_vsc] can make before a citation is attached: every
720/// profile constraint but `taskContext`.
721fn check_statement_before_citation(
722 common: &DTGCommon,
723 subject: &CredentialSubjectStatement,
724) -> Result<(), DTGCredentialError> {
725 check_predicate_iri(&subject.predicate)?;
726 match PredicateProfile::core(&subject.predicate) {
727 Some(profile) => check_constraints(
728 profile.object_kinds,
729 false,
730 profile.minimum_issuer_scope,
731 common,
732 subject,
733 ),
734 None => Ok(()),
735 }
736}
737
738/// Refuses an `issuer_scope` narrower than the core profile of `predicate` permits, before
739/// any document is read.
740fn check_minimum_scope(
741 predicate: &str,
742 issuer_scope: IssuerScope,
743) -> Result<(), DTGCredentialError> {
744 match PredicateProfile::core(predicate).and_then(|p| p.minimum_issuer_scope) {
745 Some(minimum) if !issuer_scope.satisfies(minimum) => {
746 Err(DTGCredentialError::IssuerScopeTooNarrow {
747 declared: issuer_scope,
748 minimum,
749 })
750 }
751 _ => Ok(()),
752 }
753}
754
755/// `credentialSubject.id` of a credential in its wire form.
756fn subject_of(credential: &Value) -> Option<String> {
757 credential
758 .get("credentialSubject")
759 .and_then(|subject| subject.get("id"))
760 .and_then(Value::as_str)
761 .map(str::to_string)
762}