use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::crypto::KeyPair;
use crate::{IdprovaError, Result};
use super::constraints::{DatConstraints, EvaluationContext};
use super::scope::{Scope, ScopeSet};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DatHeader {
pub alg: String,
pub typ: String,
pub kid: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatClaims {
pub iss: String,
pub sub: String,
pub iat: i64,
pub exp: i64,
pub nbf: i64,
pub jti: String,
pub scope: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub constraints: Option<DatConstraints>,
#[serde(rename = "configAttestation", skip_serializing_if = "Option::is_none")]
pub config_attestation: Option<String>,
#[serde(rename = "delegationChain", skip_serializing_if = "Option::is_none")]
pub delegation_chain: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct Dat {
pub header: DatHeader,
pub claims: DatClaims,
signature: Vec<u8>,
raw_signing_input: Option<String>,
}
impl Dat {
pub fn issue(
issuer_did: &str,
subject_did: &str,
scope: Vec<String>,
expires_at: DateTime<Utc>,
constraints: Option<DatConstraints>,
config_attestation: Option<String>,
signing_key: &KeyPair,
) -> Result<Self> {
let now = Utc::now();
let header = DatHeader {
alg: "EdDSA".to_string(),
typ: "idprova-dat+jwt".to_string(),
kid: format!("{issuer_did}#key-ed25519"),
};
let claims = DatClaims {
iss: issuer_did.to_string(),
sub: subject_did.to_string(),
iat: now.timestamp(),
exp: expires_at.timestamp(),
nbf: now.timestamp(),
jti: format!("dat_{}", ulid::Ulid::new()),
scope,
constraints,
config_attestation,
delegation_chain: Some(vec![]),
};
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?);
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?);
let signing_input = format!("{header_b64}.{claims_b64}");
let signature = signing_key.sign(signing_input.as_bytes());
let signing_input = format!("{header_b64}.{claims_b64}");
Ok(Self {
header,
claims,
signature,
raw_signing_input: Some(signing_input),
})
}
pub fn to_compact(&self) -> Result<String> {
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&self.header)?);
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&self.claims)?);
let sig_b64 = URL_SAFE_NO_PAD.encode(&self.signature);
Ok(format!("{header_b64}.{claims_b64}.{sig_b64}"))
}
pub fn from_compact(compact: &str) -> Result<Self> {
let parts: Vec<&str> = compact.split('.').collect();
if parts.len() != 3 {
return Err(IdprovaError::InvalidDat(
"compact JWS must have 3 parts".into(),
));
}
let header_bytes = URL_SAFE_NO_PAD
.decode(parts[0])
.map_err(|e| IdprovaError::InvalidDat(format!("header decode: {e}")))?;
let claims_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|e| IdprovaError::InvalidDat(format!("claims decode: {e}")))?;
let signature = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|e| IdprovaError::InvalidDat(format!("signature decode: {e}")))?;
let header: DatHeader = serde_json::from_slice(&header_bytes)?;
if header.alg != "EdDSA" {
return Err(IdprovaError::InvalidDat(format!(
"unsupported algorithm '{}': only 'EdDSA' is permitted",
header.alg
)));
}
let claims: DatClaims = serde_json::from_slice(&claims_bytes)?;
let raw_signing_input = format!("{}.{}", parts[0], parts[1]);
Ok(Self {
header,
claims,
signature,
raw_signing_input: Some(raw_signing_input),
})
}
pub fn verify_signature(&self, public_key_bytes: &[u8; 32]) -> Result<()> {
let signing_input = match &self.raw_signing_input {
Some(raw) => raw.clone(),
None => {
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&self.header)?);
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&self.claims)?);
format!("{header_b64}.{claims_b64}")
}
};
KeyPair::verify(public_key_bytes, signing_input.as_bytes(), &self.signature)
}
pub fn is_expired(&self) -> bool {
let now = Utc::now().timestamp();
now >= self.claims.exp
}
pub fn is_not_yet_valid(&self) -> bool {
let now = Utc::now().timestamp();
now < self.claims.nbf
}
pub fn validate_timing(&self) -> Result<()> {
if self.is_expired() {
return Err(IdprovaError::DatExpired);
}
if self.is_not_yet_valid() {
return Err(IdprovaError::DatNotYetValid);
}
Ok(())
}
pub fn verify(
&self,
public_key_bytes: &[u8; 32],
required_scope: &str,
ctx: &EvaluationContext,
) -> Result<()> {
self.verify_signature(public_key_bytes)?;
self.validate_timing()?;
if !required_scope.is_empty() {
let requested = Scope::parse(required_scope)?;
let granted = ScopeSet::parse(&self.claims.scope)?;
if !granted.permits(&requested) {
return Err(IdprovaError::ScopeNotPermitted(format!(
"scope '{}' is not granted by this DAT",
required_scope
)));
}
}
if let Some(constraints) = &self.claims.constraints {
let chain_depth = self
.claims
.delegation_chain
.as_ref()
.map(|c| c.len() as u32)
.unwrap_or(0);
let effective_depth = ctx.delegation_depth.max(chain_depth);
let augmented = EvaluationContext {
delegation_depth: effective_depth,
..ctx.clone()
};
constraints.evaluate(&augmented)?;
constraints
.eval_config_attestation(&augmented, self.claims.config_attestation.as_deref())?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dat::constraints::RateLimit;
use chrono::Duration;
fn test_keypair() -> KeyPair {
KeyPair::generate()
}
#[test]
fn test_issue_and_verify() {
let kp = test_keypair();
let expires = Utc::now() + Duration::hours(24);
let dat = Dat::issue(
"did:aid:example.com:alice",
"did:aid:example.com:agent",
vec!["mcp:tool:filesystem:read".to_string()],
expires,
None,
None,
&kp,
)
.unwrap();
assert_eq!(dat.claims.iss, "did:aid:example.com:alice");
assert_eq!(dat.claims.sub, "did:aid:example.com:agent");
assert!(dat.claims.jti.starts_with("dat_"));
let pub_bytes = kp.public_key_bytes();
assert!(dat.verify_signature(&pub_bytes).is_ok());
}
#[test]
fn test_compact_roundtrip() {
let kp = test_keypair();
let expires = Utc::now() + Duration::hours(24);
let dat = Dat::issue(
"did:aid:example.com:alice",
"did:aid:example.com:agent",
vec!["mcp:*:*:*".to_string()],
expires,
Some(DatConstraints {
max_actions: Some(1000),
require_receipt: Some(true),
..Default::default()
}),
None,
&kp,
)
.unwrap();
let compact = dat.to_compact().unwrap();
let parsed = Dat::from_compact(&compact).unwrap();
assert_eq!(parsed.claims.iss, dat.claims.iss);
assert_eq!(parsed.claims.sub, dat.claims.sub);
assert_eq!(parsed.claims.scope, dat.claims.scope);
let pub_bytes = kp.public_key_bytes();
assert!(parsed.verify_signature(&pub_bytes).is_ok());
}
#[test]
fn test_wrong_key_fails_verification() {
let kp1 = test_keypair();
let kp2 = test_keypair();
let expires = Utc::now() + Duration::hours(24);
let dat = Dat::issue(
"did:aid:example.com:alice",
"did:aid:example.com:agent",
vec!["mcp:tool:filesystem:read".to_string()],
expires,
None,
None,
&kp1,
)
.unwrap();
let wrong_pub = kp2.public_key_bytes();
assert!(dat.verify_signature(&wrong_pub).is_err());
}
#[test]
fn test_timing_validation() {
let kp = test_keypair();
let expired = Dat::issue(
"did:aid:example.com:alice",
"did:aid:example.com:agent",
vec!["mcp:tool:filesystem:read".to_string()],
Utc::now() - Duration::hours(1),
None,
None,
&kp,
)
.unwrap();
assert!(expired.is_expired());
assert!(expired.validate_timing().is_err());
let valid = Dat::issue(
"did:aid:example.com:alice",
"did:aid:example.com:agent",
vec!["mcp:tool:filesystem:read".to_string()],
Utc::now() + Duration::hours(24),
None,
None,
&kp,
)
.unwrap();
assert!(!valid.is_expired());
assert!(valid.validate_timing().is_ok());
}
fn issue_valid(kp: &KeyPair, scope: &str, constraints: Option<DatConstraints>) -> Dat {
Dat::issue(
"did:aid:example.com:alice",
"did:aid:example.com:agent",
vec![scope.to_string()],
Utc::now() + Duration::hours(24),
constraints,
None,
kp,
)
.unwrap()
}
#[test]
fn test_verify_happy_path() {
let kp = test_keypair();
let dat = issue_valid(&kp, "mcp:tool:filesystem:read", None);
let ctx = EvaluationContext::default();
assert!(dat
.verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
.is_ok());
}
#[test]
fn test_verify_wrong_key() {
let kp = test_keypair();
let kp2 = test_keypair();
let dat = issue_valid(&kp, "mcp:tool:filesystem:read", None);
let ctx = EvaluationContext::default();
assert!(dat
.verify(&kp2.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
.is_err());
}
#[test]
fn test_verify_expired_token() {
let kp = test_keypair();
let dat = Dat::issue(
"did:aid:example.com:alice",
"did:aid:example.com:agent",
vec!["mcp:tool:filesystem:read".to_string()],
Utc::now() - Duration::hours(1),
None,
None,
&kp,
)
.unwrap();
let ctx = EvaluationContext::default();
let err = dat
.verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
.unwrap_err();
assert!(matches!(err, IdprovaError::DatExpired));
}
#[test]
fn test_verify_scope_denied() {
let kp = test_keypair();
let dat = issue_valid(&kp, "mcp:tool:filesystem:read", None);
let ctx = EvaluationContext::default();
let err = dat
.verify(&kp.public_key_bytes(), "mcp:tool:filesystem:write", &ctx)
.unwrap_err();
assert!(matches!(err, IdprovaError::ScopeNotPermitted(_)));
}
#[test]
fn test_verify_wildcard_scope_passes() {
let kp = test_keypair();
let dat = issue_valid(&kp, "mcp:*:*:*", None);
let ctx = EvaluationContext::default();
assert!(dat
.verify(&kp.public_key_bytes(), "mcp:tool:filesystem:write", &ctx)
.is_ok());
}
#[test]
fn test_verify_empty_scope_skips_check() {
let kp = test_keypair();
let dat = issue_valid(&kp, "mcp:tool:filesystem:read", None);
let ctx = EvaluationContext::default();
assert!(dat.verify(&kp.public_key_bytes(), "", &ctx).is_ok());
}
#[test]
fn test_verify_constraint_rate_limit_blocks() {
let kp = test_keypair();
let dat = issue_valid(
&kp,
"mcp:tool:filesystem:read",
Some(DatConstraints {
rate_limit: Some(RateLimit {
max_actions: 5,
window_secs: 60,
}),
..Default::default()
}),
);
let mut ctx = EvaluationContext::default();
ctx.actions_in_window = 10;
let err = dat
.verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
.unwrap_err();
assert!(err.to_string().contains("rate limit exceeded"));
}
#[test]
fn test_verify_delegation_depth_blocked() {
let kp = test_keypair();
let dat = issue_valid(
&kp,
"mcp:tool:filesystem:read",
Some(DatConstraints {
max_delegation_depth: Some(2),
..Default::default()
}),
);
let mut ctx = EvaluationContext::default();
ctx.delegation_depth = 3;
let err = dat
.verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
.unwrap_err();
assert!(err.to_string().contains("delegation depth"));
}
#[test]
fn test_verify_delegation_depth_at_limit_passes() {
let kp = test_keypair();
let dat = issue_valid(
&kp,
"mcp:tool:filesystem:read",
Some(DatConstraints {
max_delegation_depth: Some(2),
..Default::default()
}),
);
let mut ctx = EvaluationContext::default();
ctx.delegation_depth = 2; assert!(dat
.verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
.is_ok());
}
#[test]
fn test_verify_config_attestation_pass() {
let hash = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899".to_string();
let kp = test_keypair();
let dat = Dat::issue(
"did:aid:example.com:alice",
"did:aid:example.com:agent",
vec!["mcp:tool:filesystem:read".to_string()],
Utc::now() + Duration::hours(24),
Some(DatConstraints {
required_config_hash: Some(hash.clone()),
..Default::default()
}),
Some(hash.clone()), &kp,
)
.unwrap();
let mut ctx = EvaluationContext::default();
ctx.agent_config_hash = Some(hash);
assert!(dat
.verify(&kp.public_key_bytes(), "mcp:tool:filesystem:read", &ctx)
.is_ok());
}
}