use astrid_core::{Permission, Timestamp, TokenId};
use astrid_crypto::{ContentHash, KeyPair, PublicKey, Signature};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{CapabilityError, CapabilityResult};
use crate::pattern::ResourcePattern;
const SIGNING_DATA_VERSION: u8 = 0x01;
const DEFAULT_CLOCK_SKEW_SECS: i64 = 30;
#[allow(clippy::cast_possible_truncation)]
fn write_length_prefixed(data: &mut Vec<u8>, bytes: &[u8]) {
data.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
data.extend_from_slice(bytes);
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AuditEntryId(pub Uuid);
impl AuditEntryId {
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for AuditEntryId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for AuditEntryId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "audit:{}", &self.0.to_string()[..8])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenScope {
Session,
Persistent,
}
impl std::fmt::Display for TokenScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Session => write!(f, "session"),
Self::Persistent => write!(f, "persistent"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityToken {
pub id: TokenId,
pub resource: ResourcePattern,
pub permissions: Vec<Permission>,
pub issued_at: Timestamp,
pub expires_at: Option<Timestamp>,
pub scope: TokenScope,
pub issuer: PublicKey,
pub user_id: [u8; 8],
pub approval_audit_id: AuditEntryId,
#[serde(default)]
pub single_use: bool,
pub signature: Signature,
}
impl CapabilityToken {
#[must_use]
pub fn create(
resource: ResourcePattern,
permissions: Vec<Permission>,
scope: TokenScope,
user_id: [u8; 8],
approval_audit_id: AuditEntryId,
runtime_key: &KeyPair,
ttl: Option<Duration>,
) -> Self {
Self::create_with_options(
resource,
permissions,
scope,
user_id,
approval_audit_id,
runtime_key,
ttl,
false,
)
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn create_with_options(
resource: ResourcePattern,
permissions: Vec<Permission>,
scope: TokenScope,
user_id: [u8; 8],
approval_audit_id: AuditEntryId,
runtime_key: &KeyPair,
ttl: Option<Duration>,
single_use: bool,
) -> Self {
let id = TokenId::new();
let issued_at = Timestamp::now();
let expires_at = ttl.map(|d| {
#[allow(clippy::arithmetic_side_effects)]
let expiry = Utc::now() + d;
Timestamp::from_datetime(expiry)
});
let issuer = runtime_key.export_public_key();
let mut token = Self {
id,
resource,
permissions,
issued_at,
expires_at,
scope,
issuer,
user_id,
approval_audit_id,
single_use,
signature: Signature::from_bytes([0u8; 64]), };
let signing_data = token.signing_data();
token.signature = runtime_key.sign(&signing_data);
token
}
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn signing_data(&self) -> Vec<u8> {
let mut data = Vec::with_capacity(512);
data.push(SIGNING_DATA_VERSION);
write_length_prefixed(&mut data, self.id.0.as_bytes());
write_length_prefixed(&mut data, self.resource.as_str().as_bytes());
data.extend_from_slice(&(self.permissions.len() as u32).to_le_bytes());
for perm in &self.permissions {
write_length_prefixed(&mut data, perm.to_string().as_bytes());
}
data.extend_from_slice(&self.issued_at.0.timestamp().to_le_bytes());
if let Some(expires) = &self.expires_at {
data.push(0x01); data.extend_from_slice(&expires.0.timestamp().to_le_bytes());
} else {
data.push(0x00); }
write_length_prefixed(&mut data, self.scope.to_string().as_bytes());
data.extend_from_slice(self.issuer.as_bytes());
data.extend_from_slice(&self.user_id);
write_length_prefixed(&mut data, self.approval_audit_id.0.as_bytes());
data.push(u8::from(self.single_use));
data
}
pub fn verify_signature(&self) -> CapabilityResult<()> {
let signing_data = self.signing_data();
self.issuer
.verify(&signing_data, &self.signature)
.map_err(|_| CapabilityError::InvalidSignature)
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.is_expired_with_skew(0)
}
#[must_use]
pub fn is_expired_with_skew(&self, skew_secs: i64) -> bool {
self.expires_at.as_ref().is_some_and(|exp| {
let now = Utc::now();
#[allow(clippy::arithmetic_side_effects)]
let adjusted_expiry = exp.0 + Duration::seconds(skew_secs);
now > adjusted_expiry
})
}
pub fn validate(&self) -> CapabilityResult<()> {
self.validate_with_skew(DEFAULT_CLOCK_SKEW_SECS)
}
pub fn validate_with_skew(&self, skew_secs: i64) -> CapabilityResult<()> {
if self.is_expired_with_skew(skew_secs) {
return Err(CapabilityError::TokenExpired {
token_id: self.id.to_string(),
});
}
self.verify_signature()
}
#[must_use]
pub fn is_single_use(&self) -> bool {
self.single_use
}
#[must_use]
pub fn grants(&self, resource: &str, permission: Permission) -> bool {
self.resource.matches(resource) && self.permissions.contains(&permission)
}
#[must_use]
pub fn content_hash(&self) -> ContentHash {
ContentHash::hash(&self.signing_data())
}
}
impl PartialEq for CapabilityToken {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Eq for CapabilityToken {}
impl std::hash::Hash for CapabilityToken {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
pub struct TokenBuilder {
resource: ResourcePattern,
permissions: Vec<Permission>,
scope: TokenScope,
ttl: Option<Duration>,
single_use: bool,
}
impl TokenBuilder {
#[must_use]
pub fn new(resource: ResourcePattern) -> Self {
Self {
resource,
permissions: Vec::new(),
scope: TokenScope::Session,
ttl: None,
single_use: false,
}
}
#[must_use]
pub fn permission(mut self, perm: Permission) -> Self {
if !self.permissions.contains(&perm) {
self.permissions.push(perm);
}
self
}
#[must_use]
pub fn permissions(mut self, perms: impl IntoIterator<Item = Permission>) -> Self {
for perm in perms {
if !self.permissions.contains(&perm) {
self.permissions.push(perm);
}
}
self
}
#[must_use]
pub fn scope(mut self, scope: TokenScope) -> Self {
self.scope = scope;
self
}
#[must_use]
pub fn persistent(self) -> Self {
self.scope(TokenScope::Persistent)
}
#[must_use]
pub fn session(self) -> Self {
self.scope(TokenScope::Session)
}
#[must_use]
pub fn ttl(mut self, ttl: Duration) -> Self {
self.ttl = Some(ttl);
self
}
#[must_use]
pub fn single_use(mut self) -> Self {
self.single_use = true;
self
}
#[must_use]
pub fn build(
self,
user_id: [u8; 8],
approval_audit_id: AuditEntryId,
runtime_key: &KeyPair,
) -> CapabilityToken {
CapabilityToken::create_with_options(
self.resource,
self.permissions,
self.scope,
user_id,
approval_audit_id,
runtime_key,
self.ttl,
self.single_use,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use astrid_core::Permission;
fn test_keypair() -> KeyPair {
KeyPair::generate()
}
#[test]
fn test_token_creation() {
let keypair = test_keypair();
let pattern = ResourcePattern::exact("mcp://filesystem:read_file").unwrap();
let token = CapabilityToken::create(
pattern,
vec![Permission::Invoke],
TokenScope::Session,
keypair.key_id(),
AuditEntryId::new(),
&keypair,
None,
);
assert!(!token.is_expired());
assert!(token.verify_signature().is_ok());
}
#[test]
fn test_token_grants() {
let keypair = test_keypair();
let pattern = ResourcePattern::new("mcp://filesystem:*").unwrap();
let token = CapabilityToken::create(
pattern,
vec![Permission::Invoke, Permission::Read],
TokenScope::Session,
keypair.key_id(),
AuditEntryId::new(),
&keypair,
None,
);
assert!(token.grants("mcp://filesystem:read_file", Permission::Invoke));
assert!(token.grants("mcp://filesystem:write_file", Permission::Invoke));
assert!(!token.grants("mcp://filesystem:read_file", Permission::Write));
assert!(!token.grants("mcp://memory:read", Permission::Invoke));
}
#[test]
fn test_token_expiration() {
let keypair = test_keypair();
let pattern = ResourcePattern::exact("test://resource").unwrap();
let token = CapabilityToken::create(
pattern,
vec![Permission::Read],
TokenScope::Session,
keypair.key_id(),
AuditEntryId::new(),
&keypair,
Some(Duration::seconds(-60)), );
assert!(token.is_expired());
assert!(matches!(
token.validate(),
Err(CapabilityError::TokenExpired { .. })
));
}
#[test]
fn test_token_expiration_with_clock_skew() {
let keypair = test_keypair();
let pattern = ResourcePattern::exact("test://resource").unwrap();
let token = CapabilityToken::create(
pattern,
vec![Permission::Read],
TokenScope::Session,
keypair.key_id(),
AuditEntryId::new(),
&keypair,
Some(Duration::seconds(-10)), );
assert!(token.is_expired());
assert!(token.is_expired_with_skew(0));
assert!(!token.is_expired_with_skew(30));
assert!(token.validate().is_ok());
}
#[test]
fn test_token_builder() {
let keypair = test_keypair();
let token =
TokenBuilder::new(ResourcePattern::exact("mcp://filesystem:read_file").unwrap())
.permission(Permission::Invoke)
.permission(Permission::Read)
.persistent()
.ttl(Duration::hours(24))
.build(keypair.key_id(), AuditEntryId::new(), &keypair);
assert_eq!(token.scope, TokenScope::Persistent);
assert!(token.expires_at.is_some());
assert!(token.permissions.contains(&Permission::Invoke));
assert!(token.permissions.contains(&Permission::Read));
}
#[test]
fn test_token_signature_verification() {
let keypair = test_keypair();
let pattern = ResourcePattern::exact("test://resource").unwrap();
let mut token = CapabilityToken::create(
pattern,
vec![Permission::Read],
TokenScope::Session,
keypair.key_id(),
AuditEntryId::new(),
&keypair,
None,
);
assert!(token.verify_signature().is_ok());
token.permissions.push(Permission::Write);
assert!(matches!(
token.verify_signature(),
Err(CapabilityError::InvalidSignature)
));
}
#[test]
fn test_token_content_hash() {
let keypair = test_keypair();
let pattern = ResourcePattern::exact("test://resource").unwrap();
let token = CapabilityToken::create(
pattern.clone(),
vec![Permission::Read],
TokenScope::Session,
keypair.key_id(),
AuditEntryId::new(),
&keypair,
None,
);
let hash = token.content_hash();
assert!(!hash.is_zero());
let token2 = CapabilityToken::create(
pattern,
vec![Permission::Write],
TokenScope::Session,
keypair.key_id(),
AuditEntryId::new(),
&keypair,
None,
);
assert_ne!(token.content_hash(), token2.content_hash());
}
}