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//! | `audience`, where set, must be the presenter | a leaked credential used by whoever holds it |
23//! | Depth is bounded | a denial-of-service against the verifier, which walks every link |
24//!
25//! # Bearer-side resolution
26//!
27//! The holder presents every link. This module **never dereferences**
28//! [`AuthorityGrant::parent`] to fetch a credential it was not given, and
29//! [`verify_chain`] takes the chain as a slice for exactly that reason.
30//!
31//! Deliberate, and worth stating because the alternative is attractive until it isn't:
32//! resolving parents over the network would make verification depend on availability, turn
33//! every `id` into a request the verifier can be induced to make against an address the
34//! *holder* chooses, and signal credential use to whoever hosts the identifier. `id` values
35//! in a chain are identifiers, not locators, and need not resolve to anything.
36//!
37//! Tracks a draft: `trustoverip/dtgwg-cred-spec` PR #29.
38
39use chrono::{DateTime, Utc};
40
41use crate::{DTGCredential, DTGCredentialType};
42
43/// Maximum number of VACs in a chain, including the root.
44///
45/// Verification is linear in depth and runs on every presentation, so an unbounded chain is
46/// a denial-of-service surface. The known uses need far less — a person attenuating to an
47/// agent is depth 2, and an agent attenuating to a sub-agent is depth 3 — so a chain near
48/// this ceiling is a signal that authority is being re-delegated further than intended.
49pub const MAX_CHAIN_DEPTH: usize = 8;
50
51/// Why a chain was refused.
52///
53/// Each variant names a specific way of acquiring authority that was not granted, rather
54/// than collapsing into one "invalid" — a verifier's logs are where an escalation attempt
55/// becomes visible.
56#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
57pub enum AuthorityError {
58    /// The chain was empty. Nothing to verify.
59    #[error("authority chain is empty")]
60    EmptyChain,
61
62    /// The chain is longer than [MAX_CHAIN_DEPTH].
63    #[error("authority chain is {found} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}")]
64    TooDeep {
65        /// How many links were presented.
66        found: usize,
67    },
68
69    /// A credential in the chain was not an `AuthorityCredential`.
70    #[error("chain link {index} is a {found}, not an AuthorityCredential")]
71    NotAuthority {
72        /// Position in the chain, leaf first.
73        index: usize,
74        /// What was found instead.
75        found: String,
76    },
77
78    /// The chain root was not issued by the party governing the scope.
79    ///
80    /// This is the finding that matters most: a chain that does not reach the governing
81    /// party is a self-issued grant, however well-formed each link is.
82    #[error(
83        "chain root was issued by `{root_issuer}`, not by `{expected}` which governs the scope"
84    )]
85    RootNotGoverning {
86        /// Who actually issued the root.
87        root_issuer: String,
88        /// Who governs the scope being accessed.
89        expected: String,
90    },
91
92    /// A link's `parent` did not name the credential presented as its parent.
93    #[error("chain link {index} names parent `{named}`, but was presented after `{presented}`")]
94    BrokenLink {
95        /// Position in the chain, leaf first.
96        index: usize,
97        /// The `id` the link points at.
98        named: String,
99        /// The `id` of the credential actually presented as its parent.
100        presented: String,
101    },
102
103    /// A link was issued by someone other than its parent's subject.
104    ///
105    /// Only the party a grant was made to may attenuate it. Without this check a holder
106    /// could graft an unrelated grant onto their own chain.
107    #[error("chain link {index} was issued by `{issuer}`, but its parent granted to `{subject}`")]
108    IssuerNotParentSubject {
109        /// Position in the chain, leaf first.
110        index: usize,
111        /// Who issued the link.
112        issuer: String,
113        /// Who the parent granted to.
114        subject: String,
115    },
116
117    /// A link conferred an action its parent did not.
118    #[error("chain link {index} adds action `{action}`, which its parent does not confer")]
119    WidensActions {
120        /// Position in the chain, leaf first.
121        index: usize,
122        /// The action that was added.
123        action: String,
124    },
125
126    /// A link named a different scope from its parent.
127    #[error("chain link {index} has scope `{scope}`, its parent `{parent_scope}`")]
128    WidensScope {
129        /// Position in the chain, leaf first.
130        index: usize,
131        /// The link's scope.
132        scope: String,
133        /// The parent's scope.
134        parent_scope: String,
135    },
136
137    /// A link outlived its parent.
138    #[error("chain link {index} is valid until {until}, beyond its parent's {parent_until}")]
139    OutlivesParent {
140        /// Position in the chain, leaf first.
141        index: usize,
142        /// The link's expiry.
143        until: DateTime<Utc>,
144        /// The parent's expiry.
145        parent_until: DateTime<Utc>,
146    },
147
148    /// The requested scope is not the one the chain confers on.
149    #[error("chain confers on scope `{granted}`, but `{requested}` was requested")]
150    ScopeMismatch {
151        /// What the chain grants on.
152        granted: String,
153        /// What was asked for.
154        requested: String,
155    },
156
157    /// The chain does not confer the requested action.
158    #[error("chain does not confer action `{action}`")]
159    ActionNotGranted {
160        /// The action that was requested.
161        action: String,
162    },
163
164    /// A link was presented by a party other than its bound audience.
165    #[error("chain link {index} is bound to audience `{audience}`, presented by `{presenter}`")]
166    WrongAudience {
167        /// Position in the chain, leaf first.
168        index: usize,
169        /// Who the link is bound to.
170        audience: String,
171        /// Who presented it.
172        presenter: String,
173    },
174
175    /// A link was outside its validity window at the time of the check.
176    #[error("chain link {index} is not valid at {at}")]
177    NotValidNow {
178        /// Position in the chain, leaf first.
179        index: usize,
180        /// The instant checked against.
181        at: DateTime<Utc>,
182    },
183
184    /// A link carried an empty `actions` list.
185    #[error("chain link {index} confers no actions")]
186    NoActions {
187        /// Position in the chain, leaf first.
188        index: usize,
189    },
190}
191
192/// What a verified chain permits.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct VerifiedAuthority {
195    /// The party the leaf grants to — who may act.
196    pub subject: String,
197    /// The scope the chain confers on.
198    pub scope: String,
199    /// The actions the leaf confers, already narrowed by every link above it.
200    pub actions: Vec<String>,
201    /// The party governing the scope, which issued the chain root.
202    pub governing_party: String,
203}
204
205/// Verify a chain of VACs and return what it permits.
206///
207/// `chain` is **leaf first**: `chain[0]` is the credential being presented, and the last
208/// element must be the root issued by `governing_party`. Every link the holder relies on
209/// must be present — this function never fetches one (see the module docs).
210///
211/// The signature on each credential is *not* checked here. Verify those first, with
212/// [crate::DTGCredential] and the data-integrity suite; this function answers the separate
213/// question of whether a set of cryptographically valid credentials adds up to the
214/// authority claimed. Both checks are required and neither substitutes for the other.
215///
216/// Returns [VerifiedAuthority] describing what the chain actually permits, which is never
217/// more than the root conferred.
218pub fn verify_chain(
219    chain: &[DTGCredential],
220    governing_party: &str,
221    requested_scope: &str,
222    requested_action: &str,
223    presenter: &str,
224    at: DateTime<Utc>,
225) -> Result<VerifiedAuthority, AuthorityError> {
226    if chain.is_empty() {
227        return Err(AuthorityError::EmptyChain);
228    }
229    if chain.len() > MAX_CHAIN_DEPTH {
230        return Err(AuthorityError::TooDeep { found: chain.len() });
231    }
232
233    // Every link must be a VAC carrying a grant.
234    for (index, link) in chain.iter().enumerate() {
235        if !matches!(link.type_(), DTGCredentialType::Authority) {
236            return Err(AuthorityError::NotAuthority {
237                index,
238                found: link.type_().to_string(),
239            });
240        }
241        let grant = link
242            .credential()
243            .authority()
244            .ok_or_else(|| AuthorityError::NotAuthority {
245                index,
246                found: "AuthorityCredential without an authority grant".to_string(),
247            })?;
248        if grant.actions.is_empty() {
249            return Err(AuthorityError::NoActions { index });
250        }
251        // Validity window, checked per link: a chain is only as live as its shortest-lived
252        // member, and an expired parent does not become live again because its child says so.
253        let c = link.credential();
254        if c.valid_from() > at {
255            return Err(AuthorityError::NotValidNow { index, at });
256        }
257        if let Some(until) = c.valid_until()
258            && until < at
259        {
260            return Err(AuthorityError::NotValidNow { index, at });
261        }
262    }
263
264    // The leaf must be presentable by whoever is presenting it.
265    let leaf = &chain[0];
266    let leaf_grant = leaf.credential().authority().expect("checked above");
267    if let Some(audience) = &leaf_grant.audience
268        && audience != presenter
269    {
270        return Err(AuthorityError::WrongAudience {
271            index: 0,
272            audience: audience.clone(),
273            presenter: presenter.to_string(),
274        });
275    }
276
277    // Walk leaf -> root. Each step checks the link against the credential above it.
278    for index in 0..chain.len() - 1 {
279        let link = &chain[index];
280        let parent = &chain[index + 1];
281        let grant = link.credential().authority().expect("checked above");
282        let parent_grant = parent.credential().authority().expect("checked above");
283
284        // The link must point at the credential presented as its parent. Without this a
285        // holder could interleave links from unrelated chains.
286        match (&grant.parent, parent.id()) {
287            (Some(named), Some(presented)) if named == presented => {}
288            (Some(named), presented) => {
289                return Err(AuthorityError::BrokenLink {
290                    index,
291                    named: named.clone(),
292                    presented: presented.unwrap_or("<no id>").to_string(),
293                });
294            }
295            (None, presented) => {
296                // A link with no `parent` claims to be a root, but something was presented
297                // above it.
298                return Err(AuthorityError::BrokenLink {
299                    index,
300                    named: "<none — link claims to be a root>".to_string(),
301                    presented: presented.unwrap_or("<no id>").to_string(),
302                });
303            }
304        }
305
306        // Only the party a grant was made to may attenuate it.
307        if link.credential().issuer() != parent.credential().subject() {
308            return Err(AuthorityError::IssuerNotParentSubject {
309                index,
310                issuer: link.credential().issuer().to_string(),
311                subject: parent.credential().subject().to_string(),
312            });
313        }
314
315        // Narrowing, on all three axes.
316        if grant.scope != parent_grant.scope {
317            return Err(AuthorityError::WidensScope {
318                index,
319                scope: grant.scope.clone(),
320                parent_scope: parent_grant.scope.clone(),
321            });
322        }
323        for action in &grant.actions {
324            if !parent_grant.actions.contains(action) {
325                return Err(AuthorityError::WidensActions {
326                    index,
327                    action: action.clone(),
328                });
329            }
330        }
331        if let (Some(until), Some(parent_until)) = (
332            link.credential().valid_until(),
333            parent.credential().valid_until(),
334        ) && until > parent_until
335        {
336            return Err(AuthorityError::OutlivesParent {
337                index,
338                until,
339                parent_until,
340            });
341        }
342    }
343
344    // The root must be the governing party's, and must claim to be a root.
345    let root = chain.last().expect("non-empty");
346    let root_grant = root.credential().authority().expect("checked above");
347    if root.credential().issuer() != governing_party {
348        return Err(AuthorityError::RootNotGoverning {
349            root_issuer: root.credential().issuer().to_string(),
350            expected: governing_party.to_string(),
351        });
352    }
353    if root_grant.parent.is_some() {
354        // The chain was truncated: its "root" points at something not presented.
355        return Err(AuthorityError::BrokenLink {
356            index: chain.len() - 1,
357            named: root_grant.parent.clone().unwrap_or_default(),
358            presented: "<nothing — chain ends here>".to_string(),
359        });
360    }
361
362    // Finally, what was asked for.
363    if leaf_grant.scope != requested_scope {
364        return Err(AuthorityError::ScopeMismatch {
365            granted: leaf_grant.scope.clone(),
366            requested: requested_scope.to_string(),
367        });
368    }
369    if !leaf_grant.actions.iter().any(|a| a == requested_action) {
370        return Err(AuthorityError::ActionNotGranted {
371            action: requested_action.to_string(),
372        });
373    }
374
375    Ok(VerifiedAuthority {
376        subject: leaf.credential().subject().to_string(),
377        scope: leaf_grant.scope.clone(),
378        actions: leaf_grant.actions.clone(),
379        governing_party: governing_party.to_string(),
380    })
381}