Skip to main content

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}