use std::time::{Duration, SystemTime};
use agent_uri::AgentUri;
use agent_uri_attestation::Verifier;
use agent_uri_dht::{Mutation, RecordVersion, signing_payload};
use libp2p::kad::RecordKey;
use crate::config::AttestationPolicy;
use crate::keys::identity_key;
use crate::record::{IdentityRecord, WireKind, millis};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Rejection {
WrongKey,
NoEndpoints,
ExpiryOutOfBounds,
MissingRefreshTtl,
BadSignature,
MissingAttestation,
InvalidAttestation(String),
AgentKeyMismatch,
UnknownTrustRoot,
NotNewer,
UnverifiedKeyChange,
}
impl std::fmt::Display for Rejection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WrongKey => f.write_str("record does not belong at the key it arrived on"),
Self::NoEndpoints => f.write_str("live registration names no endpoints"),
Self::ExpiryOutOfBounds => {
f.write_str("record expiry is in the past or beyond this node's ceiling")
}
Self::MissingRefreshTtl => {
f.write_str("refresh record omits the TTL its signature covers")
}
Self::BadSignature => {
f.write_str("signature does not cover this write under the record's agent key")
}
Self::MissingAttestation => f.write_str("record carries no attestation"),
Self::InvalidAttestation(reason) => write!(f, "attestation did not verify: {reason}"),
Self::AgentKeyMismatch => {
f.write_str("attestation attests a different key than the record names")
}
Self::UnknownTrustRoot => f.write_str("record's trust root is not known to this node"),
Self::NotNewer => f.write_str("record does not advance past the one already held"),
Self::UnverifiedKeyChange => f.write_str(
"record would take the key over under a different agent key without a \
verifiable attestation",
),
}
}
}
impl std::error::Error for Rejection {}
pub fn validate_identity(
record: &IdentityRecord,
key: &RecordKey,
held: Option<&IdentityRecord>,
verifier: &Verifier,
policy: AttestationPolicy,
now: SystemTime,
max_ttl: Duration,
) -> Result<(), Rejection> {
let uri = record.state.agent_uri();
if identity_key(uri) != *key {
return Err(Rejection::WrongKey);
}
check_shape(record, now, max_ttl)?;
check_signature(record)?;
let attested = check_attestation(record, verifier, policy)?;
check_supersession(record, held, attested)?;
Ok(())
}
fn check_shape(
record: &IdentityRecord,
now: SystemTime,
max_ttl: Duration,
) -> Result<(), Rejection> {
if !record.is_tombstone() && record.state.endpoints().is_empty() {
return Err(Rejection::NoEndpoints);
}
if matches!(record.kind, WireKind::Refresh) && record.refresh_ttl().is_none() {
return Err(Rejection::MissingRefreshTtl);
}
let expires = millis(record.state.expires_at());
if expires <= millis(now) || expires > millis(now + max_ttl) {
return Err(Rejection::ExpiryOutOfBounds);
}
Ok(())
}
fn check_signature(record: &IdentityRecord) -> Result<(), Rejection> {
let state = &record.state;
let version = RecordVersion::new(state.registered_at(), state.sequence());
let mutation = match record.kind {
WireKind::Register => Mutation::Register {
agent_key: state.agent_key(),
endpoints: state.endpoints(),
expires_at: state.expires_at(),
},
WireKind::UpdateEndpoint => Mutation::UpdateEndpoint {
endpoints: state.endpoints(),
expires_at: state.expires_at(),
},
WireKind::Refresh => Mutation::Refresh {
ttl: record.refresh_ttl().ok_or(Rejection::MissingRefreshTtl)?,
expires_at: state.expires_at(),
},
WireKind::Deregister => Mutation::Deregister,
};
let payload = signing_payload(state.agent_uri(), version, &mutation);
state
.agent_key()
.verify(&payload, &record.signature.into())
.map_err(|_| Rejection::BadSignature)
}
fn check_attestation(
record: &IdentityRecord,
verifier: &Verifier,
policy: AttestationPolicy,
) -> Result<bool, Rejection> {
if matches!(policy, AttestationPolicy::Unverified) {
return Ok(false);
}
let uri = record.state.agent_uri();
let known = verifier.has_trusted_root(uri.trust_root().as_str());
if !known {
return if policy.accepts_unknown_roots() {
Ok(false)
} else {
Err(Rejection::UnknownTrustRoot)
};
}
let token = record
.state
.attestation()
.ok_or(Rejection::MissingAttestation)?;
let claims = verifier
.verify_for_capability(token, uri, uri.capability_path())
.map_err(|error| Rejection::InvalidAttestation(error.to_string()))?;
let attested = claims
.agent_verifying_key()
.map_err(|error| Rejection::InvalidAttestation(error.to_string()))?;
if attested != *record.state.agent_key() {
return Err(Rejection::AgentKeyMismatch);
}
Ok(true)
}
fn check_supersession(
record: &IdentityRecord,
held: Option<&IdentityRecord>,
attested: bool,
) -> Result<(), Rejection> {
let Some(held) = held else {
return Ok(());
};
if record.version() <= held.version() {
return Err(Rejection::NotNewer);
}
if record.state.agent_key() != held.state.agent_key() && !attested {
return Err(Rejection::UnverifiedKeyChange);
}
Ok(())
}
#[must_use]
pub fn matches_query(
agent_uri: &AgentUri,
queried: &agent_uri::CapabilityPath,
exact: bool,
) -> bool {
let path = agent_uri.capability_path();
if exact {
path == queried
} else {
path.starts_with(queried)
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_uri::CapabilityPath;
use agent_uri_attestation::{AcceptAll, Issuer, SigningKey};
use agent_uri_dht::{Endpoint, MutationProof, Registration};
const TRUST_ROOT: &str = "anthropic.com";
fn uri(suffix: &str) -> AgentUri {
AgentUri::parse(&format!(
"agent://anthropic.com/assistant/chat/llm_01h455vb4pex5vsknk084sn0{suffix}"
))
.unwrap()
}
fn endpoints() -> Vec<Endpoint> {
vec![Endpoint::https("agent.anthropic.com:443")]
}
struct Fixture {
verifier: Verifier,
issuer: Issuer,
}
impl Fixture {
fn new() -> Self {
let root_key = SigningKey::generate();
let issuer = Issuer::new(TRUST_ROOT, root_key.clone(), Duration::from_hours(1));
let mut verifier = Verifier::new().with_revocation(AcceptAll);
verifier.add_trusted_root(TRUST_ROOT, root_key.verifying_key());
Self { verifier, issuer }
}
fn attested(&self, uri: &AgentUri, agent: &SigningKey) -> Registration {
let token = self
.issuer
.issue(uri, &agent.verifying_key(), vec![])
.expect("issuing a token for a well-formed URI");
Registration::new(uri.clone(), agent.verifying_key(), endpoints())
.with_attestation(token)
}
}
fn registered(state: Registration, signer: &SigningKey) -> IdentityRecord {
let proof = MutationProof::sign_registration(signer, &state);
IdentityRecord {
signature: proof.signature().into(),
state,
kind: WireKind::Register,
refresh_ttl_millis: None,
}
}
fn refreshed(state: Registration, signer: &SigningKey, ttl: Duration) -> IdentityRecord {
let expires_at = SystemTime::now() + ttl;
let proof =
MutationProof::sign_next(signer, &state, &Mutation::Refresh { ttl, expires_at });
let mut state = state.with_sequence(proof.sequence());
state.set_expires_at(expires_at);
IdentityRecord {
signature: proof.signature().into(),
state,
kind: WireKind::Refresh,
refresh_ttl_millis: Some(u64::try_from(ttl.as_millis()).unwrap()),
}
}
fn migrated(state: &Registration, signer: &SigningKey, moved: Vec<Endpoint>) -> IdentityRecord {
let proof = MutationProof::sign_next(
signer,
state,
&Mutation::UpdateEndpoint {
endpoints: &moved,
expires_at: state.expires_at(),
},
);
let mut state = state.clone().with_sequence(proof.sequence());
state.update_endpoints(moved);
IdentityRecord {
signature: proof.signature().into(),
state,
kind: WireKind::UpdateEndpoint,
refresh_ttl_millis: None,
}
}
fn validate(
record: &IdentityRecord,
held: Option<&IdentityRecord>,
fixture: &Fixture,
policy: AttestationPolicy,
) -> Result<(), Rejection> {
validate_identity(
record,
&identity_key(record.state.agent_uri()),
held,
&fixture.verifier,
policy,
SystemTime::now(),
Duration::from_hours(24),
)
}
#[test]
fn an_honest_registration_is_stored() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let record = registered(fixture.attested(&uri("2q"), &agent), &agent);
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Ok(())
);
}
#[test]
fn a_record_at_the_wrong_key_is_refused() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let record = registered(fixture.attested(&uri("2q"), &agent), &agent);
let result = validate_identity(
&record,
&identity_key(&uri("2r")),
None,
&fixture.verifier,
AttestationPolicy::VerifyKnownRoots,
SystemTime::now(),
Duration::from_hours(24),
);
assert_eq!(result, Err(Rejection::WrongKey));
}
#[test]
fn a_registration_signed_by_another_key_is_refused() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let attacker = SigningKey::generate();
let record = registered(fixture.attested(&uri("2q"), &agent), &attacker);
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Err(Rejection::BadSignature)
);
}
#[test]
fn a_lifted_token_presented_under_another_key_is_refused() {
let fixture = Fixture::new();
let victim = SigningKey::generate();
let attacker = SigningKey::generate();
let agent_uri = uri("2q");
let token = fixture
.issuer
.issue(&agent_uri, &victim.verifying_key(), vec![])
.unwrap();
let state = Registration::new(agent_uri, attacker.verifying_key(), endpoints())
.with_attestation(token);
let record = registered(state, &attacker);
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Err(Rejection::AgentKeyMismatch)
);
}
#[test]
fn substituted_endpoints_break_the_signature() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let mut record = registered(fixture.attested(&uri("2q"), &agent), &agent);
record
.state
.update_endpoints(vec![Endpoint::https("attacker.example:443")]);
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Err(Rejection::BadSignature)
);
}
#[test]
fn a_live_registration_needs_endpoints() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let state = Registration::new(uri("2q"), agent.verifying_key(), vec![]);
let record = registered(state, &agent);
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::Unverified),
Err(Rejection::NoEndpoints)
);
}
#[test]
fn a_tombstone_may_have_no_endpoints() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let live = fixture.attested(&uri("2q"), &agent);
let mut state = live.clone().with_sequence(1);
state.update_endpoints(vec![]);
let proof = MutationProof::sign_next(&agent, &live, &Mutation::Deregister);
let record = IdentityRecord {
signature: proof.signature().into(),
state,
kind: WireKind::Deregister,
refresh_ttl_millis: None,
};
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Ok(())
);
}
#[test]
fn an_expiry_beyond_this_nodes_ceiling_is_refused() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let state = fixture
.attested(&uri("2q"), &agent)
.with_ttl(Duration::from_hours(48));
let record = registered(state, &agent);
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Err(Rejection::ExpiryOutOfBounds)
);
}
#[test]
fn an_already_expired_record_is_refused() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let state = fixture
.attested(&uri("2q"), &agent)
.with_expires_at(SystemTime::now() - Duration::from_secs(1));
let record = registered(state, &agent);
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Err(Rejection::ExpiryOutOfBounds)
);
}
#[test]
fn a_replay_of_the_held_version_is_refused() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let record = registered(fixture.attested(&uri("2q"), &agent), &agent);
assert_eq!(
validate(
&record,
Some(&record),
&fixture,
AttestationPolicy::VerifyKnownRoots
),
Err(Rejection::NotNewer)
);
}
#[test]
fn a_higher_sequence_supersedes() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let held = registered(fixture.attested(&uri("2q"), &agent), &agent);
let moved = vec![Endpoint::https("eu-west-1.anthropic.com:443")];
let update = migrated(&held.state, &agent, moved);
assert_eq!(
validate(
&update,
Some(&held),
&fixture,
AttestationPolicy::VerifyKnownRoots
),
Ok(())
);
}
#[test]
fn a_migration_whose_expiry_was_rewritten_in_flight_is_refused() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let held = registered(fixture.attested(&uri("2q"), &agent), &agent);
let moved = vec![Endpoint::https("eu-west-1.anthropic.com:443")];
let mut tampered = migrated(&held.state, &agent, moved);
let honest = tampered.state.expires_at();
tampered
.state
.set_expires_at(honest + Duration::from_hours(12));
assert_eq!(
validate(
&tampered,
Some(&held),
&fixture,
AttestationPolicy::VerifyKnownRoots
),
Err(Rejection::BadSignature)
);
}
#[test]
fn a_refresh_whose_expiry_was_rewritten_in_flight_is_refused() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let held = fixture.attested(&uri("2q"), &agent);
let mut tampered = refreshed(held, &agent, Duration::from_mins(30));
let honest = tampered.state.expires_at();
tampered
.state
.set_expires_at(honest + Duration::from_hours(12));
assert_eq!(
validate(
&tampered,
None,
&fixture,
AttestationPolicy::VerifyKnownRoots
),
Err(Rejection::BadSignature)
);
}
#[test]
fn an_honest_refresh_is_stored() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let held = fixture.attested(&uri("2q"), &agent);
let record = refreshed(held, &agent, Duration::from_mins(30));
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Ok(())
);
}
#[test]
fn a_refresh_without_its_ttl_is_refused() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let held = fixture.attested(&uri("2q"), &agent);
let record = IdentityRecord {
refresh_ttl_millis: None,
..refreshed(held, &agent, Duration::from_mins(30))
};
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Err(Rejection::MissingRefreshTtl)
);
}
#[test]
fn a_refresh_carrying_a_different_ttl_than_it_signed_is_refused() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let held = fixture.attested(&uri("2q"), &agent);
let record = IdentityRecord {
refresh_ttl_millis: Some(u64::try_from(Duration::from_hours(12).as_millis()).unwrap()),
..refreshed(held, &agent, Duration::from_mins(30))
};
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Err(Rejection::BadSignature)
);
}
#[test]
fn a_proof_for_one_operation_does_not_authorize_another() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let honest = registered(fixture.attested(&uri("2q"), &agent), &agent);
let forged = IdentityRecord {
kind: WireKind::Deregister,
..honest
};
assert_eq!(
validate(&forged, None, &fixture, AttestationPolicy::Unverified),
Err(Rejection::BadSignature)
);
}
#[test]
fn an_unverified_key_change_cannot_take_a_record_over() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let attacker = SigningKey::generate();
let held = registered(fixture.attested(&uri("2q"), &agent), &agent);
let takeover = registered(
Registration::new(uri("2q"), attacker.verifying_key(), endpoints())
.with_registered_at(held.state.registered_at() + Duration::from_mins(1)),
&attacker,
);
assert_eq!(
validate(
&takeover,
Some(&held),
&fixture,
AttestationPolicy::Unverified
),
Err(Rejection::UnverifiedKeyChange)
);
}
#[test]
fn a_verified_key_change_is_allowed() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let successor = SigningKey::generate();
let held = registered(fixture.attested(&uri("2q"), &agent), &agent);
let state = fixture
.attested(&uri("2q"), &successor)
.with_registered_at(held.state.registered_at() + Duration::from_mins(1));
let reborn = registered(state, &successor);
assert_eq!(
validate(
&reborn,
Some(&held),
&fixture,
AttestationPolicy::VerifyKnownRoots
),
Ok(())
);
}
#[test]
fn a_strict_node_refuses_trust_roots_it_does_not_know() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let foreign =
AgentUri::parse("agent://openai.com/assistant/chat/llm_01h455vb4pex5vsknk084sn02q")
.unwrap();
let state = Registration::new(foreign, agent.verifying_key(), endpoints());
let record = registered(state, &agent);
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::RequireVerified),
Err(Rejection::UnknownTrustRoot)
);
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Ok(())
);
}
#[test]
fn a_known_root_still_requires_a_token() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let state = Registration::new(uri("2q"), agent.verifying_key(), endpoints());
let record = registered(state, &agent);
assert_eq!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Err(Rejection::MissingAttestation)
);
}
#[test]
fn a_garbage_token_for_a_known_root_is_refused() {
let fixture = Fixture::new();
let agent = SigningKey::generate();
let state = Registration::new(uri("2q"), agent.verifying_key(), endpoints())
.with_attestation("v4.public.not-a-token");
let record = registered(state, &agent);
assert!(matches!(
validate(&record, None, &fixture, AttestationPolicy::VerifyKnownRoots),
Err(Rejection::InvalidAttestation(_))
));
}
#[test]
fn an_injected_pointer_to_an_unrelated_agent_is_filtered_out() {
let elsewhere =
AgentUri::parse("agent://anthropic.com/payments/settle/llm_01h455vb4pex5vsknk084sn02q")
.unwrap();
let queried = CapabilityPath::parse("assistant").unwrap();
assert!(!matches_query(&elsewhere, &queried, false));
assert!(matches_query(&uri("2q"), &queried, false));
}
#[test]
fn exact_mode_does_not_return_descendants() {
let deep = AgentUri::parse(
"agent://anthropic.com/assistant/chat/streaming/llm_01h455vb4pex5vsknk084sn02q",
)
.unwrap();
let queried = CapabilityPath::parse("assistant/chat").unwrap();
assert!(matches_query(&deep, &queried, false));
assert!(!matches_query(&deep, &queried, true));
}
#[test]
fn rejections_say_what_went_wrong() {
assert!(Rejection::WrongKey.to_string().contains("key"));
assert!(
Rejection::InvalidAttestation("clock skew".into())
.to_string()
.contains("clock skew")
);
assert!(
Rejection::UnverifiedKeyChange
.to_string()
.contains("agent key")
);
}
}