Skip to main content

clasp_caps/
validator.rs

1//! Capability token validator for CLASP router integration
2//!
3//! Implements `TokenValidator` so capability tokens can be validated
4//! alongside existing CPSK tokens via `ValidatorChain`.
5
6use clasp_core::security::{Action, Scope, TokenInfo, TokenValidator, ValidationResult};
7use std::collections::HashMap;
8use std::time::{Duration, UNIX_EPOCH};
9
10use crate::error::CapError;
11use crate::token::{CapabilityToken, TOKEN_PREFIX};
12
13/// Validates CLASP capability tokens.
14///
15/// Integrates with `ValidatorChain` to support `cap_` prefixed tokens
16/// alongside existing `cpsk_` and `ext_` tokens.
17pub struct CapabilityValidator {
18    /// Trusted root issuer public keys
19    trust_anchors: Vec<Vec<u8>>,
20    /// Maximum delegation chain depth
21    max_depth: usize,
22}
23
24impl CapabilityValidator {
25    /// Create a new capability validator.
26    ///
27    /// `trust_anchors` are the Ed25519 public keys of trusted root issuers.
28    /// Only tokens whose delegation chain ultimately leads to a trust anchor
29    /// will be accepted.
30    pub fn new(trust_anchors: Vec<Vec<u8>>, max_depth: usize) -> Self {
31        Self {
32            trust_anchors,
33            max_depth,
34        }
35    }
36
37    /// Add a trust anchor (root issuer public key)
38    pub fn add_trust_anchor(&mut self, public_key: Vec<u8>) {
39        self.trust_anchors.push(public_key);
40    }
41
42    /// Validate a capability token and return the result
43    fn validate_token(&self, token_str: &str) -> std::result::Result<CapabilityToken, CapError> {
44        // Decode the token
45        let token = CapabilityToken::decode(token_str)?;
46
47        // Check expiration
48        if token.is_expired() {
49            return Err(CapError::Expired);
50        }
51
52        // Check chain depth
53        if token.chain_depth() > self.max_depth {
54            return Err(CapError::ChainTooDeep {
55                depth: token.chain_depth(),
56                max: self.max_depth,
57            });
58        }
59
60        // Verify signature
61        token.verify_signature()?;
62
63        // Verify the delegation chain root leads to a trust anchor
64        let root_issuer = if token.proofs.is_empty() {
65            &token.issuer
66        } else {
67            &token.proofs[0].issuer
68        };
69
70        if !self
71            .trust_anchors
72            .iter()
73            .any(|anchor| anchor == root_issuer)
74        {
75            return Err(CapError::UntrustedIssuer(hex::encode(root_issuer)));
76        }
77
78        // Verify scope attenuation through the chain
79        if !token.proofs.is_empty() {
80            // Check each link: child scopes must be subset of parent scopes
81            for i in 1..token.proofs.len() {
82                let parent = &token.proofs[i - 1];
83                let child = &token.proofs[i];
84                for scope in &child.scopes {
85                    if !scope_within_parent(scope, &parent.scopes) {
86                        return Err(CapError::AttenuationViolation(format!(
87                            "scope '{}' at depth {} exceeds parent",
88                            scope, i
89                        )));
90                    }
91                }
92            }
93
94            // Check final token's scopes against last proof
95            let last_proof = token.proofs.last().unwrap();
96            for scope in &token.scopes {
97                if !scope_within_parent(scope, &last_proof.scopes) {
98                    return Err(CapError::AttenuationViolation(format!(
99                        "token scope '{}' exceeds last delegation",
100                        scope
101                    )));
102                }
103            }
104        }
105
106        Ok(token)
107    }
108}
109
110/// Check if a scope string is allowed by any of the parent scopes
111fn scope_within_parent(scope: &str, parent_scopes: &[String]) -> bool {
112    let Some((child_action, child_pattern)) = scope.split_once(':') else {
113        return false;
114    };
115
116    for parent in parent_scopes {
117        let Some((parent_action, parent_pattern)) = parent.split_once(':') else {
118            continue;
119        };
120
121        let action_ok = match parent_action {
122            "admin" => true,
123            "write" => child_action == "write" || child_action == "read",
124            "read" => child_action == "read",
125            _ => parent_action == child_action,
126        };
127
128        if action_ok && crate::token::pattern_is_subset(child_pattern, parent_pattern) {
129            return true;
130        }
131    }
132
133    false
134}
135
136/// Hex encoding helper (minimal, avoids a dependency)
137mod hex {
138    pub fn encode(bytes: &[u8]) -> String {
139        bytes.iter().map(|b| format!("{:02x}", b)).collect()
140    }
141}
142
143impl TokenValidator for CapabilityValidator {
144    fn validate(&self, token: &str) -> ValidationResult {
145        // Only handle cap_ tokens
146        if !token.starts_with(TOKEN_PREFIX) {
147            return ValidationResult::NotMyToken;
148        }
149
150        match self.validate_token(token) {
151            Ok(cap_token) => {
152                // Convert capability scopes to CLASP Scope objects
153                let scopes: Vec<Scope> = cap_token
154                    .scopes
155                    .iter()
156                    .filter_map(|s| {
157                        let (action_str, pattern) = s.split_once(':')?;
158                        let action = match action_str {
159                            "admin" => Action::Admin,
160                            "write" => Action::Write,
161                            "read" => Action::Read,
162                            _ => return None,
163                        };
164                        Scope::new(action, pattern).ok()
165                    })
166                    .collect();
167
168                let expires_at = if cap_token.expires_at > 0 {
169                    Some(UNIX_EPOCH + Duration::from_secs(cap_token.expires_at))
170                } else {
171                    None
172                };
173
174                let mut metadata = HashMap::new();
175                metadata.insert(
176                    "chain_depth".to_string(),
177                    cap_token.chain_depth().to_string(),
178                );
179
180                let info = TokenInfo {
181                    token_id: cap_token.nonce.clone(),
182                    subject: None, // Capability tokens are bearer tokens
183                    scopes,
184                    expires_at,
185                    metadata,
186                };
187
188                ValidationResult::Valid(info)
189            }
190            Err(CapError::Expired) => ValidationResult::Expired,
191            Err(e) => ValidationResult::Invalid(e.to_string()),
192        }
193    }
194
195    fn name(&self) -> &str {
196        "Capability"
197    }
198
199    fn as_any(&self) -> &dyn std::any::Any {
200        self
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::token::CapabilityToken;
208    use ed25519_dalek::SigningKey;
209    use std::time::{SystemTime, UNIX_EPOCH};
210
211    fn future_timestamp() -> u64 {
212        SystemTime::now()
213            .duration_since(UNIX_EPOCH)
214            .unwrap()
215            .as_secs()
216            + 3600
217    }
218
219    fn root_key() -> SigningKey {
220        SigningKey::from_bytes(&[1u8; 32])
221    }
222
223    fn make_validator() -> CapabilityValidator {
224        let key = root_key();
225        let pub_key = key.verifying_key().to_bytes().to_vec();
226        CapabilityValidator::new(vec![pub_key], 5)
227    }
228
229    #[test]
230    fn test_validate_root_token() {
231        let validator = make_validator();
232        let key = root_key();
233
234        let token = CapabilityToken::create_root(
235            &key,
236            vec!["admin:/**".to_string()],
237            future_timestamp(),
238            None,
239        )
240        .unwrap();
241
242        let encoded = token.encode().unwrap();
243        match validator.validate(&encoded) {
244            ValidationResult::Valid(info) => {
245                assert!(!info.scopes.is_empty());
246                assert!(info.has_scope(Action::Admin, "/anything"));
247            }
248            other => panic!("expected Valid, got {:?}", other),
249        }
250    }
251
252    #[test]
253    fn test_validate_delegated_token() {
254        let validator = make_validator();
255        let root_key = root_key();
256        let child_key = SigningKey::from_bytes(&[2u8; 32]);
257
258        let root = CapabilityToken::create_root(
259            &root_key,
260            vec!["admin:/**".to_string()],
261            future_timestamp(),
262            None,
263        )
264        .unwrap();
265
266        let child = root
267            .delegate(
268                &child_key,
269                vec!["write:/lights/**".to_string()],
270                future_timestamp(),
271                None,
272            )
273            .unwrap();
274
275        let encoded = child.encode().unwrap();
276        match validator.validate(&encoded) {
277            ValidationResult::Valid(info) => {
278                assert!(info.has_scope(Action::Write, "/lights/room1"));
279                assert!(!info.has_scope(Action::Write, "/audio/channel1"));
280            }
281            other => panic!("expected Valid, got {:?}", other),
282        }
283    }
284
285    #[test]
286    fn test_reject_untrusted_issuer() {
287        let validator = make_validator();
288        let untrusted_key = SigningKey::from_bytes(&[99u8; 32]);
289
290        let token = CapabilityToken::create_root(
291            &untrusted_key,
292            vec!["admin:/**".to_string()],
293            future_timestamp(),
294            None,
295        )
296        .unwrap();
297
298        let encoded = token.encode().unwrap();
299        match validator.validate(&encoded) {
300            ValidationResult::Invalid(msg) => {
301                assert!(msg.contains("untrusted"));
302            }
303            other => panic!("expected Invalid, got {:?}", other),
304        }
305    }
306
307    #[test]
308    fn test_not_my_token() {
309        let validator = make_validator();
310        match validator.validate("cpsk_something") {
311            ValidationResult::NotMyToken => {}
312            other => panic!("expected NotMyToken, got {:?}", other),
313        }
314    }
315
316    #[test]
317    fn test_expired_token() {
318        let validator = make_validator();
319        let key = root_key();
320
321        // Create a token that's already expired
322        let token = CapabilityToken::create_root(
323            &key,
324            vec!["admin:/**".to_string()],
325            0, // Expired at epoch
326            None,
327        )
328        .unwrap();
329
330        let encoded = token.encode().unwrap();
331        match validator.validate(&encoded) {
332            ValidationResult::Expired => {}
333            other => panic!("expected Expired, got {:?}", other),
334        }
335    }
336
337    // --- Negative tests ---
338
339    #[test]
340    fn test_malformed_token_bad_base64() {
341        let validator = make_validator();
342        match validator.validate("cap_!!!not-valid-base64!!!") {
343            ValidationResult::Invalid(msg) => {
344                assert!(
345                    msg.contains("encoding"),
346                    "expected encoding error, got: {}",
347                    msg
348                );
349            }
350            other => panic!("expected Invalid, got {:?}", other),
351        }
352    }
353
354    #[test]
355    fn test_malformed_token_truncated() {
356        let validator = make_validator();
357        // Valid prefix + valid base64, but truncated msgpack
358        use base64::Engine;
359        let truncated = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x92, 0x01, 0x02]);
360        match validator.validate(&format!("cap_{}", truncated)) {
361            ValidationResult::Invalid(_) => {}
362            other => panic!("expected Invalid, got {:?}", other),
363        }
364    }
365
366    #[test]
367    fn test_signature_tampered_token() {
368        let validator = make_validator();
369        let key = root_key();
370
371        let mut token = CapabilityToken::create_root(
372            &key,
373            vec!["admin:/**".to_string()],
374            future_timestamp(),
375            None,
376        )
377        .unwrap();
378
379        // Tamper with signature
380        token.signature[0] ^= 0xFF;
381        let encoded = token.encode().unwrap();
382        match validator.validate(&encoded) {
383            ValidationResult::Invalid(msg) => {
384                assert!(
385                    msg.contains("signature"),
386                    "expected signature error, got: {}",
387                    msg
388                );
389            }
390            other => panic!("expected Invalid, got {:?}", other),
391        }
392    }
393
394    #[test]
395    fn test_chain_depth_exceeds_max() {
396        // Create a validator with max_depth = 1
397        let key = root_key();
398        let pub_key = key.verifying_key().to_bytes().to_vec();
399        let validator = CapabilityValidator::new(vec![pub_key], 1);
400
401        let key_b = SigningKey::from_bytes(&[2u8; 32]);
402        let key_c = SigningKey::from_bytes(&[3u8; 32]);
403
404        let root = CapabilityToken::create_root(
405            &key,
406            vec!["admin:/**".to_string()],
407            future_timestamp(),
408            None,
409        )
410        .unwrap();
411
412        let child = root
413            .delegate(
414                &key_b,
415                vec!["write:/**".to_string()],
416                future_timestamp(),
417                None,
418            )
419            .unwrap();
420
421        let grandchild = child
422            .delegate(
423                &key_c,
424                vec!["read:/**".to_string()],
425                future_timestamp(),
426                None,
427            )
428            .unwrap();
429
430        // grandchild has chain_depth 2, which exceeds max_depth 1
431        let encoded = grandchild.encode().unwrap();
432        match validator.validate(&encoded) {
433            ValidationResult::Invalid(msg) => {
434                assert!(
435                    msg.contains("deep") || msg.contains("chain"),
436                    "expected chain depth error, got: {}",
437                    msg
438                );
439            }
440            other => panic!("expected Invalid, got {:?}", other),
441        }
442    }
443
444    #[test]
445    fn test_multiple_trust_anchors() {
446        let key_a = root_key();
447        let key_b = SigningKey::from_bytes(&[42u8; 32]);
448
449        let pub_a = key_a.verifying_key().to_bytes().to_vec();
450        let pub_b = key_b.verifying_key().to_bytes().to_vec();
451
452        let validator = CapabilityValidator::new(vec![pub_a, pub_b], 5);
453
454        // Token from anchor A
455        let token_a = CapabilityToken::create_root(
456            &key_a,
457            vec!["admin:/**".to_string()],
458            future_timestamp(),
459            None,
460        )
461        .unwrap();
462        let encoded_a = token_a.encode().unwrap();
463        assert!(matches!(
464            validator.validate(&encoded_a),
465            ValidationResult::Valid(_)
466        ));
467
468        // Token from anchor B
469        let token_b = CapabilityToken::create_root(
470            &key_b,
471            vec!["read:/**".to_string()],
472            future_timestamp(),
473            None,
474        )
475        .unwrap();
476        let encoded_b = token_b.encode().unwrap();
477        assert!(matches!(
478            validator.validate(&encoded_b),
479            ValidationResult::Valid(_)
480        ));
481
482        // Token from untrusted anchor C
483        let key_c = SigningKey::from_bytes(&[99u8; 32]);
484        let token_c = CapabilityToken::create_root(
485            &key_c,
486            vec!["admin:/**".to_string()],
487            future_timestamp(),
488            None,
489        )
490        .unwrap();
491        let encoded_c = token_c.encode().unwrap();
492        assert!(matches!(
493            validator.validate(&encoded_c),
494            ValidationResult::Invalid(_)
495        ));
496    }
497}