use std::time::{Duration, SystemTime};
use agent_uri::AgentUri;
use agent_uri_attestation::{Signature, SigningKey, VerifyingKey};
use crate::registration::system_time_to_millis;
use crate::{DhtError, Endpoint, Registration};
const DOMAIN: &[u8] = b"agent-uri/dht-mutation/v2\x00";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MutationKind {
Register,
UpdateEndpoint,
Refresh,
Deregister,
}
impl MutationKind {
const fn tag(self) -> u8 {
match self {
Self::UpdateEndpoint => 1,
Self::Refresh => 2,
Self::Deregister => 3,
Self::Register => 4,
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::UpdateEndpoint => "update_endpoint",
Self::Refresh => "refresh",
Self::Deregister => "deregister",
Self::Register => "register",
}
}
}
impl std::fmt::Display for MutationKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mutation<'a> {
Register {
agent_key: &'a VerifyingKey,
endpoints: &'a [Endpoint],
expires_at: SystemTime,
},
UpdateEndpoint {
endpoints: &'a [Endpoint],
expires_at: SystemTime,
},
Refresh {
ttl: Duration,
expires_at: SystemTime,
},
Deregister,
}
impl Mutation<'_> {
#[must_use]
pub const fn kind(&self) -> MutationKind {
match self {
Self::Register { .. } => MutationKind::Register,
Self::UpdateEndpoint { .. } => MutationKind::UpdateEndpoint,
Self::Refresh { .. } => MutationKind::Refresh,
Self::Deregister => MutationKind::Deregister,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RecordVersion {
registered_at: SystemTime,
sequence: u64,
}
impl RecordVersion {
#[must_use]
pub const fn new(registered_at: SystemTime, sequence: u64) -> Self {
Self {
registered_at,
sequence,
}
}
#[must_use]
pub fn of(record: &Registration) -> Self {
Self::new(record.registered_at(), record.sequence())
}
#[must_use]
pub fn next(record: &Registration) -> Self {
Self::new(record.registered_at(), record.sequence().saturating_add(1))
}
#[must_use]
pub const fn registered_at(&self) -> SystemTime {
self.registered_at
}
#[must_use]
pub const fn sequence(&self) -> u64 {
self.sequence
}
}
fn push_field(out: &mut Vec<u8>, field: &[u8]) {
let len = u64::try_from(field.len()).expect("a field length fits in u64");
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(field);
}
fn push_endpoint(out: &mut Vec<u8>, endpoint: &Endpoint) {
push_field(out, endpoint.protocol().as_bytes());
push_field(out, endpoint.address().as_bytes());
match endpoint.path() {
Some(path) => {
out.push(1);
push_field(out, path.as_bytes());
}
None => out.push(0),
}
}
fn push_endpoints(out: &mut Vec<u8>, endpoints: &[Endpoint]) {
let count = u64::try_from(endpoints.len()).expect("an endpoint count fits in u64");
out.extend_from_slice(&count.to_be_bytes());
for endpoint in endpoints {
push_endpoint(out, endpoint);
}
}
#[must_use]
pub fn signing_payload(
agent_uri: &AgentUri,
version: RecordVersion,
mutation: &Mutation<'_>,
) -> Vec<u8> {
let mut out = Vec::from(DOMAIN);
out.push(mutation.kind().tag());
out.extend_from_slice(&system_time_to_millis(version.registered_at()).to_be_bytes());
out.extend_from_slice(&version.sequence().to_be_bytes());
push_field(&mut out, agent_uri.canonical().as_bytes());
match mutation {
Mutation::Register {
agent_key,
endpoints,
expires_at,
} => {
push_field(&mut out, &agent_key.to_bytes());
out.extend_from_slice(&system_time_to_millis(*expires_at).to_be_bytes());
push_endpoints(&mut out, endpoints);
}
Mutation::UpdateEndpoint {
endpoints,
expires_at,
} => {
out.extend_from_slice(&system_time_to_millis(*expires_at).to_be_bytes());
push_endpoints(&mut out, endpoints);
}
Mutation::Refresh { ttl, expires_at } => {
out.extend_from_slice(&system_time_to_millis(*expires_at).to_be_bytes());
out.extend_from_slice(&ttl.as_secs().to_be_bytes());
out.extend_from_slice(&ttl.subsec_nanos().to_be_bytes());
}
Mutation::Deregister => {}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MutationProof {
sequence: u64,
signature: Signature,
}
impl MutationProof {
#[must_use]
pub fn sign_registration(key: &SigningKey, record: &Registration) -> Self {
Self::sign(
key,
record.agent_uri(),
RecordVersion::of(record),
&Self::registration_of(record),
)
}
fn registration_of(record: &Registration) -> Mutation<'_> {
Mutation::Register {
agent_key: record.agent_key(),
endpoints: record.endpoints(),
expires_at: record.expires_at(),
}
}
pub fn authorizes_registration(&self, record: &Registration) -> Result<(), DhtError> {
let version = RecordVersion::new(record.registered_at(), self.sequence);
record
.agent_key()
.verify(
&signing_payload(record.agent_uri(), version, &Self::registration_of(record)),
&self.signature,
)
.map_err(|_| DhtError::Unauthorized {
agent_uri: record.agent_uri().canonical(),
operation: MutationKind::Register,
})
}
#[must_use]
pub fn sign_next(key: &SigningKey, record: &Registration, mutation: &Mutation<'_>) -> Self {
Self::sign(
key,
record.agent_uri(),
RecordVersion::next(record),
mutation,
)
}
#[must_use]
pub fn sign(
key: &SigningKey,
agent_uri: &AgentUri,
version: RecordVersion,
mutation: &Mutation<'_>,
) -> Self {
Self {
sequence: version.sequence(),
signature: key.sign(&signing_payload(agent_uri, version, mutation)),
}
}
#[must_use]
pub const fn from_parts(sequence: u64, signature: Signature) -> Self {
Self {
sequence,
signature,
}
}
#[must_use]
pub const fn sequence(&self) -> u64 {
self.sequence
}
#[must_use]
pub const fn signature(&self) -> Signature {
self.signature
}
pub fn authorizes(
&self,
record: &Registration,
mutation: &Mutation<'_>,
) -> Result<(), DhtError> {
let version = RecordVersion::new(record.registered_at(), self.sequence);
record
.agent_key()
.verify(
&signing_payload(record.agent_uri(), version, mutation),
&self.signature,
)
.map_err(|_| DhtError::Unauthorized {
agent_uri: record.agent_uri().canonical(),
operation: mutation.kind(),
})?;
if self.sequence <= record.sequence() {
return Err(DhtError::StaleSequence {
agent_uri: record.agent_uri().canonical(),
presented: self.sequence,
current: record.sequence(),
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_uri_attestation::VerifyingKey;
fn test_uri() -> AgentUri {
AgentUri::parse("agent://anthropic.com/assistant/chat/llm_01h455vb4pex5vsknk084sn02q")
.unwrap()
}
fn other_uri() -> AgentUri {
AgentUri::parse("agent://anthropic.com/assistant/chat/llm_01h455vb4pex5vsknk084sn02r")
.unwrap()
}
fn record(uri: &AgentUri, key: &VerifyingKey) -> Registration {
Registration::new(uri.clone(), key.clone(), vec![Endpoint::https("a.example")])
}
fn migration<'a>(record: &Registration, endpoints: &'a [Endpoint]) -> Mutation<'a> {
Mutation::UpdateEndpoint {
endpoints,
expires_at: record.expires_at(),
}
}
fn moved_to(endpoints: &[Endpoint]) -> Mutation<'_> {
Mutation::UpdateEndpoint {
endpoints,
expires_at: SystemTime::UNIX_EPOCH,
}
}
fn renewal(ttl: Duration) -> Mutation<'static> {
Mutation::Refresh {
ttl,
expires_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000),
}
}
#[test]
fn a_proof_authorizes_the_mutation_it_signed() {
let key = SigningKey::generate();
let record = record(&test_uri(), &key.verifying_key());
let endpoints = vec![Endpoint::https("b.example")];
let mutation = migration(&record, &endpoints);
let proof = MutationProof::sign_next(&key, &record, &mutation);
assert!(proof.authorizes(&record, &mutation).is_ok());
}
#[test]
fn a_proof_does_not_authorize_different_endpoints() {
let key = SigningKey::generate();
let record = record(&test_uri(), &key.verifying_key());
let honest = vec![Endpoint::https("honest.example")];
let attacker = vec![Endpoint::https("attacker.example")];
let proof = MutationProof::sign_next(&key, &record, &migration(&record, &honest));
let result = proof.authorizes(&record, &migration(&record, &attacker));
assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
}
#[test]
fn a_migration_proof_does_not_authorize_a_different_expiry() {
let key = SigningKey::generate();
let record = record(&test_uri(), &key.verifying_key());
let endpoints = vec![Endpoint::https("b.example")];
let proof = MutationProof::sign_next(&key, &record, &migration(&record, &endpoints));
let result = proof.authorizes(
&record,
&Mutation::UpdateEndpoint {
endpoints: &endpoints,
expires_at: record.expires_at() + Duration::from_hours(23),
},
);
assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
}
#[test]
fn a_refresh_proof_does_not_authorize_a_different_expiry() {
let key = SigningKey::generate();
let record = record(&test_uri(), &key.verifying_key());
let ttl = Duration::from_hours(1);
let proof = MutationProof::sign_next(&key, &record, &renewal(ttl));
let Mutation::Refresh { expires_at, .. } = renewal(ttl) else {
unreachable!("renewal builds a refresh")
};
let result = proof.authorizes(
&record,
&Mutation::Refresh {
ttl,
expires_at: expires_at + Duration::from_hours(23),
},
);
assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
}
#[test]
fn a_proof_does_not_cross_operations() {
let key = SigningKey::generate();
let record = record(&test_uri(), &key.verifying_key());
let proof = MutationProof::sign_next(&key, &record, &renewal(Duration::from_hours(1)));
let result = proof.authorizes(&record, &Mutation::Deregister);
assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
}
#[test]
fn a_proof_does_not_transfer_to_another_agent() {
let key = SigningKey::generate();
let mine = record(&test_uri(), &key.verifying_key());
let theirs = Registration::new(
other_uri(),
key.verifying_key(),
vec![Endpoint::https("a.example")],
)
.with_registered_at(mine.registered_at());
let proof = MutationProof::sign_next(&key, &mine, &Mutation::Deregister);
let result = proof.authorizes(&theirs, &Mutation::Deregister);
assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
}
#[test]
fn a_proof_does_not_transfer_to_another_sequence() {
let key = SigningKey::generate();
let record = record(&test_uri(), &key.verifying_key());
let proof = MutationProof::sign_next(&key, &record, &Mutation::Deregister);
let renumbered = MutationProof::from_parts(proof.sequence() + 1, proof.signature());
let result = renumbered.authorizes(&record, &Mutation::Deregister);
assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
}
#[test]
fn a_proof_does_not_transfer_to_another_record_instance() {
let key = SigningKey::generate();
let before = record(&test_uri(), &key.verifying_key())
.with_registered_at(SystemTime::now() - Duration::from_hours(1));
let after = record(&test_uri(), &key.verifying_key());
let captured = MutationProof::sign_next(&key, &before, &Mutation::Deregister);
let result = captured.authorizes(&after, &Mutation::Deregister);
assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
}
#[test]
fn another_key_cannot_authorize_a_mutation() {
let agent = SigningKey::generate();
let attacker = SigningKey::generate();
let record = record(&test_uri(), &agent.verifying_key());
let proof = MutationProof::sign_next(&attacker, &record, &Mutation::Deregister);
let result = proof.authorizes(&record, &Mutation::Deregister);
assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
}
#[test]
fn a_refresh_proof_is_bound_to_its_ttl() {
let key = SigningKey::generate();
let record = record(&test_uri(), &key.verifying_key());
let short = Duration::from_mins(1);
let proof = MutationProof::sign_next(&key, &record, &renewal(short));
let Mutation::Refresh { expires_at, .. } = renewal(short) else {
unreachable!("renewal builds a refresh")
};
let result = proof.authorizes(
&record,
&Mutation::Refresh {
ttl: Duration::from_hours(24),
expires_at,
},
);
assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
}
#[test]
fn a_signed_proof_that_does_not_advance_is_stale_not_unauthorized() {
let key = SigningKey::generate();
let record = record(&test_uri(), &key.verifying_key());
let proof = MutationProof::sign(
&key,
record.agent_uri(),
RecordVersion::of(&record),
&Mutation::Deregister,
);
let result = proof.authorizes(&record, &Mutation::Deregister);
assert!(matches!(
result,
Err(DhtError::StaleSequence {
presented: 0,
current: 0,
..
})
));
}
#[test]
fn an_unsigned_write_is_refused_before_the_sequence_is_examined() {
let agent = SigningKey::generate();
let attacker = SigningKey::generate();
let record = record(&test_uri(), &agent.verifying_key());
let proof = MutationProof::sign(
&attacker,
record.agent_uri(),
RecordVersion::of(&record),
&Mutation::Deregister,
);
let result = proof.authorizes(&record, &Mutation::Deregister);
assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
}
#[test]
fn the_next_version_advances_exactly_one_past_the_current() {
let key = SigningKey::generate();
let mut record = record(&test_uri(), &key.verifying_key());
record.set_sequence(41);
assert_eq!(RecordVersion::of(&record).sequence(), 41);
assert_eq!(RecordVersion::next(&record).sequence(), 42);
assert_eq!(
RecordVersion::next(&record).registered_at(),
record.registered_at()
);
}
#[test]
fn the_next_version_saturates_rather_than_wrapping() {
let key = SigningKey::generate();
let mut record = record(&test_uri(), &key.verifying_key());
record.set_sequence(u64::MAX);
assert_eq!(RecordVersion::next(&record).sequence(), u64::MAX);
}
#[test]
fn endpoint_lists_do_not_alias_through_concatenation() {
let uri = test_uri();
let version = RecordVersion::new(SystemTime::UNIX_EPOCH, 1);
let split_one = vec![Endpoint::https("ab"), Endpoint::https("c")];
let split_two = vec![Endpoint::https("a"), Endpoint::https("bc")];
assert_ne!(
signing_payload(&uri, version, &moved_to(&split_one)),
signing_payload(&uri, version, &moved_to(&split_two))
);
}
#[test]
fn an_endpoint_path_does_not_alias_into_the_address() {
let uri = test_uri();
let version = RecordVersion::new(SystemTime::UNIX_EPOCH, 1);
let with_path = vec![Endpoint::https_with_path("a.example", "/v1")];
let in_address = vec![Endpoint::https("a.example/v1")];
assert_ne!(
signing_payload(&uri, version, &moved_to(&with_path)),
signing_payload(&uri, version, &moved_to(&in_address))
);
}
#[test]
fn the_payload_is_domain_separated() {
let payload = signing_payload(
&test_uri(),
RecordVersion::new(SystemTime::UNIX_EPOCH, 1),
&Mutation::Deregister,
);
assert!(payload.starts_with(DOMAIN));
}
#[test]
fn the_payload_is_deterministic() {
let uri = test_uri();
let version = RecordVersion::new(SystemTime::UNIX_EPOCH, 7);
assert_eq!(
signing_payload(&uri, version, &Mutation::Deregister),
signing_payload(&uri, version, &Mutation::Deregister)
);
}
#[test]
fn the_payload_survives_the_millisecond_rounding_serde_applies() {
let uri = test_uri();
let base = SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_123);
assert_eq!(
signing_payload(&uri, RecordVersion::new(base, 1), &Mutation::Deregister),
signing_payload(
&uri,
RecordVersion::new(base + Duration::from_nanos(500), 1),
&Mutation::Deregister
)
);
}
#[test]
fn a_registration_proof_does_not_double_as_a_later_write() {
let key = SigningKey::generate();
let record = record(&test_uri(), &key.verifying_key());
let proof = MutationProof::sign_registration(&key, &record);
assert!(proof.authorizes_registration(&record).is_ok());
assert!(matches!(
proof.authorizes(&record, &Mutation::Deregister),
Err(DhtError::Unauthorized { .. })
));
}
#[test]
fn a_registration_proof_is_bound_to_the_key_the_record_names() {
let key = SigningKey::generate();
let other = SigningKey::generate();
let mine = record(&test_uri(), &key.verifying_key());
let proof = MutationProof::sign_registration(&key, &mine);
let swapped = Registration::new(
test_uri(),
other.verifying_key(),
vec![Endpoint::https("a.example")],
)
.with_registered_at(mine.registered_at());
assert!(matches!(
proof.authorizes_registration(&swapped),
Err(DhtError::Unauthorized { .. })
));
}
#[test]
fn mutation_kinds_have_distinct_tags() {
let tags = [
MutationKind::Register.tag(),
MutationKind::UpdateEndpoint.tag(),
MutationKind::Refresh.tag(),
MutationKind::Deregister.tag(),
];
let mut sorted = tags.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), tags.len());
}
}