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//! | 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//! # Still ahead of this module
41//!
42//! Three changes to the VAC are in flight upstream and are **not** implemented here:
43//! revocation via `credentialStatus`, cascading to everything attenuated below
44//! ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); a `maxAttenuation`
45//! ceiling bounding depth per-ancestor rather than only globally
46//! ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40)); and a key-control
47//! demonstration at invocation, which removes `audience` as redundant
48//! ([PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41)). Until they land, a
49//! caller wanting revocation must check [`crate::DTGCommon::credential_status`] itself, and
50//! a chain verified here is not evidence that the party presenting it is the leaf's
51//! subject.
52
53use chrono::{DateTime, Utc};
54
55use crate::{DTGCredential, DTGCredentialType};
56
57/// Maximum number of VACs in a chain, including the root.
58///
59/// Verification is linear in depth and runs on every presentation, so an unbounded chain is
60/// a denial-of-service surface. The known uses need far less — a person attenuating to an
61/// agent is depth 2, and an agent attenuating to a sub-agent is depth 3 — so a chain near
62/// this ceiling is a signal that authority is being re-delegated further than intended.
63pub const MAX_CHAIN_DEPTH: usize = 8;
64
65/// Why a chain was refused.
66///
67/// Each variant names a specific way of acquiring authority that was not granted, rather
68/// than collapsing into one "invalid" — a verifier's logs are where an escalation attempt
69/// becomes visible.
70#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
71pub enum AuthorityError {
72    /// The chain was empty. Nothing to verify.
73    #[error("authority chain is empty")]
74    EmptyChain,
75
76    /// A link's digest could not be computed, or one it carries could not be read.
77    ///
78    /// Distinct from [AuthorityError::BrokenLink]: a digest that cannot be *read* is not a
79    /// digest that disagrees, and a verifier that conflated the two would report a
80    /// malformed chain as a widening one.
81    #[error("digest error at index {index}: {reason}")]
82    Digest { index: usize, reason: String },
83
84    /// A link carried no `validUntil`, which a VAC MUST have.
85    #[error("VAC at index {index} carries no validUntil, which a VAC MUST have")]
86    NoExpiry { index: usize },
87
88    /// The chain is longer than [MAX_CHAIN_DEPTH].
89    #[error("authority chain is {found} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}")]
90    TooDeep {
91        /// How many links were presented.
92        found: usize,
93    },
94
95    /// A credential in the chain was not an `AuthorityCredential`.
96    #[error("chain link {index} is a {found}, not an AuthorityCredential")]
97    NotAuthority {
98        /// Position in the chain, leaf first.
99        index: usize,
100        /// What was found instead.
101        found: String,
102    },
103
104    /// The chain root was not issued by the party governing the scope.
105    ///
106    /// This is the finding that matters most: a chain that does not reach the governing
107    /// party is a self-issued grant, however well-formed each link is.
108    #[error(
109        "chain root was issued by `{root_issuer}`, not by `{expected}` which governs the scope"
110    )]
111    RootNotGoverning {
112        /// Who actually issued the root.
113        root_issuer: String,
114        /// Who governs the scope being accessed.
115        expected: String,
116    },
117
118    /// A link's `parent` did not name the credential presented as its parent.
119    #[error("chain link {index} names parent `{named}`, but was presented after `{presented}`")]
120    BrokenLink {
121        /// Position in the chain, leaf first.
122        index: usize,
123        /// The `id` the link points at.
124        named: String,
125        /// The `id` of the credential actually presented as its parent.
126        presented: String,
127    },
128
129    /// A link was issued by someone other than its parent's subject.
130    ///
131    /// Only the party a grant was made to may attenuate it. Without this check a holder
132    /// could graft an unrelated grant onto their own chain.
133    #[error("chain link {index} was issued by `{issuer}`, but its parent granted to `{subject}`")]
134    IssuerNotParentSubject {
135        /// Position in the chain, leaf first.
136        index: usize,
137        /// Who issued the link.
138        issuer: String,
139        /// Who the parent granted to.
140        subject: String,
141    },
142
143    /// A link conferred an action its parent did not.
144    #[error("chain link {index} adds action `{action}`, which its parent does not confer")]
145    WidensActions {
146        /// Position in the chain, leaf first.
147        index: usize,
148        /// The action that was added.
149        action: String,
150    },
151
152    /// A link named a different scope from its parent.
153    #[error("chain link {index} has scope `{scope}`, its parent `{parent_scope}`")]
154    WidensScope {
155        /// Position in the chain, leaf first.
156        index: usize,
157        /// The link's scope.
158        scope: String,
159        /// The parent's scope.
160        parent_scope: String,
161    },
162
163    /// A link outlived its parent.
164    #[error("chain link {index} is valid until {until}, beyond its parent's {parent_until}")]
165    OutlivesParent {
166        /// Position in the chain, leaf first.
167        index: usize,
168        /// The link's expiry.
169        until: DateTime<Utc>,
170        /// The parent's expiry.
171        parent_until: DateTime<Utc>,
172    },
173
174    /// The requested scope is not the one the chain confers on.
175    #[error("chain confers on scope `{granted}`, but `{requested}` was requested")]
176    ScopeMismatch {
177        /// What the chain grants on.
178        granted: String,
179        /// What was asked for.
180        requested: String,
181    },
182
183    /// The chain does not confer the requested action.
184    #[error("chain does not confer action `{action}`")]
185    ActionNotGranted {
186        /// The action that was requested.
187        action: String,
188    },
189
190    /// A link was presented by a party other than its bound audience.
191    #[error("chain link {index} is bound to audience `{audience}`, presented by `{presenter}`")]
192    WrongAudience {
193        /// Position in the chain, leaf first.
194        index: usize,
195        /// Who the link is bound to.
196        audience: String,
197        /// Who presented it.
198        presenter: String,
199    },
200
201    /// A link was outside its validity window at the time of the check.
202    #[error("chain link {index} is not valid at {at}")]
203    NotValidNow {
204        /// Position in the chain, leaf first.
205        index: usize,
206        /// The instant checked against.
207        at: DateTime<Utc>,
208    },
209
210    /// A link carried an empty `actions` list.
211    #[error("chain link {index} confers no actions")]
212    NoActions {
213        /// Position in the chain, leaf first.
214        index: usize,
215    },
216}
217
218/// What a verified chain permits.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct VerifiedAuthority {
221    /// The party the leaf grants to — who may act.
222    pub subject: String,
223    /// The scope the chain confers on.
224    pub scope: String,
225    /// The actions the leaf confers, already narrowed by every link above it.
226    pub actions: Vec<String>,
227    /// The party governing the scope, which issued the chain root.
228    pub governing_party: String,
229}
230
231/// Verify a chain of VACs and return what it permits.
232///
233/// `chain` is **leaf first**: `chain[0]` is the credential being presented, and the last
234/// element must be the root issued by `governing_party`. Every link the holder relies on
235/// must be present — this function never fetches one (see the module docs).
236///
237/// The signature on each credential is *not* checked here. Verify those first, with
238/// [crate::DTGCredential] and the data-integrity suite; this function answers the separate
239/// question of whether a set of cryptographically valid credentials adds up to the
240/// authority claimed. Both checks are required and neither substitutes for the other.
241///
242/// Returns [VerifiedAuthority] describing what the chain actually permits, which is never
243/// more than the root conferred.
244pub fn verify_chain(
245    chain: &[DTGCredential],
246    governing_party: &str,
247    requested_scope: &str,
248    requested_action: &str,
249    presenter: &str,
250    at: DateTime<Utc>,
251) -> Result<VerifiedAuthority, AuthorityError> {
252    if chain.is_empty() {
253        return Err(AuthorityError::EmptyChain);
254    }
255    if chain.len() > MAX_CHAIN_DEPTH {
256        return Err(AuthorityError::TooDeep { found: chain.len() });
257    }
258
259    // Every link must be a VAC carrying a grant.
260    for (index, link) in chain.iter().enumerate() {
261        if !matches!(link.type_(), DTGCredentialType::Authority) {
262            return Err(AuthorityError::NotAuthority {
263                index,
264                found: link.type_().to_string(),
265            });
266        }
267        let grant = link
268            .credential()
269            .authority()
270            .ok_or_else(|| AuthorityError::NotAuthority {
271                index,
272                found: "AuthorityCredential without an authority grant".to_string(),
273            })?;
274        if grant.actions.is_empty() {
275            return Err(AuthorityError::NoActions { index });
276        }
277        // Validity window, checked per link: a chain is only as live as its shortest-lived
278        // member, and an expired parent does not become live again because its child says so.
279        let c = link.credential();
280        if c.valid_from() > at {
281            return Err(AuthorityError::NotValidNow { index, at });
282        }
283        // `validUntil` is REQUIRED on a VAC, not merely recommended. Nothing about the
284        // subject's current standing is consulted here, so a VAC that never expires is
285        // authority nobody can withdraw by waiting — and a verifier that accepted one
286        // would be honouring exactly that.
287        let Some(until) = c.valid_until() else {
288            return Err(AuthorityError::NoExpiry { index });
289        };
290        if until < at {
291            return Err(AuthorityError::NotValidNow { index, at });
292        }
293    }
294
295    // The leaf must be presentable by whoever is presenting it.
296    let leaf = &chain[0];
297    let leaf_grant = leaf.credential().authority().expect("checked above");
298    if let Some(audience) = &leaf_grant.audience
299        && audience != presenter
300    {
301        return Err(AuthorityError::WrongAudience {
302            index: 0,
303            audience: audience.clone(),
304            presenter: presenter.to_string(),
305        });
306    }
307
308    // Walk leaf -> root. Each step checks the link against the credential above it.
309    for index in 0..chain.len() - 1 {
310        let link = &chain[index];
311        let parent = &chain[index + 1];
312        let grant = link.credential().authority().expect("checked above");
313        let parent_grant = parent.credential().authority().expect("checked above");
314
315        // The link must point at the credential presented as its parent. Without this a
316        // holder could interleave links from unrelated chains.
317        //
318        // `parent` is a digest, not an identifier, so this is a hash comparison over the
319        // parent's claims — and the specification requires comparing decoded digest bytes
320        // rather than encoded strings, since one digest has more than one spelling.
321        let presented_digest = parent
322            .digest_multibase()
323            .map_err(|e| AuthorityError::Digest {
324                index: index + 1,
325                reason: e.to_string(),
326            })?;
327        match &grant.parent {
328            Some(named) => {
329                let matches = crate::digests_match(named, &presented_digest).map_err(|e| {
330                    AuthorityError::Digest {
331                        index,
332                        reason: e.to_string(),
333                    }
334                })?;
335                if !matches {
336                    return Err(AuthorityError::BrokenLink {
337                        index,
338                        named: named.clone(),
339                        presented: presented_digest,
340                    });
341                }
342            }
343            None => {
344                // A link with no `parent` claims to be a root, but something was presented
345                // above it.
346                return Err(AuthorityError::BrokenLink {
347                    index,
348                    named: "<none — link claims to be a root>".to_string(),
349                    presented: presented_digest,
350                });
351            }
352        }
353
354        // Only the party a grant was made to may attenuate it.
355        if link.credential().issuer() != parent.credential().subject() {
356            return Err(AuthorityError::IssuerNotParentSubject {
357                index,
358                issuer: link.credential().issuer().to_string(),
359                subject: parent.credential().subject().to_string(),
360            });
361        }
362
363        // Narrowing, on all three axes.
364        if grant.scope != parent_grant.scope {
365            return Err(AuthorityError::WidensScope {
366                index,
367                scope: grant.scope.clone(),
368                parent_scope: parent_grant.scope.clone(),
369            });
370        }
371        for action in &grant.actions {
372            if !parent_grant.actions.contains(action) {
373                return Err(AuthorityError::WidensActions {
374                    index,
375                    action: action.clone(),
376                });
377            }
378        }
379        // Both are present: the loop above rejected any link without one.
380        if let (Some(until), Some(parent_until)) = (
381            link.credential().valid_until(),
382            parent.credential().valid_until(),
383        ) && until > parent_until
384        {
385            return Err(AuthorityError::OutlivesParent {
386                index,
387                until,
388                parent_until,
389            });
390        }
391    }
392
393    // The root must be the governing party's, and must claim to be a root.
394    let root = chain.last().expect("non-empty");
395    let root_grant = root.credential().authority().expect("checked above");
396    if root.credential().issuer() != governing_party {
397        return Err(AuthorityError::RootNotGoverning {
398            root_issuer: root.credential().issuer().to_string(),
399            expected: governing_party.to_string(),
400        });
401    }
402    if root_grant.parent.is_some() {
403        // The chain was truncated: its "root" points at something not presented.
404        return Err(AuthorityError::BrokenLink {
405            index: chain.len() - 1,
406            named: root_grant.parent.clone().unwrap_or_default(),
407            presented: "<nothing — chain ends here>".to_string(),
408        });
409    }
410
411    // Finally, what was asked for.
412    if leaf_grant.scope != requested_scope {
413        return Err(AuthorityError::ScopeMismatch {
414            granted: leaf_grant.scope.clone(),
415            requested: requested_scope.to_string(),
416        });
417    }
418    if !leaf_grant.actions.iter().any(|a| a == requested_action) {
419        return Err(AuthorityError::ActionNotGranted {
420            action: requested_action.to_string(),
421        });
422    }
423
424    Ok(VerifiedAuthority {
425        subject: leaf.credential().subject().to_string(),
426        scope: leaf_grant.scope.clone(),
427        actions: leaf_grant.actions.clone(),
428        governing_party: governing_party.to_string(),
429    })
430}