Skip to main content

idprova_core/dat/
chain.rs

1//! Delegation chain validation for multi-level delegation (v0.2+).
2//!
3//! In v0.1, only single-level delegation is supported (human → agent).
4//! This module provides the foundation for future chain validation.
5
6use super::scope::ScopeSet;
7use super::token::Dat;
8use crate::{IdprovaError, Result};
9
10/// Configuration for delegation chain validation.
11///
12/// # SR-8: Maximum delegation depth
13///
14/// Without a depth limit, an attacker can construct arbitrarily deep delegation
15/// chains, creating denial-of-service via quadratic chain validation cost and
16/// enabling privilege confusion through deeply nested delegation.
17#[derive(Debug, Clone)]
18pub struct ChainValidationConfig {
19    /// Maximum number of delegation hops allowed (default: 5, hard max: 10).
20    ///
21    /// A depth of 1 means the root (human) issuer → agent (no re-delegation).
22    /// A depth of 2 means human → orchestrator → tool-agent.
23    /// Values above 10 are clamped to 10.
24    pub max_depth: u32,
25}
26
27impl Default for ChainValidationConfig {
28    fn default() -> Self {
29        Self { max_depth: 5 }
30    }
31}
32
33impl ChainValidationConfig {
34    /// The hard maximum depth — cannot be overridden.
35    pub const HARD_MAX_DEPTH: u32 = 10;
36
37    /// Create a config with a specific depth limit (clamped to `HARD_MAX_DEPTH`).
38    pub fn with_max_depth(max_depth: u32) -> Self {
39        Self {
40            max_depth: max_depth.min(Self::HARD_MAX_DEPTH),
41        }
42    }
43}
44
45/// Validates that a delegation chain is valid using default configuration.
46///
47/// For production use, prefer `validate_chain_with_config()` to set explicit depth limits.
48pub fn validate_chain(chain: &[Dat]) -> Result<()> {
49    validate_chain_with_config(chain, &ChainValidationConfig::default())
50}
51
52/// Validates that a delegation chain is valid with explicit configuration.
53///
54/// Checks:
55/// - Chain depth does not exceed `config.max_depth` (SR-8)
56/// - Each DAT was issued by the subject of the previous DAT
57/// - Scopes narrow (or stay equal) at each level
58/// - No DAT in the chain expires after its parent
59/// - Each DAT in the chain is temporally valid
60pub fn validate_chain_with_config(chain: &[Dat], config: &ChainValidationConfig) -> Result<()> {
61    if chain.is_empty() {
62        return Ok(());
63    }
64
65    // SR-8: Enforce maximum delegation depth
66    let depth = chain.len() as u32;
67    let effective_max = config.max_depth.min(ChainValidationConfig::HARD_MAX_DEPTH);
68    if depth > effective_max {
69        return Err(IdprovaError::InvalidDelegationChain(format!(
70            "delegation chain depth {} exceeds maximum allowed depth {}",
71            depth, effective_max
72        )));
73    }
74
75    for i in 1..chain.len() {
76        let parent = &chain[i - 1];
77        let child = &chain[i];
78
79        // The child must have been issued by the parent's subject
80        if child.claims.iss != parent.claims.sub {
81            return Err(IdprovaError::InvalidDelegationChain(format!(
82                "DAT {} was issued by {} but expected {}",
83                child.claims.jti, child.claims.iss, parent.claims.sub
84            )));
85        }
86
87        // Child scopes must be a subset of parent scopes
88        let parent_scopes = ScopeSet::parse(&parent.claims.scope)?;
89        let child_scopes = ScopeSet::parse(&child.claims.scope)?;
90        if !child_scopes.is_subset_of(&parent_scopes) {
91            return Err(IdprovaError::InvalidDelegationChain(format!(
92                "DAT {} has scopes that exceed parent DAT {}",
93                child.claims.jti, parent.claims.jti
94            )));
95        }
96
97        // Child must not expire after parent
98        if child.claims.exp > parent.claims.exp {
99            return Err(IdprovaError::InvalidDelegationChain(format!(
100                "DAT {} expires after parent DAT {}",
101                child.claims.jti, parent.claims.jti
102            )));
103        }
104
105        // Each DAT must be temporally valid
106        child.validate_timing()?;
107    }
108
109    Ok(())
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::crypto::KeyPair;
116    use crate::dat::token::Dat;
117    use chrono::{Duration, Utc};
118
119    fn make_dat(issuer: &str, subject: &str, scopes: Vec<&str>, kp: &KeyPair) -> Dat {
120        Dat::issue(
121            issuer,
122            subject,
123            scopes.into_iter().map(String::from).collect(),
124            Utc::now() + Duration::hours(24),
125            None,
126            None,
127            kp,
128        )
129        .unwrap()
130    }
131
132    fn build_chain(depth: usize) -> Vec<Dat> {
133        let kp = KeyPair::generate();
134        let mut chain = Vec::new();
135        let scopes = vec!["mcp:*:*:*"];
136
137        // Root DAT: human → agent0
138        chain.push(make_dat(
139            "did:idprova:example.com:human",
140            &format!("did:idprova:example.com:agent0"),
141            scopes.clone(),
142            &kp,
143        ));
144
145        // Re-delegations: agent_i → agent_{i+1}
146        for i in 0..depth.saturating_sub(1) {
147            let issuer = format!("did:idprova:example.com:agent{i}");
148            let subject = format!("did:idprova:example.com:agent{}", i + 1);
149            chain.push(make_dat(&issuer, &subject, scopes.clone(), &kp));
150        }
151
152        chain
153    }
154
155    #[test]
156    fn test_chain_depth_5_passes_default_config() {
157        let chain = build_chain(5);
158        assert!(
159            validate_chain(&chain).is_ok(),
160            "chain of depth 5 must pass with default config (max_depth=5)"
161        );
162    }
163
164    /// SR-8: Chain exceeding max_depth must be rejected.
165    #[test]
166    fn test_sr8_chain_depth_6_fails_default_config() {
167        let chain = build_chain(6);
168        assert!(
169            validate_chain(&chain).is_err(),
170            "chain of depth 6 must fail with default config (max_depth=5)"
171        );
172    }
173
174    #[test]
175    fn test_sr8_custom_depth_config() {
176        let chain = build_chain(8);
177        let config = ChainValidationConfig::with_max_depth(8);
178        assert!(
179            validate_chain_with_config(&chain, &config).is_ok(),
180            "chain of depth 8 must pass with max_depth=8"
181        );
182
183        let chain9 = build_chain(9);
184        assert!(
185            validate_chain_with_config(&chain9, &config).is_err(),
186            "chain of depth 9 must fail with max_depth=8"
187        );
188    }
189
190    /// SR-8: Hard max of 10 cannot be bypassed by setting max_depth higher.
191    #[test]
192    fn test_sr8_hard_max_depth_10_cannot_be_exceeded() {
193        // Config requesting 20 is clamped to 10
194        let config = ChainValidationConfig::with_max_depth(20);
195        assert_eq!(
196            config.max_depth,
197            ChainValidationConfig::HARD_MAX_DEPTH,
198            "max_depth=20 must be clamped to HARD_MAX_DEPTH=10"
199        );
200
201        let chain11 = build_chain(11);
202        assert!(
203            validate_chain_with_config(&chain11, &config).is_err(),
204            "chain of depth 11 must fail even with max_depth config of 20 (clamped to 10)"
205        );
206    }
207
208    #[test]
209    fn test_chain_depth_10_passes_hard_max() {
210        let chain = build_chain(10);
211        let config = ChainValidationConfig::with_max_depth(10);
212        assert!(
213            validate_chain_with_config(&chain, &config).is_ok(),
214            "chain of depth 10 must pass with max_depth=10 (HARD_MAX)"
215        );
216    }
217}