dtg_credentials/accept.rs
1//! Which predicates a verifier accepts: the configuration that turns a well-formed VSC
2//! into a meaningful one.
3//!
4//! A VSC whose signature and status verify is not thereby meaningful. DTG Core Credentials
5//! §Predicate Handling requires a verifier to reject a statement whose predicate is not in
6//! a vocabulary it has been configured to accept, and gives rejection as the only
7//! conforming outcome:
8//!
9//! - **Exact match.** A predicate is compared byte for byte with the IRIs configured
10//! here. There is no prefix, namespace or case-insensitive match.
11//! - **No equivalence.** An `owl:sameAs`, `skos:exactMatch` or similar assertion published
12//! by anyone is not followed. Whether two predicates are treated alike is a governance
13//! decision, and it is made by putting both in the list.
14//! - **Never from the credential.** The list is the verifier's; nothing a credential
15//! carries adds to it.
16//!
17//! A well-formed statement under an unrecognized predicate is the intended shape of an
18//! attack that names authority, membership or personhood in a string, so
19//! [PredicateAcceptList::accept] fails closed: anything it cannot positively accept is an
20//! error.
21//!
22//! # Two ways to build one
23//!
24//! - [PredicateAcceptList::from_iris], from a list of IRIs the verifier's governance names.
25//! - [PredicateAcceptList::from_registry_json], from the machine-readable `accept-list.json`
26//! the DTG VSC Predicate Registry publishes, keeping the entries whose status the verifier
27//! admits. Entries carry the profile's machine-checkable constraints — permitted `object`
28//! kinds, whether `taskContext` is required, a minimum `issuerScope`, REQUIRED additional
29//! members — and `accept` applies them.
30
31use std::collections::BTreeMap;
32
33use serde::{Deserialize, Serialize};
34
35use crate::statement::check_constraints;
36use crate::{
37 DTGCredential, DTGCredentialError, DTGCredentialType, IssuerScope, ObjectKind,
38 check_predicate_iri,
39};
40
41/// A predicate's lifecycle status in the registry.
42///
43/// The registry lists every status and leaves the floor to the verifier; `candidate` and
44/// above is its recommended default.
45#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
46#[serde(rename_all = "lowercase")]
47pub enum PredicateStatus {
48 Draft,
49 Candidate,
50 Standard,
51 Deprecated,
52}
53
54/// An additional `credentialSubject` member a profile defines.
55#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57pub struct AdditionalMember {
58 /// Whether a statement under the predicate MUST carry it.
59 pub required: bool,
60
61 /// A JSON Schema for the member, if the profile publishes one. Not applied here.
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub schema: Option<String>,
64}
65
66/// One predicate's entry in the registry's accept-list: its status and machine-checkable
67/// constraints.
68///
69/// # Unknown members are refused
70///
71/// Deliberately. A registry that adds a constraint this library does not know would
72/// otherwise have it silently ignored, and a verifier would accept statements the registry
73/// says it must not. Refusing the document makes that a configuration error rather than a
74/// fail-open.
75#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct AcceptListEntry {
78 pub status: PredicateStatus,
79 pub object_kind: Vec<ObjectKind>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub object_schema: Option<String>,
82 pub task_context_required: bool,
83 pub minimum_issuer_scope: Option<IssuerScope>,
84 pub additional_members: BTreeMap<String, AdditionalMember>,
85 pub superseded_by: Option<String>,
86}
87
88/// The registry's `accept-list.json`, per its `meta/accept-list.schema.json`.
89///
90/// Unknown members of the envelope are ignored, so build metadata the registry adds or
91/// drops (it dropped `revision` when it stopped tagging releases) never breaks loading.
92/// Entries stay strict: an unknown member there could be a constraint this version does
93/// not know how to apply, and failing closed is the only safe reading of it.
94#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
95#[serde(rename_all = "camelCase")]
96pub struct RegistryAcceptList {
97 #[serde(rename = "$schema")]
98 pub schema: String,
99 pub namespace: String,
100 /// The registry commit the list was built from. Pin this, or the list's digest.
101 pub commit: String,
102 pub generated_at: String,
103 /// Every published predicate version, keyed by IRI.
104 pub predicates: BTreeMap<String, AcceptListEntry>,
105}
106
107/// The predicates a verifier accepts. See the [module docs](self).
108#[derive(Debug, Clone, Default, PartialEq, Eq)]
109pub struct PredicateAcceptList {
110 /// IRI → the constraints to apply, where the configuration carries any.
111 predicates: BTreeMap<String, Option<AcceptListEntry>>,
112}
113
114impl PredicateAcceptList {
115 /// An accept-list naming exactly these predicate IRIs.
116 ///
117 /// No constraints beyond the match are attached, but a statement under a core
118 /// predicate ([crate::WITNESSED_V1] and the others) is still held to its profile,
119 /// because that is checked whenever a VSC is parsed or validated.
120 ///
121 /// # Errors
122 ///
123 /// [DTGCredentialError::InvalidPredicate] for an entry that is not an absolute NFC IRI:
124 /// a list containing one could never match a well-formed statement, and is more likely
125 /// a configuration mistake than an intent.
126 pub fn from_iris<I, S>(iris: I) -> Result<Self, DTGCredentialError>
127 where
128 I: IntoIterator<Item = S>,
129 S: Into<String>,
130 {
131 let mut predicates = BTreeMap::new();
132 for iri in iris {
133 let iri = iri.into();
134 check_predicate_iri(&iri)?;
135 predicates.insert(iri, None);
136 }
137 Ok(PredicateAcceptList { predicates })
138 }
139
140 /// An accept-list of the registry entries whose status is one of `statuses`, each
141 /// carrying its constraints.
142 ///
143 /// `statuses` is the verifier's floor, stated explicitly — `&[Candidate, Standard]` for
144 /// the registry's recommended default. An empty slice accepts nothing.
145 ///
146 /// # Errors
147 ///
148 /// [DTGCredentialError::InvalidPredicate] for a key that is not an absolute NFC IRI.
149 pub fn from_registry(
150 list: &RegistryAcceptList,
151 statuses: &[PredicateStatus],
152 ) -> Result<Self, DTGCredentialError> {
153 let mut predicates = BTreeMap::new();
154 for (iri, entry) in &list.predicates {
155 if statuses.contains(&entry.status) {
156 check_predicate_iri(iri)?;
157 predicates.insert(iri.clone(), Some(entry.clone()));
158 }
159 }
160 Ok(PredicateAcceptList { predicates })
161 }
162
163 /// [PredicateAcceptList::from_registry] over the registry's `accept-list.json` text.
164 ///
165 /// # Errors
166 ///
167 /// [DTGCredentialError::MalformedAcceptList] if the document does not have the
168 /// registry's shape — including a member this library does not know — and the errors
169 /// of [PredicateAcceptList::from_registry].
170 pub fn from_registry_json(
171 json: &str,
172 statuses: &[PredicateStatus],
173 ) -> Result<Self, DTGCredentialError> {
174 let list: RegistryAcceptList = serde_json::from_str(json)
175 .map_err(|e| DTGCredentialError::MalformedAcceptList(e.to_string()))?;
176 Self::from_registry(&list, statuses)
177 }
178
179 /// Is `predicate` accepted, by exact byte comparison?
180 pub fn contains(&self, predicate: &str) -> bool {
181 self.predicates.contains_key(predicate)
182 }
183
184 /// The accepted predicate IRIs.
185 pub fn iris(&self) -> impl Iterator<Item = &str> {
186 self.predicates.keys().map(String::as_str)
187 }
188
189 /// The registry constraints attached to `predicate`, if it is accepted and the list was
190 /// built from the registry.
191 pub fn entry(&self, predicate: &str) -> Option<&AcceptListEntry> {
192 self.predicates.get(predicate).and_then(Option::as_ref)
193 }
194
195 /// How many predicates are accepted.
196 pub fn len(&self) -> usize {
197 self.predicates.len()
198 }
199
200 /// Does this list accept nothing?
201 pub fn is_empty(&self) -> bool {
202 self.predicates.is_empty()
203 }
204
205 /// Accepts `vsc` or says why not, failing closed. Returns the accepted predicate.
206 ///
207 /// In order:
208 ///
209 /// 1. `vsc` must be a `StatementCredential`, else
210 /// [DTGCredentialError::WrongCredentialType].
211 /// 2. It must pass [DTGCredential::validate] — well-formed predicate, the core profile
212 /// where there is one, the window's ordering and the JSON depth bound.
213 /// 3. Its `predicate` must be in this list, byte for byte, else
214 /// [DTGCredentialError::PredicateNotAccepted].
215 /// 4. Where the entry carries registry constraints, they must hold: the `object` kind
216 /// ([DTGCredentialError::ProfileViolation]), `taskContext` and `taskDigestMultibase`
217 /// ([DTGCredentialError::MissingTaskContext], [DTGCredentialError::MissingTaskDigest]),
218 /// the minimum `issuerScope` ([DTGCredentialError::IssuerScopeTooNarrow]) and every
219 /// REQUIRED additional member ([DTGCredentialError::ProfileViolation]).
220 ///
221 /// # What this does not check
222 ///
223 /// The proof, whether the window contains the present instant, revocation, whether the
224 /// issuer is one the verifier trusts for this predicate, and any subject–object rule
225 /// needing the credential the object names — [DTGCredential::witnesses_issuance_of] and
226 /// [DTGCredential::witnesses_presentation_of] are those. Nor does acceptance widen what a
227 /// statement means: a VSC attests and never establishes, and a verifier MUST NOT draw a
228 /// conclusion its profile does not state.
229 pub fn accept<'a>(&self, vsc: &'a DTGCredential) -> Result<&'a str, DTGCredentialError> {
230 let (true, Some(statement)) =
231 (vsc.type_() == DTGCredentialType::Statement, vsc.statement())
232 else {
233 return Err(DTGCredentialError::WrongCredentialType {
234 expected: DTGCredentialType::Statement.to_string(),
235 got: vsc.type_().to_string(),
236 });
237 };
238 vsc.validate()?;
239
240 let Some(entry) = self.predicates.get(&statement.predicate) else {
241 return Err(DTGCredentialError::PredicateNotAccepted(
242 statement.predicate.clone(),
243 ));
244 };
245
246 if let Some(entry) = entry {
247 check_constraints(
248 &entry.object_kind,
249 entry.task_context_required,
250 entry.minimum_issuer_scope,
251 vsc.credential(),
252 statement,
253 )?;
254 for (member, definition) in &entry.additional_members {
255 let present = match member.as_str() {
256 "witnessContext" => statement.witness_context.is_some(),
257 other => statement.extra.contains_key(other),
258 };
259 if definition.required && !present {
260 return Err(DTGCredentialError::ProfileViolation(format!(
261 "`{}` requires `credentialSubject.{member}`",
262 statement.predicate
263 )));
264 }
265 }
266 }
267
268 Ok(&statement.predicate)
269 }
270}