dtg-credentials 0.7.0

Decentralized Trust Graph (DTG) Credentials Library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! Verifying a chain of Verifiable Authority Credentials.
//!
//! # Why this module is the important one
//!
//! Issuing a VAC is a struct and a signature. The security of the whole credential is in
//! *refusing* a chain that widens — because attenuation is only a narrowing if somebody
//! walks it. A verifier that checks only the credential it was handed accepts a
//! **self-issued grant of arbitrary authority**: anyone can mint a VAC naming any scope and
//! any actions, and it will verify perfectly as a signed credential. What makes it
//! worthless is that its chain does not reach the party governing the scope.
//!
//! So the rules below are not stylistic. Each of them closes a way to get authority you
//! were not given:
//!
//! | Rule | What it stops |
//! |---|---|
//! | Chain must reach a root issued by the governing party | a self-issued grant |
//! | No link may add an action absent from its parent | privilege escalation by re-issue |
//! | No link may widen `scope` | authority earned in one room used in another |
//! | No link may outlive its parent | an expiry escaped by re-delegation |
//! | Each link's issuer must be its parent's subject | grafting someone else's grant onto your own |
//! | `audience`, where set, must be the presenter | a leaked credential used by whoever holds it |
//! | Depth is bounded | a denial-of-service against the verifier, which walks every link |
//! | Every link must carry `validUntil` | authority nobody can withdraw by waiting |
//!
//! # Bearer-side resolution
//!
//! The holder presents every link. This module **never dereferences**
//! [`crate::AuthorityGrant::parent`] to fetch a credential it was not given, and
//! [`verify_chain`] takes the chain as a slice for exactly that reason.
//!
//! Working Draft 02 made that structural rather than merely required: `parent` is a
//! **digest**, and a digest names nothing that can be fetched. So verification cannot come
//! to depend on availability, a verifier cannot be induced to make a request against an
//! address the *holder* chooses, and nobody hosting an identifier learns when a credential
//! is used. The digest also binds a link to the exact claims its issuer narrowed from,
//! which an identifier could not do: a parent re-issued with different claims does not
//! carry its old children with it.
//!
//! # Still ahead of this module
//!
//! Three changes to the VAC are in flight upstream and are **not** implemented here:
//! revocation via `credentialStatus`, cascading to everything attenuated below
//! ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); a `maxAttenuation`
//! ceiling bounding depth per-ancestor rather than only globally
//! ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40)); and a key-control
//! demonstration at invocation, which removes `audience` as redundant
//! ([PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41)). Until they land, a
//! caller wanting revocation must check [`crate::DTGCommon::credential_status`] itself, and
//! a chain verified here is not evidence that the party presenting it is the leaf's
//! subject.

use chrono::{DateTime, Utc};

use crate::{DTGCredential, DTGCredentialType};

/// Maximum number of VACs in a chain, including the root.
///
/// Verification is linear in depth and runs on every presentation, so an unbounded chain is
/// a denial-of-service surface. The known uses need far less — a person attenuating to an
/// agent is depth 2, and an agent attenuating to a sub-agent is depth 3 — so a chain near
/// this ceiling is a signal that authority is being re-delegated further than intended.
pub const MAX_CHAIN_DEPTH: usize = 8;

/// Why a chain was refused.
///
/// Each variant names a specific way of acquiring authority that was not granted, rather
/// than collapsing into one "invalid" — a verifier's logs are where an escalation attempt
/// becomes visible.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AuthorityError {
    /// The chain was empty. Nothing to verify.
    #[error("authority chain is empty")]
    EmptyChain,

    /// A link's digest could not be computed, or one it carries could not be read.
    ///
    /// Distinct from [AuthorityError::BrokenLink]: a digest that cannot be *read* is not a
    /// digest that disagrees, and a verifier that conflated the two would report a
    /// malformed chain as a widening one.
    #[error("digest error at index {index}: {reason}")]
    Digest { index: usize, reason: String },

    /// A link carried no `validUntil`, which a VAC MUST have.
    #[error("VAC at index {index} carries no validUntil, which a VAC MUST have")]
    NoExpiry { index: usize },

    /// The chain is longer than [MAX_CHAIN_DEPTH].
    #[error("authority chain is {found} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}")]
    TooDeep {
        /// How many links were presented.
        found: usize,
    },

    /// A credential in the chain was not an `AuthorityCredential`.
    #[error("chain link {index} is a {found}, not an AuthorityCredential")]
    NotAuthority {
        /// Position in the chain, leaf first.
        index: usize,
        /// What was found instead.
        found: String,
    },

    /// The chain root was not issued by the party governing the scope.
    ///
    /// This is the finding that matters most: a chain that does not reach the governing
    /// party is a self-issued grant, however well-formed each link is.
    #[error(
        "chain root was issued by `{root_issuer}`, not by `{expected}` which governs the scope"
    )]
    RootNotGoverning {
        /// Who actually issued the root.
        root_issuer: String,
        /// Who governs the scope being accessed.
        expected: String,
    },

    /// A link's `parent` did not name the credential presented as its parent.
    #[error("chain link {index} names parent `{named}`, but was presented after `{presented}`")]
    BrokenLink {
        /// Position in the chain, leaf first.
        index: usize,
        /// The `id` the link points at.
        named: String,
        /// The `id` of the credential actually presented as its parent.
        presented: String,
    },

    /// A link was issued by someone other than its parent's subject.
    ///
    /// Only the party a grant was made to may attenuate it. Without this check a holder
    /// could graft an unrelated grant onto their own chain.
    #[error("chain link {index} was issued by `{issuer}`, but its parent granted to `{subject}`")]
    IssuerNotParentSubject {
        /// Position in the chain, leaf first.
        index: usize,
        /// Who issued the link.
        issuer: String,
        /// Who the parent granted to.
        subject: String,
    },

    /// A link conferred an action its parent did not.
    #[error("chain link {index} adds action `{action}`, which its parent does not confer")]
    WidensActions {
        /// Position in the chain, leaf first.
        index: usize,
        /// The action that was added.
        action: String,
    },

    /// A link named a different scope from its parent.
    #[error("chain link {index} has scope `{scope}`, its parent `{parent_scope}`")]
    WidensScope {
        /// Position in the chain, leaf first.
        index: usize,
        /// The link's scope.
        scope: String,
        /// The parent's scope.
        parent_scope: String,
    },

    /// A link outlived its parent.
    #[error("chain link {index} is valid until {until}, beyond its parent's {parent_until}")]
    OutlivesParent {
        /// Position in the chain, leaf first.
        index: usize,
        /// The link's expiry.
        until: DateTime<Utc>,
        /// The parent's expiry.
        parent_until: DateTime<Utc>,
    },

    /// The requested scope is not the one the chain confers on.
    #[error("chain confers on scope `{granted}`, but `{requested}` was requested")]
    ScopeMismatch {
        /// What the chain grants on.
        granted: String,
        /// What was asked for.
        requested: String,
    },

    /// The chain does not confer the requested action.
    #[error("chain does not confer action `{action}`")]
    ActionNotGranted {
        /// The action that was requested.
        action: String,
    },

    /// A link was presented by a party other than its bound audience.
    #[error("chain link {index} is bound to audience `{audience}`, presented by `{presenter}`")]
    WrongAudience {
        /// Position in the chain, leaf first.
        index: usize,
        /// Who the link is bound to.
        audience: String,
        /// Who presented it.
        presenter: String,
    },

    /// A link was outside its validity window at the time of the check.
    #[error("chain link {index} is not valid at {at}")]
    NotValidNow {
        /// Position in the chain, leaf first.
        index: usize,
        /// The instant checked against.
        at: DateTime<Utc>,
    },

    /// A link carried an empty `actions` list.
    #[error("chain link {index} confers no actions")]
    NoActions {
        /// Position in the chain, leaf first.
        index: usize,
    },
}

/// What a verified chain permits.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedAuthority {
    /// The party the leaf grants to — who may act.
    pub subject: String,
    /// The scope the chain confers on.
    pub scope: String,
    /// The actions the leaf confers, already narrowed by every link above it.
    pub actions: Vec<String>,
    /// The party governing the scope, which issued the chain root.
    pub governing_party: String,
}

/// Verify a chain of VACs and return what it permits.
///
/// `chain` is **leaf first**: `chain[0]` is the credential being presented, and the last
/// element must be the root issued by `governing_party`. Every link the holder relies on
/// must be present — this function never fetches one (see the module docs).
///
/// The signature on each credential is *not* checked here. Verify those first, with
/// [crate::DTGCredential] and the data-integrity suite; this function answers the separate
/// question of whether a set of cryptographically valid credentials adds up to the
/// authority claimed. Both checks are required and neither substitutes for the other.
///
/// Returns [VerifiedAuthority] describing what the chain actually permits, which is never
/// more than the root conferred.
pub fn verify_chain(
    chain: &[DTGCredential],
    governing_party: &str,
    requested_scope: &str,
    requested_action: &str,
    presenter: &str,
    at: DateTime<Utc>,
) -> Result<VerifiedAuthority, AuthorityError> {
    if chain.is_empty() {
        return Err(AuthorityError::EmptyChain);
    }
    if chain.len() > MAX_CHAIN_DEPTH {
        return Err(AuthorityError::TooDeep { found: chain.len() });
    }

    // Every link must be a VAC carrying a grant.
    for (index, link) in chain.iter().enumerate() {
        if !matches!(link.type_(), DTGCredentialType::Authority) {
            return Err(AuthorityError::NotAuthority {
                index,
                found: link.type_().to_string(),
            });
        }
        let grant = link
            .credential()
            .authority()
            .ok_or_else(|| AuthorityError::NotAuthority {
                index,
                found: "AuthorityCredential without an authority grant".to_string(),
            })?;
        if grant.actions.is_empty() {
            return Err(AuthorityError::NoActions { index });
        }
        // Validity window, checked per link: a chain is only as live as its shortest-lived
        // member, and an expired parent does not become live again because its child says so.
        let c = link.credential();
        if c.valid_from() > at {
            return Err(AuthorityError::NotValidNow { index, at });
        }
        // `validUntil` is REQUIRED on a VAC, not merely recommended. Nothing about the
        // subject's current standing is consulted here, so a VAC that never expires is
        // authority nobody can withdraw by waiting — and a verifier that accepted one
        // would be honouring exactly that.
        let Some(until) = c.valid_until() else {
            return Err(AuthorityError::NoExpiry { index });
        };
        if until < at {
            return Err(AuthorityError::NotValidNow { index, at });
        }
    }

    // The leaf must be presentable by whoever is presenting it.
    let leaf = &chain[0];
    let leaf_grant = leaf.credential().authority().expect("checked above");
    if let Some(audience) = &leaf_grant.audience
        && audience != presenter
    {
        return Err(AuthorityError::WrongAudience {
            index: 0,
            audience: audience.clone(),
            presenter: presenter.to_string(),
        });
    }

    // Walk leaf -> root. Each step checks the link against the credential above it.
    for index in 0..chain.len() - 1 {
        let link = &chain[index];
        let parent = &chain[index + 1];
        let grant = link.credential().authority().expect("checked above");
        let parent_grant = parent.credential().authority().expect("checked above");

        // The link must point at the credential presented as its parent. Without this a
        // holder could interleave links from unrelated chains.
        //
        // `parent` is a digest, not an identifier, so this is a hash comparison over the
        // parent's claims — and the specification requires comparing decoded digest bytes
        // rather than encoded strings, since one digest has more than one spelling.
        let presented_digest = parent
            .digest_multibase()
            .map_err(|e| AuthorityError::Digest {
                index: index + 1,
                reason: e.to_string(),
            })?;
        match &grant.parent {
            Some(named) => {
                let matches = crate::digests_match(named, &presented_digest).map_err(|e| {
                    AuthorityError::Digest {
                        index,
                        reason: e.to_string(),
                    }
                })?;
                if !matches {
                    return Err(AuthorityError::BrokenLink {
                        index,
                        named: named.clone(),
                        presented: presented_digest,
                    });
                }
            }
            None => {
                // A link with no `parent` claims to be a root, but something was presented
                // above it.
                return Err(AuthorityError::BrokenLink {
                    index,
                    named: "<none — link claims to be a root>".to_string(),
                    presented: presented_digest,
                });
            }
        }

        // Only the party a grant was made to may attenuate it.
        if link.credential().issuer() != parent.credential().subject() {
            return Err(AuthorityError::IssuerNotParentSubject {
                index,
                issuer: link.credential().issuer().to_string(),
                subject: parent.credential().subject().to_string(),
            });
        }

        // Narrowing, on all three axes.
        if grant.scope != parent_grant.scope {
            return Err(AuthorityError::WidensScope {
                index,
                scope: grant.scope.clone(),
                parent_scope: parent_grant.scope.clone(),
            });
        }
        for action in &grant.actions {
            if !parent_grant.actions.contains(action) {
                return Err(AuthorityError::WidensActions {
                    index,
                    action: action.clone(),
                });
            }
        }
        // Both are present: the loop above rejected any link without one.
        if let (Some(until), Some(parent_until)) = (
            link.credential().valid_until(),
            parent.credential().valid_until(),
        ) && until > parent_until
        {
            return Err(AuthorityError::OutlivesParent {
                index,
                until,
                parent_until,
            });
        }
    }

    // The root must be the governing party's, and must claim to be a root.
    let root = chain.last().expect("non-empty");
    let root_grant = root.credential().authority().expect("checked above");
    if root.credential().issuer() != governing_party {
        return Err(AuthorityError::RootNotGoverning {
            root_issuer: root.credential().issuer().to_string(),
            expected: governing_party.to_string(),
        });
    }
    if root_grant.parent.is_some() {
        // The chain was truncated: its "root" points at something not presented.
        return Err(AuthorityError::BrokenLink {
            index: chain.len() - 1,
            named: root_grant.parent.clone().unwrap_or_default(),
            presented: "<nothing — chain ends here>".to_string(),
        });
    }

    // Finally, what was asked for.
    if leaf_grant.scope != requested_scope {
        return Err(AuthorityError::ScopeMismatch {
            granted: leaf_grant.scope.clone(),
            requested: requested_scope.to_string(),
        });
    }
    if !leaf_grant.actions.iter().any(|a| a == requested_action) {
        return Err(AuthorityError::ActionNotGranted {
            action: requested_action.to_string(),
        });
    }

    Ok(VerifiedAuthority {
        subject: leaf.credential().subject().to_string(),
        scope: leaf_grant.scope.clone(),
        actions: leaf_grant.actions.clone(),
        governing_party: governing_party.to_string(),
    })
}