Skip to main content

idprova_core/dat/
token.rs

1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4
5use crate::crypto::KeyPair;
6use crate::{IdprovaError, Result};
7
8use super::constraints::{DatConstraints, EvaluationContext};
9use super::scope::{Scope, ScopeSet};
10
11/// JWS header for a DAT.
12///
13/// SEC-3 mitigation: `alg` is validated on deserialization — only "EdDSA" is accepted.
14/// SEC-4 mitigation: `deny_unknown_fields` rejects `jwk`, `jku`, `x5u`, etc.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct DatHeader {
18    /// Algorithm — always "EdDSA" for Ed25519.
19    pub alg: String,
20    /// Token type.
21    pub typ: String,
22    /// Key ID — the DID URL of the signing key.
23    pub kid: String,
24}
25
26/// The claims (payload) of a DAT.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct DatClaims {
29    /// Issuer — the DID of the delegator.
30    pub iss: String,
31    /// Subject — the DID of the agent receiving delegation.
32    pub sub: String,
33    /// Issued at (Unix timestamp).
34    pub iat: i64,
35    /// Expiration (Unix timestamp).
36    pub exp: i64,
37    /// Not before (Unix timestamp).
38    pub nbf: i64,
39    /// JWT ID — unique token identifier.
40    pub jti: String,
41    /// Granted scopes.
42    pub scope: Vec<String>,
43    /// Usage constraints.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub constraints: Option<DatConstraints>,
46    /// Config attestation hash of the agent's configuration.
47    #[serde(rename = "configAttestation", skip_serializing_if = "Option::is_none")]
48    pub config_attestation: Option<String>,
49    /// Delegation chain — list of parent DAT JTIs for multi-level delegation.
50    #[serde(rename = "delegationChain", skip_serializing_if = "Option::is_none")]
51    pub delegation_chain: Option<Vec<String>>,
52}
53
54/// A complete Delegation Attestation Token.
55#[derive(Debug, Clone)]
56pub struct Dat {
57    pub header: DatHeader,
58    pub claims: DatClaims,
59    signature: Vec<u8>,
60    /// The original base64url-encoded signing input (header.payload) from the compact JWS.
61    /// Preserved to ensure signature verification uses the exact bytes that were signed,
62    /// avoiding any JSON re-serialization roundtrip issues.
63    raw_signing_input: Option<String>,
64}
65
66impl Dat {
67    /// Issue a new DAT signed by the issuer's keypair.
68    pub fn issue(
69        issuer_did: &str,
70        subject_did: &str,
71        scope: Vec<String>,
72        expires_at: DateTime<Utc>,
73        constraints: Option<DatConstraints>,
74        config_attestation: Option<String>,
75        signing_key: &KeyPair,
76    ) -> Result<Self> {
77        let now = Utc::now();
78
79        let header = DatHeader {
80            alg: "EdDSA".to_string(),
81            typ: "idprova-dat+jwt".to_string(),
82            kid: format!("{issuer_did}#key-ed25519"),
83        };
84
85        let claims = DatClaims {
86            iss: issuer_did.to_string(),
87            sub: subject_did.to_string(),
88            iat: now.timestamp(),
89            exp: expires_at.timestamp(),
90            nbf: now.timestamp(),
91            jti: format!("dat_{}", ulid::Ulid::new()),
92            scope,
93            constraints,
94            config_attestation,
95            delegation_chain: Some(vec![]),
96        };
97
98        // Create the signing input: base64url(header).base64url(payload)
99        let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?);
100        let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?);
101        let signing_input = format!("{header_b64}.{claims_b64}");
102
103        let signature = signing_key.sign(signing_input.as_bytes());
104
105        let signing_input = format!("{header_b64}.{claims_b64}");
106
107        Ok(Self {
108            header,
109            claims,
110            signature,
111            raw_signing_input: Some(signing_input),
112        })
113    }
114
115    /// Serialize to compact JWS format: header.payload.signature
116    pub fn to_compact(&self) -> Result<String> {
117        let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&self.header)?);
118        let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&self.claims)?);
119        let sig_b64 = URL_SAFE_NO_PAD.encode(&self.signature);
120        Ok(format!("{header_b64}.{claims_b64}.{sig_b64}"))
121    }
122
123    /// Parse a compact JWS string into a DAT (without verifying the signature).
124    ///
125    /// Preserves the raw base64url-encoded header.payload as `raw_signing_input`
126    /// so that `verify_signature` can verify against the exact original bytes.
127    pub fn from_compact(compact: &str) -> Result<Self> {
128        let parts: Vec<&str> = compact.split('.').collect();
129        if parts.len() != 3 {
130            return Err(IdprovaError::InvalidDat(
131                "compact JWS must have 3 parts".into(),
132            ));
133        }
134
135        let header_bytes = URL_SAFE_NO_PAD
136            .decode(parts[0])
137            .map_err(|e| IdprovaError::InvalidDat(format!("header decode: {e}")))?;
138        let claims_bytes = URL_SAFE_NO_PAD
139            .decode(parts[1])
140            .map_err(|e| IdprovaError::InvalidDat(format!("claims decode: {e}")))?;
141        let signature = URL_SAFE_NO_PAD
142            .decode(parts[2])
143            .map_err(|e| IdprovaError::InvalidDat(format!("signature decode: {e}")))?;
144
145        let header: DatHeader = serde_json::from_slice(&header_bytes)?;
146
147        // SEC-3: Hard-reject any algorithm other than EdDSA
148        if header.alg != "EdDSA" {
149            return Err(IdprovaError::InvalidDat(format!(
150                "unsupported algorithm '{}': only 'EdDSA' is permitted",
151                header.alg
152            )));
153        }
154
155        let claims: DatClaims = serde_json::from_slice(&claims_bytes)?;
156
157        // Preserve the original base64url signing input for signature verification
158        let raw_signing_input = format!("{}.{}", parts[0], parts[1]);
159
160        Ok(Self {
161            header,
162            claims,
163            signature,
164            raw_signing_input: Some(raw_signing_input),
165        })
166    }
167
168    /// Verify the DAT's signature against a public key.
169    ///
170    /// Uses the raw signing input from the original compact JWS when available,
171    /// falling back to re-serialization for tokens created via `issue()`.
172    pub fn verify_signature(&self, public_key_bytes: &[u8; 32]) -> Result<()> {
173        let signing_input = match &self.raw_signing_input {
174            Some(raw) => raw.clone(),
175            None => {
176                let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&self.header)?);
177                let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&self.claims)?);
178                format!("{header_b64}.{claims_b64}")
179            }
180        };
181
182        KeyPair::verify(public_key_bytes, signing_input.as_bytes(), &self.signature)
183    }
184
185    /// Check if the DAT is expired.
186    pub fn is_expired(&self) -> bool {
187        let now = Utc::now().timestamp();
188        now >= self.claims.exp
189    }
190
191    /// Check if the DAT is not yet valid (before nbf).
192    pub fn is_not_yet_valid(&self) -> bool {
193        let now = Utc::now().timestamp();
194        now < self.claims.nbf
195    }
196
197    /// Validate timing constraints (not expired, not before valid).
198    pub fn validate_timing(&self) -> Result<()> {
199        if self.is_expired() {
200            return Err(IdprovaError::DatExpired);
201        }
202        if self.is_not_yet_valid() {
203            return Err(IdprovaError::DatNotYetValid);
204        }
205        Ok(())
206    }
207
208    /// Full verification pipeline.
209    ///
210    /// Runs all checks in order:
211    /// 1. Signature verification
212    /// 2. Timing (exp + nbf)
213    /// 3. Scope — `required_scope` must be permitted by the DAT's scope set
214    /// 4. Constraint policy engine (rate limit, IP, trust, depth, geofence, time windows)
215    /// 5. Config attestation (if constraint requires it)
216    ///
217    /// Delegation depth is taken as the **maximum** of `ctx.delegation_depth` and the
218    /// length of `claims.delegation_chain`, so the stricter value always wins.
219    ///
220    /// Pass `required_scope = ""` to skip the scope check (e.g. for token introspection).
221    pub fn verify(
222        &self,
223        public_key_bytes: &[u8; 32],
224        required_scope: &str,
225        ctx: &EvaluationContext,
226    ) -> Result<()> {
227        // 1. Signature
228        self.verify_signature(public_key_bytes)?;
229
230        // 2. Timing
231        self.validate_timing()?;
232
233        // 3. Scope
234        if !required_scope.is_empty() {
235            let requested = Scope::parse(required_scope)?;
236            let granted = ScopeSet::parse(&self.claims.scope)?;
237            if !granted.permits(&requested) {
238                return Err(IdprovaError::ScopeNotPermitted(format!(
239                    "scope '{}' is not granted by this DAT",
240                    required_scope
241                )));
242            }
243        }
244
245        // 4 & 5. Constraint policy engine (if present)
246        if let Some(constraints) = &self.claims.constraints {
247            // Derive effective delegation depth — conservative (take max)
248            let chain_depth = self
249                .claims
250                .delegation_chain
251                .as_ref()
252                .map(|c| c.len() as u32)
253                .unwrap_or(0);
254
255            let effective_depth = ctx.delegation_depth.max(chain_depth);
256
257            // Build augmented context with resolved depth
258            let augmented = EvaluationContext {
259                delegation_depth: effective_depth,
260                ..ctx.clone()
261            };
262
263            // 4. All constraint evaluators
264            constraints.evaluate(&augmented)?;
265
266            // 5. Config attestation (needs the token's own claim)
267            constraints
268                .eval_config_attestation(&augmented, self.claims.config_attestation.as_deref())?;
269        }
270
271        Ok(())
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::dat::constraints::RateLimit;
279    use chrono::Duration;
280
281    fn test_keypair() -> KeyPair {
282        KeyPair::generate()
283    }
284
285    #[test]
286    fn test_issue_and_verify() {
287        let kp = test_keypair();
288        let expires = Utc::now() + Duration::hours(24);
289
290        let dat = Dat::issue(
291            "did:idprova:example.com:alice",
292            "did:idprova:example.com:agent",
293            vec!["mcp:tool:filesystem:read".to_string()],
294            expires,
295            None,
296            None,
297            &kp,
298        )
299        .unwrap();
300
301        assert_eq!(dat.claims.iss, "did:idprova:example.com:alice");
302        assert_eq!(dat.claims.sub, "did:idprova:example.com:agent");
303        assert!(dat.claims.jti.starts_with("dat_"));
304
305        // Verify signature
306        let pub_bytes = kp.public_key_bytes();
307        assert!(dat.verify_signature(&pub_bytes).is_ok());
308    }
309
310    #[test]
311    fn test_compact_roundtrip() {
312        let kp = test_keypair();
313        let expires = Utc::now() + Duration::hours(24);
314
315        let dat = Dat::issue(
316            "did:idprova:example.com:alice",
317            "did:idprova:example.com:agent",
318            vec!["mcp:*:*:*".to_string()],
319            expires,
320            Some(DatConstraints {
321                max_actions: Some(1000),
322                require_receipt: Some(true),
323                ..Default::default()
324            }),
325            None,
326            &kp,
327        )
328        .unwrap();
329
330        let compact = dat.to_compact().unwrap();
331        let parsed = Dat::from_compact(&compact).unwrap();
332
333        assert_eq!(parsed.claims.iss, dat.claims.iss);
334        assert_eq!(parsed.claims.sub, dat.claims.sub);
335        assert_eq!(parsed.claims.scope, dat.claims.scope);
336
337        // Verify the parsed token's signature
338        let pub_bytes = kp.public_key_bytes();
339        assert!(parsed.verify_signature(&pub_bytes).is_ok());
340    }
341
342    #[test]
343    fn test_wrong_key_fails_verification() {
344        let kp1 = test_keypair();
345        let kp2 = test_keypair();
346        let expires = Utc::now() + Duration::hours(24);
347
348        let dat = Dat::issue(
349            "did:idprova:example.com:alice",
350            "did:idprova:example.com:agent",
351            vec!["mcp:tool:filesystem:read".to_string()],
352            expires,
353            None,
354            None,
355            &kp1,
356        )
357        .unwrap();
358
359        let wrong_pub = kp2.public_key_bytes();
360        assert!(dat.verify_signature(&wrong_pub).is_err());
361    }
362
363    #[test]
364    fn test_timing_validation() {
365        let kp = test_keypair();
366
367        // Expired DAT
368        let expired = Dat::issue(
369            "did:idprova:example.com:alice",
370            "did:idprova:example.com:agent",
371            vec!["mcp:tool:filesystem:read".to_string()],
372            Utc::now() - Duration::hours(1),
373            None,
374            None,
375            &kp,
376        )
377        .unwrap();
378        assert!(expired.is_expired());
379        assert!(expired.validate_timing().is_err());
380
381        // Valid DAT
382        let valid = Dat::issue(
383            "did:idprova:example.com:alice",
384            "did:idprova:example.com:agent",
385            vec!["mcp:tool:filesystem:read".to_string()],
386            Utc::now() + Duration::hours(24),
387            None,
388            None,
389            &kp,
390        )
391        .unwrap();
392        assert!(!valid.is_expired());
393        assert!(valid.validate_timing().is_ok());
394    }
395
396    // ── verify() full pipeline ───────────────────────────────────────────────
397
398    fn issue_valid(kp: &KeyPair, scope: &str, constraints: Option<DatConstraints>) -> Dat {
399        Dat::issue(
400            "did:idprova:example.com:alice",
401            "did:idprova:example.com:agent",
402            vec![scope.to_string()],
403            Utc::now() + Duration::hours(24),
404            constraints,
405            None,
406            kp,
407        )
408        .unwrap()
409    }
410
411    #[test]
412    fn test_verify_happy_path() {
413        let kp = test_keypair();
414        let dat = issue_valid(&kp, "mcp:tool:filesystem:read", None);
415        let ctx = EvaluationContext::default();
416        assert!(dat
417            .verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
418            .is_ok());
419    }
420
421    #[test]
422    fn test_verify_wrong_key() {
423        let kp = test_keypair();
424        let kp2 = test_keypair();
425        let dat = issue_valid(&kp, "mcp:tool:filesystem:read", None);
426        let ctx = EvaluationContext::default();
427        assert!(dat
428            .verify(&kp2.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
429            .is_err());
430    }
431
432    #[test]
433    fn test_verify_expired_token() {
434        let kp = test_keypair();
435        let dat = Dat::issue(
436            "did:idprova:example.com:alice",
437            "did:idprova:example.com:agent",
438            vec!["mcp:tool:filesystem:read".to_string()],
439            Utc::now() - Duration::hours(1),
440            None,
441            None,
442            &kp,
443        )
444        .unwrap();
445        let ctx = EvaluationContext::default();
446        let err = dat
447            .verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
448            .unwrap_err();
449        assert!(matches!(err, IdprovaError::DatExpired));
450    }
451
452    #[test]
453    fn test_verify_scope_denied() {
454        let kp = test_keypair();
455        let dat = issue_valid(&kp, "mcp:tool:filesystem:read", None);
456        let ctx = EvaluationContext::default();
457        let err = dat
458            .verify(&kp.public_key_bytes(), "mcp:tool:filesystem:write", &ctx)
459            .unwrap_err();
460        assert!(matches!(err, IdprovaError::ScopeNotPermitted(_)));
461    }
462
463    #[test]
464    fn test_verify_wildcard_scope_passes() {
465        let kp = test_keypair();
466        let dat = issue_valid(&kp, "mcp:*:*:*", None);
467        let ctx = EvaluationContext::default();
468        assert!(dat
469            .verify(&kp.public_key_bytes(), "mcp:tool:filesystem:write", &ctx)
470            .is_ok());
471    }
472
473    #[test]
474    fn test_verify_empty_scope_skips_check() {
475        let kp = test_keypair();
476        let dat = issue_valid(&kp, "mcp:tool:filesystem:read", None);
477        let ctx = EvaluationContext::default();
478        // "" → skip scope check
479        assert!(dat.verify(&kp.public_key_bytes(), "", &ctx).is_ok());
480    }
481
482    #[test]
483    fn test_verify_constraint_rate_limit_blocks() {
484        let kp = test_keypair();
485        let dat = issue_valid(
486            &kp,
487            "mcp:tool:filesystem:read",
488            Some(DatConstraints {
489                rate_limit: Some(RateLimit {
490                    max_actions: 5,
491                    window_secs: 60,
492                }),
493                ..Default::default()
494            }),
495        );
496        let mut ctx = EvaluationContext::default();
497        ctx.actions_in_window = 10;
498        let err = dat
499            .verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
500            .unwrap_err();
501        assert!(err.to_string().contains("rate limit exceeded"));
502    }
503
504    #[test]
505    fn test_verify_delegation_depth_blocked() {
506        let kp = test_keypair();
507        let dat = issue_valid(
508            &kp,
509            "mcp:tool:filesystem:read",
510            Some(DatConstraints {
511                max_delegation_depth: Some(2),
512                ..Default::default()
513            }),
514        );
515        // ctx carries the runtime depth — 3 levels deep exceeds max=2
516        let mut ctx = EvaluationContext::default();
517        ctx.delegation_depth = 3;
518        let err = dat
519            .verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
520            .unwrap_err();
521        assert!(err.to_string().contains("delegation depth"));
522    }
523
524    #[test]
525    fn test_verify_delegation_depth_at_limit_passes() {
526        let kp = test_keypair();
527        let dat = issue_valid(
528            &kp,
529            "mcp:tool:filesystem:read",
530            Some(DatConstraints {
531                max_delegation_depth: Some(2),
532                ..Default::default()
533            }),
534        );
535        let mut ctx = EvaluationContext::default();
536        ctx.delegation_depth = 2; // exactly at limit → ok
537        assert!(dat
538            .verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
539            .is_ok());
540    }
541
542    #[test]
543    fn test_verify_config_attestation_pass() {
544        let hash = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899".to_string();
545        let kp = test_keypair();
546        let dat = Dat::issue(
547            "did:idprova:example.com:alice",
548            "did:idprova:example.com:agent",
549            vec!["mcp:tool:filesystem:read".to_string()],
550            Utc::now() + Duration::hours(24),
551            Some(DatConstraints {
552                required_config_hash: Some(hash.clone()),
553                ..Default::default()
554            }),
555            Some(hash.clone()), // config_attestation claim in token
556            &kp,
557        )
558        .unwrap();
559        let mut ctx = EvaluationContext::default();
560        ctx.agent_config_hash = Some(hash);
561        assert!(dat
562            .verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
563            .is_ok());
564    }
565}