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}