use crate::*;
use agent_protocols::{delegation as sdk, identity as sdk_id};
use dmsg_types::{agent::*, *};
use std::collections::BTreeSet;
pub const MAX_CONTROLLER_NAME_BYTES: usize = 64;
pub const MAX_CEILING_SCOPES: usize = 8;
pub const MAX_CEILING_AUDIENCES: usize = 4;
pub const MAX_SCOPE_BYTES: usize = 64;
pub const MAX_AUDIENCE_BYTES: usize = 256;
pub const EVENT_PAST_SKEW: u64 = 60 * SECOND;
pub const EVENT_FUTURE_SKEW: u64 = 5 * SECOND;
fn sdk_error(error: agent_protocols::SdkError) -> Error {
Error::InvalidInput(error.to_string())
}
fn millis(value: u64) -> Result<i64> {
ensure_valid(value <= sdk_id::MAX_SAFE_NONCE, "timestamp")?;
Ok(value as i64)
}
pub fn agent_id(public_key: &Hash) -> String {
sdk_id::AgentId::from_public_key(public_key).to_string()
}
pub fn validate_principal_origin(origin: &str) -> Result<()> {
sdk_id::validate_origin(origin).map_err(sdk_error)
}
pub fn principal_id(origin: &str, account_id: &AccountId) -> String {
format!("{origin}/{account_id}")
}
pub fn delegation_id_prefix(account_id: &AccountId) -> String {
format!("{account_id}.")
}
pub fn event_hash(bytes: &[u8]) -> Hash {
use sha3::Digest;
Hash::new(sha3::Sha3_256::digest(bytes).into())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DelegationGrant {
pub scopes: Vec<String>,
pub audiences: Vec<String>,
pub not_before: Option<u64>,
pub expires_at: Option<u64>,
pub constraints_bytes: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DelegationEvent {
pub hash: Hash,
pub actor: Hash,
pub created_at: u64,
pub nonce: u64,
pub principal_id: String,
pub id: String,
pub grant: Option<DelegationGrant>,
}
pub fn parse_delegation_event(bytes: &[u8]) -> Result<DelegationEvent> {
ensure(bytes.len() <= MAX_AGENT_EVENT_BYTES, Error::QuotaExceeded)?;
let text = std::str::from_utf8(bytes).map_err(|_| invalid("event is not UTF-8"))?;
let value = sdk_id::parse_strict_json(text).map_err(sdk_error)?;
let event: sdk_id::Event<sdk::DelegationPayload> =
serde_json::from_value(value).map_err(|error| Error::InvalidInput(error.to_string()))?;
ensure(
event.protocol == AGENT_DELEGATION_PROTOCOL,
Error::UnsupportedProtocol,
)?;
sdk_id::validate_event_fields(&event, &[]).map_err(sdk_error)?;
ensure_valid(
sdk_id::canonical_event_bytes(&event).map_err(sdk_error)? == bytes,
"event is not canonical JCS",
)?;
let hash = sdk_id::event_hash_bytes(&event).map_err(sdk_error)?;
let created_at = u64::try_from(event.created_at).map_err(|_| invalid("created_at"))?;
let actor = event.actor.public_key_bytes().map_err(sdk_error)?;
let (principal_id, id, grant) = match (event.kind.as_str(), &event.payload) {
(sdk::DELEGATION_GRANT, sdk::DelegationPayload::Grant(payload)) => {
sdk::validate_delegation_grant_payload(payload, Some(event.created_at))
.map_err(sdk_error)?;
let constraints_bytes = match &payload.constraints {
Some(constraints) => serde_jcs::to_vec(constraints)
.map_err(|error| Error::InvalidInput(error.to_string()))?
.len(),
None => 0,
};
let time = |value: Option<i64>| {
value
.map(|t| u64::try_from(t).map_err(|_| invalid("timestamp")))
.transpose()
};
(
payload.principal_id.clone(),
payload.id.clone(),
Some(DelegationGrant {
scopes: payload.scopes.clone(),
audiences: payload.audiences.clone(),
not_before: time(payload.not_before)?,
expires_at: time(payload.expires_at)?,
constraints_bytes,
}),
)
}
(sdk::DELEGATION_REVOKE, sdk::DelegationPayload::Revoke(payload)) => {
sdk::validate_delegation_revoke_payload(payload).map_err(sdk_error)?;
(payload.principal_id.clone(), payload.id.clone(), None)
}
(sdk::DELEGATION_GRANT | sdk::DELEGATION_REVOKE, _) => {
return Err(invalid("event type does not match its payload"))
}
_ => return Err(Error::UnsupportedProtocol),
};
Ok(DelegationEvent {
hash: Hash::new(hash),
actor: Hash::new(actor),
created_at,
nonce: event.nonce,
principal_id,
id,
grant,
})
}
pub fn check_hosted_event(
event: &DelegationEvent,
account_id: &AccountId,
principal_id: &str,
controller: &HostedController,
now: u64,
) -> Result<()> {
ensure(controller.retired_at.is_none(), Error::Forbidden)?;
ensure(
event.actor == controller.public_key && event.principal_id == principal_id,
Error::IntegrityFailed,
)?;
ensure_valid(
event
.id
.strip_prefix(&delegation_id_prefix(account_id))
.is_some_and(|suffix| !suffix.is_empty()),
"delegation id must start with the account prefix",
)?;
ensure(
event.created_at >= controller.valid_from
&& event.created_at.saturating_add(EVENT_PAST_SKEW) >= now
&& event.created_at <= now.saturating_add(EVENT_FUTURE_SKEW),
Error::Expired,
)?;
if let Some(grant) = &event.grant {
if let DelegationAuthority::Restricted { scopes, audiences } = &controller.delegation {
ensure(
grant.scopes.iter().all(|s| scopes.contains(s))
&& grant.audiences.iter().all(|a| audiences.contains(a)),
Error::Forbidden,
)?;
}
ensure_valid(
grant.expires_at.is_some_and(|expires_at| {
expires_at > event.created_at && expires_at - event.created_at <= MAX_GRANT_LIFETIME
}),
"grant expires_at is required within 366 days",
)?;
ensure_valid(
grant.constraints_bytes <= MAX_GRANT_CONSTRAINTS_BYTES,
"grant constraints are too large",
)?;
}
Ok(())
}
fn validate_authority(authority: &DelegationAuthority) -> Result<()> {
let DelegationAuthority::Restricted { scopes, audiences } = authority else {
return Ok(());
};
let unique = |values: &[String]| {
let mut seen = BTreeSet::new();
values.iter().all(|value| seen.insert(value))
};
ensure_valid(
(1..=MAX_CEILING_SCOPES).contains(&scopes.len())
&& (1..=MAX_CEILING_AUDIENCES).contains(&audiences.len())
&& unique(scopes)
&& unique(audiences)
&& scopes
.iter()
.all(|s| s.len() <= MAX_SCOPE_BYTES && !s.trim().is_empty() && s != "*")
&& audiences.iter().all(|a| a.len() <= MAX_AUDIENCE_BYTES),
"delegation ceiling",
)?;
for audience in audiences {
sdk::validate_audience(audience).map_err(sdk_error)?;
}
Ok(())
}
pub fn validate_controller_name(name: &str) -> Result<()> {
ensure_valid(
!name.trim().is_empty()
&& name.len() <= MAX_CONTROLLER_NAME_BYTES
&& !name.chars().any(char::is_control),
"controller name",
)
}
pub fn validate_principal_state(state: &PrincipalState) -> Result<()> {
ensure(
state.controllers.len() <= MAX_CONTROLLER_RECORDS
&& state
.controllers
.iter()
.filter(|c| c.retired_at.is_none())
.count()
<= MAX_CURRENT_CONTROLLERS,
Error::QuotaExceeded,
)?;
millis(state.updated_at)?;
let mut keys = BTreeSet::new();
for (index, c) in state.controllers.iter().enumerate() {
ensure_valid(
c.generation > 0
&& index
.checked_sub(1)
.is_none_or(|p| state.controllers[p].generation < c.generation)
&& keys.insert(c.public_key),
"controller generation/key",
)?;
crate::validate_ed25519_key(c.public_key.as_slice())?;
if let Some(name) = &c.name {
validate_controller_name(name)?;
}
validate_authority(&c.delegation)?;
ensure_valid(c.valid_from <= state.updated_at, "valid_from")?;
match c.retired_at {
Some(retired_at) => ensure_valid(
c.valid_from <= retired_at
&& retired_at <= state.updated_at
&& c.invalid_from
.is_none_or(|t| c.valid_from <= t && t <= retired_at),
"retirement interval",
)?,
None => ensure_valid(c.invalid_from.is_none(), "current controller")?,
}
let mut seen = BTreeSet::new();
for generation in &c.supersedes {
ensure_valid(
seen.insert(*generation)
&& state.controllers[..index]
.iter()
.any(|p| p.generation == *generation && p.valid_from < c.valid_from),
"supersedes",
)?;
}
}
Ok(())
}
fn principal_type(value: &PrincipalType) -> &'static str {
match value {
PrincipalType::Person => "person",
PrincipalType::Organization => "organization",
PrincipalType::Team => "team",
PrincipalType::Project => "project",
PrincipalType::Other => "other",
}
}
pub fn render_principal_document(
config: &DirectoryInit,
account_id: &AccountId,
state: &PrincipalState,
) -> Result<Vec<u8>> {
validate_principal_state(state)?;
let key = |generation: u32| {
state
.controllers
.iter()
.find(|c| c.generation == generation)
.map(|c| sdk_id::AgentId::from_public_key(&c.public_key))
.ok_or_else(|| invalid("supersedes"))
};
let mut controllers = Vec::new();
let mut retired_controllers = Vec::new();
for c in &state.controllers {
let record = sdk::Controller {
id: sdk_id::AgentId::from_public_key(&c.public_key),
source: config.controller_source.clone(),
valid_from: millis(c.valid_from)?,
name: c.name.clone(),
delegation: Some(match &c.delegation {
DelegationAuthority::Unrestricted => {
sdk::DelegationAuthority::Unrestricted("*".into())
}
DelegationAuthority::Restricted { scopes, audiences } => {
sdk::DelegationAuthority::Restricted(sdk::DelegationPolicy {
scopes: scopes.clone(),
audiences: audiences.clone(),
})
}
}),
supersedes: (!c.supersedes.is_empty())
.then(|| c.supersedes.iter().map(|g| key(*g)).collect::<Result<_>>())
.transpose()?,
retired_at: c.retired_at.map(millis).transpose()?,
invalid_from: c.invalid_from.map(millis).transpose()?,
};
if c.retired_at.is_some() {
retired_controllers.push(record);
} else {
controllers.push(record);
}
}
let document = sdk::PrincipalDocument {
id: principal_id(&config.principal_origin, account_id),
kind: Some(principal_type(&state.principal_type).into()),
name: None,
description: None,
avatar_url: None,
aliases: vec![],
links: vec![sdk::PrincipalLink {
name: "dMsg".into(),
url: format!("{}{account_id}", config.profile_url_prefix),
rel: "profile".into(),
}],
protocol: AGENT_DELEGATION_PROTOCOL.into(),
controllers,
retired_controllers,
delegation_query_url: Some(config.delegation_query_url.clone()),
updated_at: millis(state.updated_at)?,
extra: Default::default(),
};
sdk::validate_principal_document(&document).map_err(sdk_error)?;
serde_jcs::to_vec(&document).map_err(|error| Error::InvalidInput(error.to_string()))
}
pub fn validate_directory_init(config: &DirectoryInit) -> Result<()> {
validate_namespace(&config.issuer_namespace)?;
ensure_valid(
(1..=16).contains(&config.user_homes.len())
&& config.custom_domains.len() <= 8
&& config.custom_domains.iter().all(|d| {
!d.is_empty()
&& d.len() <= 253
&& d.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-'
})
}),
"directory homes/domains",
)?;
for (index, home) in config.user_homes.iter().enumerate() {
authenticated(*home)?;
ensure_valid(!config.user_homes[..index].contains(home), "duplicate home")?;
}
validate_principal_origin(&config.principal_origin)?;
sdk_id::validate_origin(&config.controller_source).map_err(sdk_error)?;
for url in [&config.delegation_query_url, &config.profile_url_prefix] {
let parsed = url::Url::parse(url).map_err(|_| invalid("directory URL"))?;
ensure_valid(
parsed.scheme() == "https"
&& parsed.as_str() == url.as_str()
&& parsed.query().is_none()
&& parsed.fragment().is_none(),
"directory URL",
)?;
}
ensure_valid(config.profile_url_prefix.ends_with('/'), "profile prefix")
}
pub fn account_allocator_digest(
environment: &Environment,
namespace: &str,
canister: candid::Principal,
) -> Hash {
digest(
"dmsg/account-id-generator/v1",
&("dmsg", environment, namespace, canister),
)
}
pub fn allocated_by(account_id: &AccountId, allocator_digest: &Hash) -> bool {
account_id.as_slice()[4..9] == allocator_digest[..5]
}
#[cfg(test)]
#[path = "agent_tests.rs"]
mod tests;