use candid::{CandidType, Principal};
use ic_auth_types::{ByteArrayB64, deterministic_cbor_into_vec};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use crate::{RegistryError, SignedEnvelope, TEEInfo, sha3_256};
pub const ZERO_CHALLENGE_CODE: ByteArrayB64<16> = ByteArrayB64([0u8; 16]);
pub const PERMITTED_DRIFT_MS: u64 = 60 * 1000;
pub const CHALLENGE_EXPIRES_IN_MS: u64 = 1000 * 60 * 5;
#[derive(Clone, Debug, CandidType, Deserialize, Serialize)]
pub struct Agent {
pub id: Principal,
pub info: AgentInfo,
pub created_at: u64,
pub actived_start: u64,
pub health_power: u64,
pub challenge_code: ByteArrayB64<16>,
pub challenged_at: u64,
pub challenged_by: Principal,
pub challenged_expiration: u64,
pub tee: Option<TEEInfo>,
}
#[derive(Clone, Debug, Default, CandidType, Deserialize, Serialize)]
pub struct AgentInfo {
pub handle: String,
pub handle_canister: Option<Principal>,
pub name: String,
pub image: String,
pub description: String,
pub endpoint: String,
pub protocols: Vec<AgentProtocol>,
pub provider: Option<AgentProvider>,
}
impl AgentInfo {
pub fn validate(&self) -> Result<(), String> {
validate_handle(&self.handle)?;
if self.name.is_empty() {
return Err("name is required".to_string());
}
if self.name.trim() != self.name {
return Err("name cannot contain leading or trailing whitespace".to_string());
}
if self.name.len() > 32 {
return Err("name cannot be longer than 32 bytes".to_string());
}
if self.description.len() > 512 {
return Err("description cannot be longer than 512 bytes".to_string());
}
let endpoint = url::Url::parse(&self.endpoint)
.map_err(|_| "endpoint is not a valid URL".to_string())?;
if endpoint.scheme() != "https" {
return Err("endpoint should start with https://".to_string());
}
let mut names = HashSet::new();
for protocol in &self.protocols {
protocol.validate()?;
if !names.insert(protocol.name.clone()) {
return Err(format!("duplicate protocol name: {}", protocol.name));
}
}
if let Some(provider) = &self.provider {
provider.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, CandidType, Deserialize, Serialize)]
pub struct AgentProtocol {
pub name: String,
pub endpoint: String,
pub version: Option<String>,
}
impl AgentProtocol {
pub fn validate(&self) -> Result<(), String> {
if self.name.is_empty() {
return Err("protocol name is required".to_string());
}
if self.name.trim().to_ascii_uppercase() != self.name {
return Err("protocol name must be uppercase".to_string());
}
if self.name.len() > 12 {
return Err("protocol name cannot be longer than 12 bytes".to_string());
}
if self.endpoint.is_empty() {
return Err("protocol endpoint is required".to_string());
}
let endpoint = self.endpoint.to_ascii_lowercase();
if endpoint.starts_with("http") {
let u = url::Url::parse(&self.endpoint)
.map_err(|_| "protocol endpoint is not a valid URL".to_string())?;
if u.scheme() != "https" {
return Err("protocol endpoint must use https scheme".to_string());
}
}
Ok(())
}
}
#[derive(Clone, Debug, CandidType, Deserialize, Serialize)]
pub struct AgentProvider {
pub id: Principal,
pub name: String,
pub logo: String,
pub url: String,
}
impl AgentProvider {
pub fn validate(&self) -> Result<(), String> {
if self.name.is_empty() {
return Err("provider name is required".to_string());
}
if self.name.trim() != self.name {
return Err("provider name cannot contain leading or trailing whitespace".to_string());
}
if self.name.len() > 32 {
return Err("provider name cannot be longer than 32 bytes".to_string());
}
let logo = url::Url::parse(&self.logo)
.map_err(|_| "provider logo is not a valid URL".to_string())?;
if logo.scheme() != "https" {
return Err("provider logo should start with https://".to_string());
}
let u = url::Url::parse(&self.url)
.map_err(|_| "provider url is not a valid URL".to_string())?;
if u.scheme() != "https" {
return Err("provider url should start with https://".to_string());
}
if url::Url::parse(&self.url).is_err() {
return Err("provider url is not a valid URL".to_string());
}
Ok(())
}
}
pub fn validate_handle(handle: &str) -> Result<(), String> {
if handle.is_empty() {
return Err("empty string".into());
}
if handle.len() > 64 {
return Err("string length exceeds the limit 64".into());
}
let mut iter = handle.chars();
if !matches!(iter.next(), Some('a'..='z')) {
return Err("handle must start with a lowercase letter".into());
}
for c in iter {
if !matches!(c, 'a'..='z' | '0'..='9' | '_' ) {
return Err(format!("invalid character: {}", c));
}
}
Ok(())
}
#[derive(Clone, Debug, CandidType, Deserialize, Serialize)]
pub struct ChallengeRequest {
pub registry: Principal,
pub code: ByteArrayB64<16>,
pub agent: AgentInfo,
pub created_at: u64,
pub authentication: Option<SignedEnvelope>,
}
#[derive(Debug, Serialize)]
struct ChallengeRequestCoreRef<'a> {
registry: &'a Principal,
code: &'a ByteArrayB64<16>,
agent: &'a AgentInfo,
created_at: u64,
}
impl ChallengeRequest {
pub fn core_digest(&self) -> [u8; 32] {
let data = deterministic_cbor_into_vec(&ChallengeRequestCoreRef {
registry: &self.registry,
code: &self.code,
agent: &self.agent,
created_at: self.created_at,
})
.expect("failed to serialize ChallengeRequestCoreRef");
sha3_256(&data)
}
pub fn digest(&self) -> [u8; 32] {
let data =
deterministic_cbor_into_vec(&self).expect("failed to serialize ChallengeRequest");
sha3_256(&data)
}
pub fn validate(&self, now_ms: u64, registry: &Principal) -> Result<(), String> {
self.agent.validate()?;
if self.created_at + CHALLENGE_EXPIRES_IN_MS + PERMITTED_DRIFT_MS < now_ms {
return Err(format!(
"challenge request is too old, created_at: {}, now: {}",
self.created_at, now_ms
));
}
if self.created_at > now_ms + PERMITTED_DRIFT_MS {
return Err(format!(
"challenge request is in the future, created_at: {}, now: {}",
self.created_at, now_ms
));
}
if self.registry != *registry {
return Err(format!(
"challenge request is for a different registry, expected: {}, got: {}",
registry, self.registry
));
}
Ok(())
}
pub fn verify(&self, now_ms: u64, registry: Principal) -> Result<(), RegistryError> {
self.validate(now_ms, ®istry)
.map_err(|error| RegistryError::BadRequest { error })?;
let digest = self.core_digest();
if let Some(auth) = &self.authentication {
auth.verify(now_ms, Some(registry), Some(&digest))
.map_err(|error| RegistryError::Unauthorized { error })?;
} else {
return Err(RegistryError::BadRequest {
error: "challenger authentication is not provided".to_string(),
});
}
Ok(())
}
}
#[derive(Clone, Debug, CandidType, Deserialize, Serialize)]
pub struct ChallengeEnvelope {
pub request: ChallengeRequest,
pub authentication: SignedEnvelope,
pub tee: Option<TEEInfo>,
}
impl ChallengeEnvelope {
pub fn verify(&self, now_ms: u64, registry: Principal) -> Result<(), RegistryError> {
if let Some(tee) = &self.tee {
tee.validate()
.map_err(|error| RegistryError::BadRequest { error })?;
}
self.request.verify(now_ms, registry)?;
let digest = self.request.digest();
self.authentication
.verify(now_ms, Some(registry), Some(&digest))
.map_err(|error| RegistryError::Unauthorized { error })?;
Ok(())
}
}
pub static AGENT_EVENT_API: &str = "on_agent_event";
#[derive(Clone, Debug, CandidType, Deserialize, Serialize)]
pub struct AgentEvent {
pub id: Principal,
pub kind: AgentEventKind,
pub ts: u64,
}
#[derive(
Clone, Debug, CandidType, Deserialize, Serialize, Eq, PartialEq, Hash, Ord, PartialOrd,
)]
pub enum AgentEventKind {
Registered,
Challenged,
Unregistered,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_protocol_validate_rejects_lowercase_name() {
let protocol = AgentProtocol {
name: "mcp".into(),
endpoint: "https://agent.example/protocol".into(),
version: Some("v1".into()),
};
assert!(matches!(protocol.validate(), Err(message) if message.contains("uppercase")));
}
#[test]
fn agent_info_validate_accepts_valid_configuration() {
let info = sample_agent_info();
assert!(info.validate().is_ok());
}
#[test]
fn agent_info_validate_detects_duplicate_protocol_names() {
let mut info = sample_agent_info();
info.protocols.push(AgentProtocol {
name: "MCP".into(),
endpoint: "https://agent.example/dup".into(),
version: Some("v1".into()),
});
assert!(
matches!(info.validate(), Err(message) if message.contains("duplicate protocol name"))
);
}
#[test]
fn agent_provider_validate_rejects_insecure_logo_url() {
let provider = AgentProvider {
id: sample_principal(3),
name: "Anda Labs".into(),
logo: "http://example.com/logo.png".into(),
url: "https://example.com".into(),
};
assert!(
matches!(provider.validate(), Err(message) if message.contains("should start with https"))
);
}
#[test]
fn validate_handle_enforces_rules() {
assert!(validate_handle("agent_1").is_ok());
assert!(validate_handle("").is_err());
assert!(validate_handle("1agent").is_err());
assert!(validate_handle("agent-1").is_err());
}
#[test]
fn challenge_request_validate_accepts_recent_requests() {
let registry = sample_principal(10);
let now_ms = 1_000_000;
let request = sample_challenge_request(now_ms - 1_000, registry);
assert!(request.validate(now_ms, ®istry).is_ok());
}
#[test]
fn challenge_request_validate_rejects_expired_requests() {
let registry = sample_principal(11);
let now_ms = 2_000_000;
let created_at = now_ms - CHALLENGE_EXPIRES_IN_MS - PERMITTED_DRIFT_MS - 1;
let request = sample_challenge_request(created_at, registry);
assert!(
matches!(request.validate(now_ms, ®istry), Err(message) if message.contains("too old"))
);
}
#[test]
fn challenge_request_validate_rejects_future_requests() {
let registry = sample_principal(12);
let now_ms = 3_000_000;
let created_at = now_ms + PERMITTED_DRIFT_MS + 1;
let request = sample_challenge_request(created_at, registry);
assert!(
matches!(request.validate(now_ms, ®istry), Err(message) if message.contains("in the future"))
);
}
#[test]
fn challenge_request_validate_rejects_wrong_registry() {
let registry = sample_principal(13);
let wrong_registry = sample_principal(14);
let now_ms = 4_000_000;
let request = sample_challenge_request(now_ms, wrong_registry);
assert!(
matches!(request.validate(now_ms, ®istry), Err(message) if message.contains("different registry"))
);
}
fn sample_principal(seed: u8) -> Principal {
Principal::self_authenticating([seed; 32])
}
fn sample_agent_info() -> AgentInfo {
AgentInfo {
handle: "agent_one".into(),
handle_canister: Some(sample_principal(1)),
name: "Agent One".into(),
image: "https://agent.example/image.png".into(),
description: "Helpful agent".into(),
endpoint: "https://agent.example/api".into(),
protocols: vec![AgentProtocol {
name: "MCP".into(),
endpoint: "https://agent.example/protocol".into(),
version: Some("v1".into()),
}],
provider: Some(AgentProvider {
id: sample_principal(2),
name: "Anda Labs".into(),
logo: "https://example.com/logo.png".into(),
url: "https://example.com".into(),
}),
}
}
fn sample_challenge_request(created_at: u64, registry: Principal) -> ChallengeRequest {
ChallengeRequest {
registry,
code: ByteArrayB64([1u8; 16]),
agent: sample_agent_info(),
created_at,
authentication: None,
}
}
}