Skip to main content

clasp_caps/
token.rs

1//! Capability token types and operations
2//!
3//! Implements UCAN-inspired delegatable tokens where each token in a
4//! delegation chain can only narrow (attenuate) scopes, never widen.
5
6use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
7use serde::{Deserialize, Serialize};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use crate::error::{CapError, Result};
11
12/// A CLASP capability token.
13///
14/// Token format: `cap_<base64url(messagepack(CapabilityToken))>`
15///
16/// Tokens form delegation chains where each child can only narrow
17/// the parent's scopes, never widen them.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CapabilityToken {
20    /// Token version (currently 1)
21    pub version: u8,
22    /// Issuer's public key (Ed25519, 32 bytes)
23    pub issuer: Vec<u8>,
24    /// Audience public key (None = bearer token)
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub audience: Option<Vec<u8>>,
27    /// Scopes granted (same "action:pattern" format as existing CLASP scopes)
28    pub scopes: Vec<String>,
29    /// Expiration time (Unix timestamp, seconds)
30    pub expires_at: u64,
31    /// Unique nonce to prevent replay
32    pub nonce: String,
33    /// Proof chain: signatures of parent tokens in the delegation chain
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub proofs: Vec<ProofLink>,
36    /// Signature over the token payload (by issuer)
37    #[serde(with = "serde_bytes")]
38    pub signature: Vec<u8>,
39}
40
41/// A link in the proof/delegation chain
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ProofLink {
44    /// The parent token's issuer public key
45    pub issuer: Vec<u8>,
46    /// The parent token's scopes (for attenuation checking)
47    pub scopes: Vec<String>,
48    /// Signature of the parent token
49    #[serde(with = "serde_bytes")]
50    pub signature: Vec<u8>,
51}
52
53/// Token prefix
54pub const TOKEN_PREFIX: &str = "cap_";
55
56impl CapabilityToken {
57    /// Create and sign a new root capability token (no parent).
58    pub fn create_root(
59        signing_key: &SigningKey,
60        scopes: Vec<String>,
61        expires_at: u64,
62        audience: Option<Vec<u8>>,
63    ) -> Result<Self> {
64        let issuer = signing_key.verifying_key().to_bytes().to_vec();
65        let nonce = uuid::Uuid::new_v4().to_string();
66
67        let mut token = Self {
68            version: 1,
69            issuer,
70            audience,
71            scopes,
72            expires_at,
73            nonce,
74            proofs: vec![],
75            signature: vec![],
76        };
77
78        // Sign the token
79        let payload = token.signable_payload()?;
80        let signature = signing_key.sign(&payload);
81        token.signature = signature.to_bytes().to_vec();
82
83        Ok(token)
84    }
85
86    /// Delegate this token to create a child with narrower scopes.
87    ///
88    /// The child token can only have scopes that are a subset of this token's scopes.
89    pub fn delegate(
90        &self,
91        child_signing_key: &SigningKey,
92        child_scopes: Vec<String>,
93        expires_at: u64,
94        audience: Option<Vec<u8>>,
95    ) -> Result<Self> {
96        // Verify attenuation: child scopes must be subset of parent scopes.
97        // See pentest CAP-02: Scope Attenuation Bypass, CAP-04: Proof Chain Manipulation
98        for child_scope in &child_scopes {
99            if !self.scope_allows(child_scope) {
100                return Err(CapError::AttenuationViolation(format!(
101                    "child scope '{}' not allowed by parent scopes {:?}",
102                    child_scope, self.scopes
103                )));
104            }
105        }
106
107        // Child expiration cannot exceed parent
108        let child_expires = expires_at.min(self.expires_at);
109
110        let child_issuer = child_signing_key.verifying_key().to_bytes().to_vec();
111        let nonce = uuid::Uuid::new_v4().to_string();
112
113        // Build proof chain: include all of parent's proofs + parent itself
114        let mut proofs = self.proofs.clone();
115        proofs.push(ProofLink {
116            issuer: self.issuer.clone(),
117            scopes: self.scopes.clone(),
118            signature: self.signature.clone(),
119        });
120
121        let mut token = Self {
122            version: 1,
123            issuer: child_issuer,
124            audience,
125            scopes: child_scopes,
126            expires_at: child_expires,
127            nonce,
128            proofs,
129            signature: vec![],
130        };
131
132        let payload = token.signable_payload()?;
133        let signature = child_signing_key.sign(&payload);
134        token.signature = signature.to_bytes().to_vec();
135
136        Ok(token)
137    }
138
139    /// Check if this token's scopes allow a given scope string.
140    ///
141    /// Uses the same matching logic as CLASP's `Scope::allows()`.
142    fn scope_allows(&self, child_scope: &str) -> bool {
143        // Parse child scope as "action:pattern"
144        let Some((child_action, child_pattern)) = child_scope.split_once(':') else {
145            return false;
146        };
147
148        for parent_scope in &self.scopes {
149            let Some((parent_action, parent_pattern)) = parent_scope.split_once(':') else {
150                continue;
151            };
152
153            // Check action attenuation
154            let action_ok = match parent_action {
155                "admin" => true,
156                "write" => child_action == "write" || child_action == "read",
157                "read" => child_action == "read",
158                _ => parent_action == child_action,
159            };
160
161            if !action_ok {
162                continue;
163            }
164
165            // Check pattern attenuation (child must be same or narrower)
166            if pattern_is_subset(child_pattern, parent_pattern) {
167                return true;
168            }
169        }
170
171        false
172    }
173
174    /// Verify this token's signature
175    pub fn verify_signature(&self) -> Result<()> {
176        let verifying_key = VerifyingKey::from_bytes(
177            self.issuer
178                .as_slice()
179                .try_into()
180                .map_err(|_| CapError::KeyError("invalid issuer key length".to_string()))?,
181        )
182        .map_err(|e| CapError::KeyError(e.to_string()))?;
183
184        let payload = self.signable_payload()?;
185        let signature = Signature::from_bytes(
186            self.signature
187                .as_slice()
188                .try_into()
189                .map_err(|_| CapError::InvalidSignature)?,
190        );
191
192        verifying_key
193            .verify(&payload, &signature)
194            .map_err(|_| CapError::InvalidSignature)
195    }
196
197    /// Check if the token is expired
198    pub fn is_expired(&self) -> bool {
199        let now = SystemTime::now()
200            .duration_since(UNIX_EPOCH)
201            .unwrap_or_default()
202            .as_secs();
203        now > self.expires_at
204    }
205
206    /// Get the delegation chain depth.
207    /// Chain depth is bounded at validation time. See pentest CAP-03: Chain Depth Bypass
208    pub fn chain_depth(&self) -> usize {
209        self.proofs.len()
210    }
211
212    /// Encode to the `cap_<base64>` wire format
213    pub fn encode(&self) -> Result<String> {
214        use base64::Engine;
215        let bytes = rmp_serde::to_vec_named(self).map_err(|e| CapError::Encoding(e.to_string()))?;
216        Ok(format!(
217            "{}{}",
218            TOKEN_PREFIX,
219            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
220        ))
221    }
222
223    /// Decode from the `cap_<base64>` wire format
224    pub fn decode(token: &str) -> Result<Self> {
225        use base64::Engine;
226        let encoded = token
227            .strip_prefix(TOKEN_PREFIX)
228            .ok_or_else(|| CapError::Encoding("missing cap_ prefix".to_string()))?;
229
230        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
231            .decode(encoded)
232            .map_err(|e| CapError::Encoding(e.to_string()))?;
233
234        rmp_serde::from_slice(&bytes).map_err(|e| CapError::Encoding(e.to_string()))
235    }
236
237    /// Compute the signable payload (everything except the signature field)
238    fn signable_payload(&self) -> Result<Vec<u8>> {
239        // Create a copy without the signature for signing
240        let signable = SignableToken {
241            version: self.version,
242            issuer: &self.issuer,
243            audience: self.audience.as_deref(),
244            scopes: &self.scopes,
245            expires_at: self.expires_at,
246            nonce: &self.nonce,
247            proofs: &self.proofs,
248        };
249
250        rmp_serde::to_vec_named(&signable).map_err(|e| CapError::Encoding(e.to_string()))
251    }
252}
253
254/// Signable portion of a token (excludes the signature itself)
255#[derive(Serialize)]
256struct SignableToken<'a> {
257    version: u8,
258    issuer: &'a [u8],
259    audience: Option<&'a [u8]>,
260    scopes: &'a [String],
261    expires_at: u64,
262    nonce: &'a str,
263    proofs: &'a [ProofLink],
264}
265
266/// Check if `child` pattern is a subset of `parent` pattern.
267///
268/// A pattern is a subset if every address it matches is also matched by the parent.
269/// Simple heuristic: check segment-by-segment prefix match with wildcard handling.
270///
271/// See pentest CAP-10: Pattern Wildcard Injection, PAT-04: pattern_is_subset Edge Cases
272pub fn pattern_is_subset(child: &str, parent: &str) -> bool {
273    // Exact match
274    if child == parent {
275        return true;
276    }
277
278    // Parent is "/**" or "**" -- matches everything
279    if parent == "/**" || parent == "**" {
280        return true;
281    }
282
283    let parent_parts: Vec<&str> = parent.split('/').filter(|s| !s.is_empty()).collect();
284    let child_parts: Vec<&str> = child.split('/').filter(|s| !s.is_empty()).collect();
285
286    // Walk through parent segments
287    let mut pi = 0;
288    let mut ci = 0;
289
290    while pi < parent_parts.len() && ci < child_parts.len() {
291        let pp = parent_parts[pi];
292        let cp = child_parts[ci];
293
294        if pp == "**" {
295            // Parent ** matches any remaining child segments
296            return true;
297        }
298
299        if pp == "*" {
300            // ** is wider than *, not a subset (prevents scope widening).
301            // See pentest CAP-10: Pattern Wildcard Injection
302            if cp == "**" {
303                return false;
304            }
305            // Parent * matches one child segment
306            pi += 1;
307            ci += 1;
308            continue;
309        }
310
311        if cp == "**" {
312            // Child ** is wider than parent literal -- NOT a subset
313            return false;
314        }
315
316        if cp == "*" {
317            // Child * at position where parent has literal -- NOT a subset
318            // (child could match things parent doesn't)
319            return false;
320        }
321
322        // Both literal: must match
323        if pp != cp {
324            return false;
325        }
326
327        pi += 1;
328        ci += 1;
329    }
330
331    // If parent has remaining ** at end, child is a subset
332    if pi < parent_parts.len() && parent_parts[pi] == "**" {
333        return true;
334    }
335
336    // Both must be exhausted for equal-length patterns
337    pi >= parent_parts.len() && ci >= child_parts.len()
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use ed25519_dalek::SigningKey;
344
345    fn test_key() -> SigningKey {
346        SigningKey::from_bytes(&[1u8; 32])
347    }
348
349    fn future_timestamp() -> u64 {
350        SystemTime::now()
351            .duration_since(UNIX_EPOCH)
352            .unwrap()
353            .as_secs()
354            + 3600
355    }
356
357    #[test]
358    fn test_create_root_token() {
359        let key = test_key();
360        let token = CapabilityToken::create_root(
361            &key,
362            vec!["admin:/**".to_string()],
363            future_timestamp(),
364            None,
365        )
366        .unwrap();
367
368        assert_eq!(token.version, 1);
369        assert_eq!(token.scopes, vec!["admin:/**"]);
370        assert!(token.proofs.is_empty());
371        assert!(!token.signature.is_empty());
372    }
373
374    #[test]
375    fn test_verify_signature() {
376        let key = test_key();
377        let token = CapabilityToken::create_root(
378            &key,
379            vec!["admin:/**".to_string()],
380            future_timestamp(),
381            None,
382        )
383        .unwrap();
384
385        assert!(token.verify_signature().is_ok());
386    }
387
388    #[test]
389    fn test_encode_decode() {
390        let key = test_key();
391        let token = CapabilityToken::create_root(
392            &key,
393            vec!["read:/**".to_string()],
394            future_timestamp(),
395            None,
396        )
397        .unwrap();
398
399        let encoded = token.encode().unwrap();
400        assert!(encoded.starts_with("cap_"));
401
402        let decoded = CapabilityToken::decode(&encoded).unwrap();
403        assert_eq!(decoded.scopes, token.scopes);
404        assert_eq!(decoded.issuer, token.issuer);
405    }
406
407    #[test]
408    fn test_delegation() {
409        let root_key = test_key();
410        let child_key = SigningKey::from_bytes(&[2u8; 32]);
411
412        let root = CapabilityToken::create_root(
413            &root_key,
414            vec!["admin:/**".to_string()],
415            future_timestamp(),
416            None,
417        )
418        .unwrap();
419
420        let child = root
421            .delegate(
422                &child_key,
423                vec!["write:/lights/**".to_string()],
424                future_timestamp(),
425                None,
426            )
427            .unwrap();
428
429        assert_eq!(child.chain_depth(), 1);
430        assert_eq!(child.scopes, vec!["write:/lights/**"]);
431        assert!(child.verify_signature().is_ok());
432    }
433
434    #[test]
435    fn test_attenuation_violation() {
436        let root_key = test_key();
437        let child_key = SigningKey::from_bytes(&[2u8; 32]);
438
439        let root = CapabilityToken::create_root(
440            &root_key,
441            vec!["write:/lights/**".to_string()],
442            future_timestamp(),
443            None,
444        )
445        .unwrap();
446
447        // Try to widen scope -- should fail
448        let result = root.delegate(
449            &child_key,
450            vec!["write:/audio/**".to_string()],
451            future_timestamp(),
452            None,
453        );
454        assert!(result.is_err());
455        assert!(matches!(
456            result.unwrap_err(),
457            CapError::AttenuationViolation(_)
458        ));
459    }
460
461    #[test]
462    fn test_expiration_clamped() {
463        let root_key = test_key();
464        let child_key = SigningKey::from_bytes(&[2u8; 32]);
465
466        let root_expires = future_timestamp();
467        let root = CapabilityToken::create_root(
468            &root_key,
469            vec!["admin:/**".to_string()],
470            root_expires,
471            None,
472        )
473        .unwrap();
474
475        // Child tries to set expiration beyond parent's
476        let child = root
477            .delegate(
478                &child_key,
479                vec!["read:/**".to_string()],
480                root_expires + 9999,
481                None,
482            )
483            .unwrap();
484
485        // Should be clamped to parent's expiration
486        assert_eq!(child.expires_at, root_expires);
487    }
488
489    #[test]
490    fn test_pattern_is_subset() {
491        assert!(pattern_is_subset("/lights/room1/**", "/lights/**"));
492        assert!(pattern_is_subset("/lights/room1", "/lights/**"));
493        assert!(pattern_is_subset("/**", "/**"));
494        assert!(!pattern_is_subset("/audio/**", "/lights/**"));
495        assert!(!pattern_is_subset("/**", "/lights/**"));
496        assert!(pattern_is_subset("/lights/1", "/lights/*"));
497        // ** is wider than *, not a subset
498        assert!(!pattern_is_subset("/lights/**", "/lights/*"));
499        assert!(!pattern_is_subset("/**", "/*"));
500    }
501
502    // --- Negative tests ---
503
504    #[test]
505    fn test_decode_malformed_base64() {
506        let result = CapabilityToken::decode("cap_!!!invalid-base64!!!");
507        assert!(result.is_err());
508    }
509
510    #[test]
511    fn test_decode_missing_prefix() {
512        let result = CapabilityToken::decode("not_a_cap_token");
513        assert!(result.is_err());
514    }
515
516    #[test]
517    fn test_decode_truncated_payload() {
518        use base64::Engine;
519        // Valid base64 but truncated msgpack payload
520        let truncated = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x92, 0x01]);
521        let result = CapabilityToken::decode(&format!("cap_{}", truncated));
522        assert!(result.is_err());
523    }
524
525    #[test]
526    fn test_decode_corrupted_msgpack() {
527        use base64::Engine;
528        // Valid base64 but not valid msgpack for CapabilityToken
529        let garbage =
530            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"this is not msgpack");
531        let result = CapabilityToken::decode(&format!("cap_{}", garbage));
532        assert!(result.is_err());
533    }
534
535    #[test]
536    fn test_signature_tampering() {
537        let key = test_key();
538        let mut token = CapabilityToken::create_root(
539            &key,
540            vec!["admin:/**".to_string()],
541            future_timestamp(),
542            None,
543        )
544        .unwrap();
545
546        // Flip a bit in the signature
547        token.signature[0] ^= 0xFF;
548        assert!(token.verify_signature().is_err());
549    }
550
551    #[test]
552    fn test_empty_scopes_delegation() {
553        let root_key = test_key();
554        let child_key = SigningKey::from_bytes(&[2u8; 32]);
555
556        let root = CapabilityToken::create_root(
557            &root_key,
558            vec!["admin:/**".to_string()],
559            future_timestamp(),
560            None,
561        )
562        .unwrap();
563
564        // Delegate with empty scopes — should succeed (empty is a subset of anything)
565        let child = root
566            .delegate(&child_key, vec![], future_timestamp(), None)
567            .unwrap();
568        assert!(child.scopes.is_empty());
569        assert!(child.verify_signature().is_ok());
570    }
571
572    #[test]
573    fn test_multi_hop_delegation() {
574        let key_a = SigningKey::from_bytes(&[1u8; 32]);
575        let key_b = SigningKey::from_bytes(&[2u8; 32]);
576        let key_c = SigningKey::from_bytes(&[3u8; 32]);
577
578        let root = CapabilityToken::create_root(
579            &key_a,
580            vec!["admin:/**".to_string()],
581            future_timestamp(),
582            None,
583        )
584        .unwrap();
585
586        let child = root
587            .delegate(
588                &key_b,
589                vec!["write:/lights/**".to_string()],
590                future_timestamp(),
591                None,
592            )
593            .unwrap();
594
595        let grandchild = child
596            .delegate(
597                &key_c,
598                vec!["write:/lights/room1/**".to_string()],
599                future_timestamp(),
600                None,
601            )
602            .unwrap();
603
604        assert_eq!(grandchild.chain_depth(), 2);
605        assert!(grandchild.verify_signature().is_ok());
606
607        // Grandchild can't widen beyond child
608        let result = child.delegate(
609            &key_c,
610            vec!["write:/audio/**".to_string()],
611            future_timestamp(),
612            None,
613        );
614        assert!(result.is_err());
615    }
616}