Skip to main content

dtg_credentials/
authority.rs

1//! Verifying a chain of Verifiable Authority Credentials.
2//!
3//! # Why this module is the important one
4//!
5//! Issuing a VAC is a struct and a signature. The security of the whole credential is in
6//! *refusing* a chain that widens — because attenuation is only a narrowing if somebody
7//! walks it. A verifier that checks only the credential it was handed accepts a
8//! **self-issued grant of arbitrary authority**: anyone can mint a VAC naming any scope and
9//! any actions, and it will verify perfectly as a signed credential. What makes it
10//! worthless is that its chain does not reach the party governing the scope.
11//!
12//! So the rules below are not stylistic. Each of them closes a way to get authority you
13//! were not given:
14//!
15//! | Rule | What it stops |
16//! |---|---|
17//! | Chain must reach a root issued by the governing party | a self-issued grant |
18//! | No link may add an action absent from its parent | privilege escalation by re-issue |
19//! | No link may widen `scope` | authority earned in one room used in another |
20//! | No link may outlive its parent | an expiry escaped by re-delegation |
21//! | Each link's issuer must be its parent's subject | grafting someone else's grant onto your own |
22//! | The leaf's subject must be the presenter | a captured presentation replayed by whoever caught it |
23//! | Depth is bounded | a denial-of-service against the verifier, which walks every link |
24//! | Every link must carry `validUntil` | authority nobody can withdraw by waiting |
25//!
26//! # Bearer-side resolution
27//!
28//! The holder presents every link. This module **never dereferences**
29//! [`crate::AuthorityGrant::parent`] to fetch a credential it was not given, and
30//! [`verify_chain`] takes the chain as a slice for exactly that reason.
31//!
32//! Working Draft 02 made that structural rather than merely required: `parent` is a
33//! **digest**, and a digest names nothing that can be fetched. So verification cannot come
34//! to depend on availability, a verifier cannot be induced to make a request against an
35//! address the *holder* chooses, and nobody hosting an identifier learns when a credential
36//! is used. The digest also binds a link to the exact claims its issuer narrowed from,
37//! which an identifier could not do: a parent re-issued with different claims does not
38//! carry its old children with it.
39//!
40//! # A VAC is not a bearer credential
41//!
42//! [`verify_chain`] takes a `presenter` and requires the leaf to grant to it. That is the
43//! rule [PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41) states normatively
44//! — *a verifier MUST NOT accept a party as holding the authority a VAC confers unless that
45//! party demonstrates control of the verification method associated with the presented
46//! VAC's `credentialSubject.id`* — and it is why this module no longer has an `audience`.
47//!
48//! An earlier draft of the VAC carried an OPTIONAL `audience` naming the DID that had to
49//! present the credential, and this module compared it against `presenter`. Once the
50//! presenter must be the subject, that field can only name the same party (adding nothing)
51//! or a different one (satisfiable by nobody), so it was removed rather than kept as a
52//! weaker second check. The destination question it was sometimes read as answering —
53//! *where* may this be presented — is not the credential's to answer; it belongs to the
54//! trust task carrying the presentation, which binds its own recipient.
55//!
56//! **What `presenter` must be.** The identifier of a party whose key control the caller has
57//! already established for *this request* — the DID a transport authenticated, or one a
58//! signature over the request proved. Passing an identifier the caller merely read out of
59//! the request body reduces this check to a string comparison an attacker chooses both
60//! sides of.
61//!
62//! **Only the leaf's subject demonstrates anything.** The parties named in the links above
63//! it are not present and are asked for nothing. Requiring otherwise would defeat
64//! attenuation, whose whole purpose is that the party who attenuated is not in the loop
65//! when its agent acts.
66//!
67//! # Still ahead of this module
68//!
69//! Two changes to the VAC are in flight upstream and are **not** implemented here:
70//! revocation via `credentialStatus`, cascading to everything attenuated below
71//! ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); and a
72//! `maxAttenuation` ceiling bounding depth per-ancestor rather than only globally
73//! ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40)). Until they land, a
74//! caller wanting revocation must check [`crate::DTGCommon::credential_status`] itself.
75
76use chrono::{DateTime, Utc};
77
78use crate::{DTGCredential, DTGCredentialType};
79
80/// Maximum number of VACs in a chain, including the root.
81///
82/// Verification is linear in depth and runs on every presentation, so an unbounded chain is
83/// a denial-of-service surface. The known uses need far less — a person attenuating to an
84/// agent is depth 2, and an agent attenuating to a sub-agent is depth 3 — so a chain near
85/// this ceiling is a signal that authority is being re-delegated further than intended.
86pub const MAX_CHAIN_DEPTH: usize = 8;
87
88/// Why a chain was refused.
89///
90/// Each variant names a specific way of acquiring authority that was not granted, rather
91/// than collapsing into one "invalid" — a verifier's logs are where an escalation attempt
92/// becomes visible.
93#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
94pub enum AuthorityError {
95    /// The chain was empty. Nothing to verify.
96    #[error("authority chain is empty")]
97    EmptyChain,
98
99    /// A link's digest could not be computed, or one it carries could not be read.
100    ///
101    /// Distinct from [AuthorityError::BrokenLink]: a digest that cannot be *read* is not a
102    /// digest that disagrees, and a verifier that conflated the two would report a
103    /// malformed chain as a widening one.
104    #[error("digest error at index {index}: {reason}")]
105    Digest { index: usize, reason: String },
106
107    /// A link carried no `validUntil`, which a VAC MUST have.
108    #[error("VAC at index {index} carries no validUntil, which a VAC MUST have")]
109    NoExpiry { index: usize },
110
111    /// The chain is longer than [MAX_CHAIN_DEPTH].
112    #[error("authority chain is {found} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}")]
113    TooDeep {
114        /// How many links were presented.
115        found: usize,
116    },
117
118    /// A credential in the chain was not an `AuthorityCredential`.
119    #[error("chain link {index} is a {found}, not an AuthorityCredential")]
120    NotAuthority {
121        /// Position in the chain, leaf first.
122        index: usize,
123        /// What was found instead.
124        found: String,
125    },
126
127    /// The chain root was not issued by the party governing the scope.
128    ///
129    /// This is the finding that matters most: a chain that does not reach the governing
130    /// party is a self-issued grant, however well-formed each link is.
131    #[error(
132        "chain root was issued by `{root_issuer}`, not by `{expected}` which governs the scope"
133    )]
134    RootNotGoverning {
135        /// Who actually issued the root.
136        root_issuer: String,
137        /// Who governs the scope being accessed.
138        expected: String,
139    },
140
141    /// A link's `parent` did not name the credential presented as its parent.
142    ///
143    /// Both fields are `digestMultibase` values, not identifiers. Working Draft 02 changed
144    /// `parent` from an `id` to a digest, so a value here that looks like a `urn:uuid:` or
145    /// a WD01 `sha256:<hex>` is a version skew rather than a mismatched chain — see the
146    /// upgrade ordering notes in the README.
147    #[error("chain link {index} names parent `{named}`, but was presented after `{presented}`")]
148    BrokenLink {
149        /// Position in the chain, leaf first.
150        index: usize,
151        /// The `digestMultibase` the link points at, as it was carried.
152        named: String,
153        /// The digest of the credential actually presented as its parent.
154        presented: String,
155    },
156
157    /// A link was issued by someone other than its parent's subject.
158    ///
159    /// Only the party a grant was made to may attenuate it. Without this check a holder
160    /// could graft an unrelated grant onto their own chain.
161    #[error("chain link {index} was issued by `{issuer}`, but its parent granted to `{subject}`")]
162    IssuerNotParentSubject {
163        /// Position in the chain, leaf first.
164        index: usize,
165        /// Who issued the link.
166        issuer: String,
167        /// Who the parent granted to.
168        subject: String,
169    },
170
171    /// A link conferred an action its parent did not.
172    #[error("chain link {index} adds action `{action}`, which its parent does not confer")]
173    WidensActions {
174        /// Position in the chain, leaf first.
175        index: usize,
176        /// The action that was added.
177        action: String,
178    },
179
180    /// A link named a different scope from its parent.
181    #[error("chain link {index} has scope `{scope}`, its parent `{parent_scope}`")]
182    WidensScope {
183        /// Position in the chain, leaf first.
184        index: usize,
185        /// The link's scope.
186        scope: String,
187        /// The parent's scope.
188        parent_scope: String,
189    },
190
191    /// A link outlived its parent.
192    #[error("chain link {index} is valid until {until}, beyond its parent's {parent_until}")]
193    OutlivesParent {
194        /// Position in the chain, leaf first.
195        index: usize,
196        /// The link's expiry.
197        until: DateTime<Utc>,
198        /// The parent's expiry.
199        parent_until: DateTime<Utc>,
200    },
201
202    /// The requested scope is not the one the chain confers on.
203    #[error("chain confers on scope `{granted}`, but `{requested}` was requested")]
204    ScopeMismatch {
205        /// What the chain grants on.
206        granted: String,
207        /// What was asked for.
208        requested: String,
209    },
210
211    /// The chain does not confer the requested action.
212    #[error("chain does not confer action `{action}`")]
213    ActionNotGranted {
214        /// The action that was requested.
215        action: String,
216    },
217
218    /// The leaf grants to somebody other than the party presenting it.
219    ///
220    /// A VAC is evidence that authority was conferred on somebody. It is not evidence that
221    /// whoever handed it over is that somebody, and a verifier that conflated the two would
222    /// authorize every captured presentation.
223    #[error("the chain's leaf grants to `{subject}`, but it was presented by `{presenter}`")]
224    NotThePresenter {
225        /// Who the leaf grants to.
226        subject: String,
227        /// Who presented it.
228        presenter: String,
229    },
230
231    /// A link was outside its validity window at the time of the check.
232    #[error("chain link {index} is not valid at {at}")]
233    NotValidNow {
234        /// Position in the chain, leaf first.
235        index: usize,
236        /// The instant checked against.
237        at: DateTime<Utc>,
238    },
239
240    /// A link carried an empty `actions` list.
241    #[error("chain link {index} confers no actions")]
242    NoActions {
243        /// Position in the chain, leaf first.
244        index: usize,
245    },
246}
247
248/// What a verified chain permits.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct VerifiedAuthority {
251    /// The party the leaf grants to — who may act.
252    pub subject: String,
253    /// The scope the chain confers on.
254    pub scope: String,
255    /// The actions the leaf confers, already narrowed by every link above it.
256    pub actions: Vec<String>,
257    /// The party governing the scope, which issued the chain root.
258    pub governing_party: String,
259}
260
261/// Verify a chain of VACs and return what it permits.
262///
263/// `chain` is **leaf first**: `chain[0]` is the credential being presented, and the last
264/// element must be the root issued by `governing_party`. Every link the holder relies on
265/// must be present — this function never fetches one (see the module docs).
266///
267/// The signature on each credential is *not* checked here. Verify those first, with
268/// [crate::DTGCredential] and the data-integrity suite; this function answers the separate
269/// question of whether a set of cryptographically valid credentials adds up to the
270/// authority claimed. Both checks are required and neither substitutes for the other.
271///
272/// Returns [VerifiedAuthority] describing what the chain actually permits, which is never
273/// more than the root conferred.
274pub fn verify_chain(
275    chain: &[DTGCredential],
276    governing_party: &str,
277    requested_scope: &str,
278    requested_action: &str,
279    presenter: &str,
280    at: DateTime<Utc>,
281) -> Result<VerifiedAuthority, AuthorityError> {
282    if chain.is_empty() {
283        return Err(AuthorityError::EmptyChain);
284    }
285    if chain.len() > MAX_CHAIN_DEPTH {
286        return Err(AuthorityError::TooDeep { found: chain.len() });
287    }
288
289    // Every link must be a VAC carrying a grant.
290    for (index, link) in chain.iter().enumerate() {
291        if !matches!(link.type_(), DTGCredentialType::Authority) {
292            return Err(AuthorityError::NotAuthority {
293                index,
294                found: link.type_().to_string(),
295            });
296        }
297        let grant = link
298            .credential()
299            .authority()
300            .ok_or_else(|| AuthorityError::NotAuthority {
301                index,
302                found: "AuthorityCredential without an authority grant".to_string(),
303            })?;
304        if grant.actions.is_empty() {
305            return Err(AuthorityError::NoActions { index });
306        }
307        // Validity window, checked per link: a chain is only as live as its shortest-lived
308        // member, and an expired parent does not become live again because its child says so.
309        let c = link.credential();
310        if c.valid_from() > at {
311            return Err(AuthorityError::NotValidNow { index, at });
312        }
313        // `validUntil` is REQUIRED on a VAC, not merely recommended. Nothing about the
314        // subject's current standing is consulted here, so a VAC that never expires is
315        // authority nobody can withdraw by waiting — and a verifier that accepted one
316        // would be honouring exactly that.
317        let Some(until) = c.valid_until() else {
318            return Err(AuthorityError::NoExpiry { index });
319        };
320        if until < at {
321            return Err(AuthorityError::NotValidNow { index, at });
322        }
323    }
324
325    // Key control at invocation: the leaf must grant to whoever is presenting it.
326    //
327    // Without this a presentation is a bearer object — it names what may be done, not who
328    // is doing it — so anyone who observes one inherits everything it confers. The check is
329    // only as good as `presenter`: see the module docs on what a caller must have
330    // established before passing one.
331    let leaf = &chain[0];
332    let leaf_grant = leaf.credential().authority().expect("checked above");
333    let leaf_subject = leaf.credential().subject();
334    if leaf_subject != presenter {
335        return Err(AuthorityError::NotThePresenter {
336            subject: leaf_subject.to_string(),
337            presenter: presenter.to_string(),
338        });
339    }
340
341    // Walk leaf -> root. Each step checks the link against the credential above it.
342    for index in 0..chain.len() - 1 {
343        let link = &chain[index];
344        let parent = &chain[index + 1];
345        let grant = link.credential().authority().expect("checked above");
346        let parent_grant = parent.credential().authority().expect("checked above");
347
348        // The link must point at the credential presented as its parent. Without this a
349        // holder could interleave links from unrelated chains.
350        //
351        // `parent` is a digest, not an identifier, so this is a hash comparison over the
352        // parent's claims — and the specification requires comparing decoded digest bytes
353        // rather than encoded strings, since one digest has more than one spelling.
354        let presented_digest = parent
355            .digest_multibase()
356            .map_err(|e| AuthorityError::Digest {
357                index: index + 1,
358                reason: e.to_string(),
359            })?;
360        match &grant.parent {
361            Some(named) => {
362                let matches = crate::digests_match(named, &presented_digest).map_err(|e| {
363                    AuthorityError::Digest {
364                        index,
365                        reason: e.to_string(),
366                    }
367                })?;
368                if !matches {
369                    return Err(AuthorityError::BrokenLink {
370                        index,
371                        named: named.clone(),
372                        presented: presented_digest,
373                    });
374                }
375            }
376            None => {
377                // A link with no `parent` claims to be a root, but something was presented
378                // above it.
379                return Err(AuthorityError::BrokenLink {
380                    index,
381                    named: "<none — link claims to be a root>".to_string(),
382                    presented: presented_digest,
383                });
384            }
385        }
386
387        // Only the party a grant was made to may attenuate it.
388        if link.credential().issuer() != parent.credential().subject() {
389            return Err(AuthorityError::IssuerNotParentSubject {
390                index,
391                issuer: link.credential().issuer().to_string(),
392                subject: parent.credential().subject().to_string(),
393            });
394        }
395
396        // Narrowing, on all three axes.
397        if grant.scope != parent_grant.scope {
398            return Err(AuthorityError::WidensScope {
399                index,
400                scope: grant.scope.clone(),
401                parent_scope: parent_grant.scope.clone(),
402            });
403        }
404        for action in &grant.actions {
405            if !parent_grant.actions.contains(action) {
406                return Err(AuthorityError::WidensActions {
407                    index,
408                    action: action.clone(),
409                });
410            }
411        }
412        // Both are present: the loop above rejected any link without one.
413        if let (Some(until), Some(parent_until)) = (
414            link.credential().valid_until(),
415            parent.credential().valid_until(),
416        ) && until > parent_until
417        {
418            return Err(AuthorityError::OutlivesParent {
419                index,
420                until,
421                parent_until,
422            });
423        }
424    }
425
426    // The root must be the governing party's, and must claim to be a root.
427    let root = chain.last().expect("non-empty");
428    let root_grant = root.credential().authority().expect("checked above");
429    if root.credential().issuer() != governing_party {
430        return Err(AuthorityError::RootNotGoverning {
431            root_issuer: root.credential().issuer().to_string(),
432            expected: governing_party.to_string(),
433        });
434    }
435    if root_grant.parent.is_some() {
436        // The chain was truncated: its "root" points at something not presented.
437        return Err(AuthorityError::BrokenLink {
438            index: chain.len() - 1,
439            named: root_grant.parent.clone().unwrap_or_default(),
440            presented: "<nothing — chain ends here>".to_string(),
441        });
442    }
443
444    // Finally, what was asked for.
445    if leaf_grant.scope != requested_scope {
446        return Err(AuthorityError::ScopeMismatch {
447            granted: leaf_grant.scope.clone(),
448            requested: requested_scope.to_string(),
449        });
450    }
451    if !leaf_grant.actions.iter().any(|a| a == requested_action) {
452        return Err(AuthorityError::ActionNotGranted {
453            action: requested_action.to_string(),
454        });
455    }
456
457    Ok(VerifiedAuthority {
458        subject: leaf.credential().subject().to_string(),
459        scope: leaf_grant.scope.clone(),
460        actions: leaf_grant.actions.clone(),
461        governing_party: governing_party.to_string(),
462    })
463}