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#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct DatHeader {
18 pub alg: String,
20 pub typ: String,
22 pub kid: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct DatClaims {
29 pub iss: String,
31 pub sub: String,
33 pub iat: i64,
35 pub exp: i64,
37 pub nbf: i64,
39 pub jti: String,
41 pub scope: Vec<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub constraints: Option<DatConstraints>,
46 #[serde(rename = "configAttestation", skip_serializing_if = "Option::is_none")]
48 pub config_attestation: Option<String>,
49 #[serde(rename = "delegationChain", skip_serializing_if = "Option::is_none")]
51 pub delegation_chain: Option<Vec<String>>,
52}
53
54#[derive(Debug, Clone)]
56pub struct Dat {
57 pub header: DatHeader,
58 pub claims: DatClaims,
59 signature: Vec<u8>,
60 raw_signing_input: Option<String>,
64}
65
66impl Dat {
67 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 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 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 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 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 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 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 pub fn is_expired(&self) -> bool {
187 let now = Utc::now().timestamp();
188 now >= self.claims.exp
189 }
190
191 pub fn is_not_yet_valid(&self) -> bool {
193 let now = Utc::now().timestamp();
194 now < self.claims.nbf
195 }
196
197 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 pub fn verify(
222 &self,
223 public_key_bytes: &[u8; 32],
224 required_scope: &str,
225 ctx: &EvaluationContext,
226 ) -> Result<()> {
227 self.verify_signature(public_key_bytes)?;
229
230 self.validate_timing()?;
232
233 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 if let Some(constraints) = &self.claims.constraints {
247 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 let augmented = EvaluationContext {
259 delegation_depth: effective_depth,
260 ..ctx.clone()
261 };
262
263 constraints.evaluate(&augmented)?;
265
266 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 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 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 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 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 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 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 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; 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()), &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}