Skip to main content

agent_uri_dht/
registration.rs

1//! Registration records for DHT storage.
2
3use std::time::{Duration, SystemTime};
4
5use agent_uri::AgentUri;
6use agent_uri_attestation::VerifyingKey;
7
8use crate::Endpoint;
9
10/// A registration record stored in the DHT.
11///
12/// Contains all information needed to contact an agent and verify its identity.
13/// Registrations have a TTL and must be refreshed to remain active.
14///
15/// # Mutability
16///
17/// A record names the Ed25519 key that is allowed to change it. Every later
18/// write must carry a [`MutationProof`](crate::MutationProof) signed by that
19/// key, and must advance [`Registration::sequence`]. Without both, knowing an
20/// agent's URI would be enough to repoint it at attacker infrastructure.
21///
22/// # Examples
23///
24/// ```
25/// use std::time::Duration;
26/// use agent_uri::AgentUri;
27/// use agent_uri_attestation::SigningKey;
28/// use agent_uri_dht::{Endpoint, Registration};
29///
30/// let uri = AgentUri::parse("agent://anthropic.com/assistant/chat/llm_01h455vb4pex5vsknk084sn02q").unwrap();
31/// let endpoint = Endpoint::https("agent.anthropic.com:443");
32/// let agent_key = SigningKey::generate();
33///
34/// let registration = Registration::new(uri, agent_key.verifying_key(), vec![endpoint])
35///     .with_ttl(Duration::from_secs(3600));
36/// ```
37#[derive(Debug, Clone)]
38pub struct Registration {
39    /// The agent's URI (identity).
40    agent_uri: AgentUri,
41    /// The only key permitted to change or remove this record.
42    agent_key: VerifyingKey,
43    /// Network endpoints where agent can be reached.
44    endpoints: Vec<Endpoint>,
45    /// Attestation token proving capability claims (PASETO).
46    attestation: Option<String>,
47    /// When this registration expires.
48    expires_at: SystemTime,
49    /// When this registration was created.
50    registered_at: SystemTime,
51    /// Monotonic counter ordering writes to this record.
52    sequence: u64,
53}
54
55impl Registration {
56    /// Default TTL for registrations (1 hour).
57    pub const DEFAULT_TTL: Duration = Duration::from_hours(1);
58
59    /// Creates a new registration with default TTL.
60    ///
61    /// # Arguments
62    ///
63    /// * `agent_uri` - The agent's identity URI
64    /// * `agent_key` - The public key that may later change or remove this record
65    /// * `endpoints` - Network endpoints for contacting the agent
66    #[must_use]
67    pub fn new(agent_uri: AgentUri, agent_key: VerifyingKey, endpoints: Vec<Endpoint>) -> Self {
68        let now = SystemTime::now();
69        Self {
70            agent_uri,
71            agent_key,
72            endpoints,
73            attestation: None,
74            expires_at: now + Self::DEFAULT_TTL,
75            registered_at: now,
76            sequence: 0,
77        }
78    }
79
80    /// Sets the TTL for this registration.
81    #[must_use]
82    pub fn with_ttl(mut self, ttl: Duration) -> Self {
83        self.expires_at = self.registered_at + ttl;
84        self
85    }
86
87    /// Sets the attestation token.
88    #[must_use]
89    pub fn with_attestation(mut self, attestation: impl Into<String>) -> Self {
90        self.attestation = Some(attestation.into());
91        self
92    }
93
94    /// Sets the expiration time directly.
95    #[must_use]
96    pub fn with_expires_at(mut self, expires_at: SystemTime) -> Self {
97        self.expires_at = expires_at;
98        self
99    }
100
101    /// Sets the registration time (for testing/simulation).
102    #[must_use]
103    pub fn with_registered_at(mut self, registered_at: SystemTime) -> Self {
104        self.registered_at = registered_at;
105        self
106    }
107
108    /// Sets the sequence number this record starts its history at.
109    ///
110    /// Records open at zero, which is right for an agent registering a URI for
111    /// the first time. An agent that has held this URI before should instead
112    /// continue past the highest sequence it ever signed: a record that starts
113    /// over at zero sits below every proof its previous incarnation handed out,
114    /// and a kept deregistration could evict it again each time it returns.
115    #[must_use]
116    pub const fn with_sequence(mut self, sequence: u64) -> Self {
117        self.sequence = sequence;
118        self
119    }
120
121    /// Returns the agent URI.
122    #[must_use]
123    pub fn agent_uri(&self) -> &AgentUri {
124        &self.agent_uri
125    }
126
127    /// Returns the key permitted to change or remove this record.
128    #[must_use]
129    pub fn agent_key(&self) -> &VerifyingKey {
130        &self.agent_key
131    }
132
133    /// Returns the sequence number of the last write applied to this record.
134    ///
135    /// A write must present a strictly greater number, so a caller preparing a
136    /// mutation signs `sequence() + 1`.
137    #[must_use]
138    pub const fn sequence(&self) -> u64 {
139        self.sequence
140    }
141
142    /// Returns the endpoints.
143    #[must_use]
144    pub fn endpoints(&self) -> &[Endpoint] {
145        &self.endpoints
146    }
147
148    /// Returns the attestation token, if any.
149    #[must_use]
150    pub fn attestation(&self) -> Option<&str> {
151        self.attestation.as_deref()
152    }
153
154    /// Returns the expiration time.
155    #[must_use]
156    pub fn expires_at(&self) -> SystemTime {
157        self.expires_at
158    }
159
160    /// Returns the registration time.
161    #[must_use]
162    pub fn registered_at(&self) -> SystemTime {
163        self.registered_at
164    }
165
166    /// Returns true if this registration has expired.
167    #[must_use]
168    pub fn is_expired(&self) -> bool {
169        SystemTime::now() >= self.expires_at
170    }
171
172    /// Returns the remaining TTL, or None if expired.
173    #[must_use]
174    pub fn remaining_ttl(&self) -> Option<Duration> {
175        self.expires_at.duration_since(SystemTime::now()).ok()
176    }
177
178    /// Updates the endpoints for this registration.
179    pub fn update_endpoints(&mut self, endpoints: Vec<Endpoint>) {
180        self.endpoints = endpoints;
181    }
182
183    /// Moves the registration's expiry to an instant a write has authorized.
184    ///
185    /// Callers are the store, applying a write it has already checked. The
186    /// instant is taken rather than derived from a TTL because it is what the
187    /// agent signed: a store that computed `now + ttl` for itself would write
188    /// an expiry the agent never put its name to, and on a wire protocol that
189    /// field would then be one an observer could rewrite freely.
190    ///
191    /// `registered_at` is deliberately left alone. It identifies this record
192    /// instance, which is what a [`MutationProof`](crate::MutationProof) binds
193    /// itself to; moving it on every refresh would invalidate proofs an agent
194    /// had already prepared, and would make the field misreport when the agent
195    /// actually registered.
196    pub const fn set_expires_at(&mut self, expires_at: SystemTime) {
197        self.expires_at = expires_at;
198    }
199
200    /// Records the sequence number of a write that has been authorized.
201    ///
202    /// Callers are the store, applying a write it has already checked. The
203    /// number is not validated here: whether it advances is a question about
204    /// the record's history, which only the store holding that record can
205    /// answer.
206    pub const fn set_sequence(&mut self, sequence: u64) {
207        self.sequence = sequence;
208    }
209}
210
211impl PartialEq for Registration {
212    fn eq(&self, other: &Self) -> bool {
213        // Equality based on agent URI (identity)
214        self.agent_uri == other.agent_uri
215    }
216}
217
218impl Eq for Registration {}
219
220impl std::hash::Hash for Registration {
221    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
222        self.agent_uri.as_str().hash(state);
223    }
224}
225
226/// Milliseconds since the Unix epoch, saturating at both ends.
227///
228/// Shared by the serde representation and by the signing payload, so a record
229/// that has been through the wire and one that has not produce the same bytes
230/// to sign. A finer clock in one and not the other would make proofs fail to
231/// verify after a round trip.
232pub(crate) fn system_time_to_millis(time: SystemTime) -> u64 {
233    time.duration_since(SystemTime::UNIX_EPOCH)
234        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
235}
236
237#[cfg(feature = "serde")]
238fn millis_to_system_time(millis: u64) -> SystemTime {
239    SystemTime::UNIX_EPOCH + Duration::from_millis(millis)
240}
241
242#[cfg(feature = "serde")]
243impl serde::Serialize for Registration {
244    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
245    where
246        S: serde::Serializer,
247    {
248        use serde::ser::SerializeStruct;
249        let mut state = serializer.serialize_struct("Registration", 7)?;
250        state.serialize_field("agent_uri", self.agent_uri.as_str())?;
251        state.serialize_field("agent_key", &self.agent_key.to_bytes())?;
252        state.serialize_field("endpoints", &self.endpoints)?;
253        state.serialize_field("attestation", &self.attestation)?;
254        state.serialize_field("expires_at", &system_time_to_millis(self.expires_at))?;
255        state.serialize_field("registered_at", &system_time_to_millis(self.registered_at))?;
256        state.serialize_field("sequence", &self.sequence)?;
257        state.end()
258    }
259}
260
261#[cfg(feature = "serde")]
262impl<'de> serde::Deserialize<'de> for Registration {
263    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
264    where
265        D: serde::Deserializer<'de>,
266    {
267        #[derive(serde::Deserialize)]
268        struct RegistrationData {
269            agent_uri: String,
270            agent_key: [u8; 32],
271            endpoints: Vec<Endpoint>,
272            attestation: Option<String>,
273            expires_at: u64,
274            registered_at: u64,
275            sequence: u64,
276        }
277
278        let data = RegistrationData::deserialize(deserializer)?;
279        let agent_uri = AgentUri::parse(&data.agent_uri).map_err(serde::de::Error::custom)?;
280        // A record whose key does not decode is a record nobody could ever
281        // prove authority over, so it is rejected rather than carried forward.
282        let agent_key =
283            VerifyingKey::from_bytes(&data.agent_key).map_err(serde::de::Error::custom)?;
284
285        Ok(Self {
286            agent_uri,
287            agent_key,
288            endpoints: data.endpoints,
289            attestation: data.attestation,
290            expires_at: millis_to_system_time(data.expires_at),
291            registered_at: millis_to_system_time(data.registered_at),
292            sequence: data.sequence,
293        })
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    fn test_uri() -> AgentUri {
302        AgentUri::parse("agent://anthropic.com/assistant/chat/llm_01h455vb4pex5vsknk084sn02q")
303            .unwrap()
304    }
305
306    fn test_endpoint() -> Endpoint {
307        Endpoint::https("agent.anthropic.com:443")
308    }
309
310    fn test_key() -> agent_uri_attestation::SigningKey {
311        agent_uri_attestation::SigningKey::generate()
312    }
313
314    #[test]
315    fn new_registration_has_default_ttl() {
316        let registration = Registration::new(
317            test_uri(),
318            test_key().verifying_key(),
319            vec![test_endpoint()],
320        );
321        assert!(!registration.is_expired());
322        assert!(registration.remaining_ttl().is_some());
323    }
324
325    #[test]
326    fn with_ttl_sets_expiration() {
327        let registration = Registration::new(
328            test_uri(),
329            test_key().verifying_key(),
330            vec![test_endpoint()],
331        )
332        .with_ttl(Duration::from_mins(1));
333        let remaining = registration.remaining_ttl().unwrap();
334        // Should be close to 60 seconds, allow for some test execution time
335        assert!(remaining.as_secs() <= 60);
336        assert!(remaining.as_secs() >= 59);
337    }
338
339    #[test]
340    fn with_attestation_sets_attestation() {
341        let registration = Registration::new(
342            test_uri(),
343            test_key().verifying_key(),
344            vec![test_endpoint()],
345        )
346        .with_attestation("token123");
347        assert_eq!(registration.attestation(), Some("token123"));
348    }
349
350    #[test]
351    fn expired_registration() {
352        let past = SystemTime::now() - Duration::from_secs(10);
353        let registration = Registration::new(
354            test_uri(),
355            test_key().verifying_key(),
356            vec![test_endpoint()],
357        )
358        .with_expires_at(past);
359        assert!(registration.is_expired());
360        assert!(registration.remaining_ttl().is_none());
361    }
362
363    #[test]
364    fn update_endpoints() {
365        let mut registration = Registration::new(
366            test_uri(),
367            test_key().verifying_key(),
368            vec![test_endpoint()],
369        );
370        let new_endpoint = Endpoint::grpc("agent.anthropic.com:50051");
371        registration.update_endpoints(vec![new_endpoint.clone()]);
372        assert_eq!(registration.endpoints(), &[new_endpoint]);
373    }
374
375    #[test]
376    fn setting_the_expiry_revives_a_lapsed_registration() {
377        let past = SystemTime::now() - Duration::from_hours(1);
378        let mut registration = Registration::new(
379            test_uri(),
380            test_key().verifying_key(),
381            vec![test_endpoint()],
382        )
383        .with_registered_at(past)
384        .with_expires_at(past);
385
386        assert!(registration.is_expired());
387
388        registration.set_expires_at(SystemTime::now() + Duration::from_mins(1));
389
390        assert!(!registration.is_expired());
391        assert!(registration.remaining_ttl().is_some());
392    }
393
394    #[test]
395    fn setting_the_expiry_leaves_the_record_instance_alone() {
396        // `registered_at` identifies the instance a mutation proof binds to.
397        // A refresh that moved it would invalidate every proof the agent had
398        // already prepared against this record.
399        let mut registration = Registration::new(
400            test_uri(),
401            test_key().verifying_key(),
402            vec![test_endpoint()],
403        );
404        let instance = registration.registered_at();
405
406        registration.set_expires_at(SystemTime::now() + Duration::from_hours(9));
407
408        assert_eq!(registration.registered_at(), instance);
409    }
410
411    #[test]
412    fn equality_based_on_uri() {
413        let registration1 = Registration::new(
414            test_uri(),
415            test_key().verifying_key(),
416            vec![test_endpoint()],
417        );
418        let registration2 = Registration::new(
419            test_uri(),
420            test_key().verifying_key(),
421            vec![Endpoint::grpc("other.com:50051")],
422        );
423        assert_eq!(registration1, registration2);
424    }
425
426    #[test]
427    fn different_uris_not_equal() {
428        let uri1 =
429            AgentUri::parse("agent://anthropic.com/assistant/chat/llm_01h455vb4pex5vsknk084sn02q")
430                .unwrap();
431        let uri2 =
432            AgentUri::parse("agent://anthropic.com/assistant/code/llm_01h455vb4pex5vsknk084sn02r")
433                .unwrap();
434        let registration1 =
435            Registration::new(uri1, test_key().verifying_key(), vec![test_endpoint()]);
436        let registration2 =
437            Registration::new(uri2, test_key().verifying_key(), vec![test_endpoint()]);
438        assert_ne!(registration1, registration2);
439    }
440
441    #[cfg(feature = "serde")]
442    #[test]
443    fn pre_epoch_time_serializes_to_zero_millis() {
444        let time = SystemTime::UNIX_EPOCH - Duration::from_secs(1);
445        assert_eq!(system_time_to_millis(time), 0);
446    }
447
448    #[cfg(feature = "serde")]
449    #[test]
450    fn far_future_time_saturates_instead_of_wrapping() {
451        let time = SystemTime::UNIX_EPOCH + Duration::from_secs(u64::MAX / 1000 + 1);
452        assert_eq!(system_time_to_millis(time), u64::MAX);
453    }
454
455    #[cfg(feature = "serde")]
456    #[test]
457    fn millis_round_trip_preserves_time() {
458        let time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
459        assert_eq!(millis_to_system_time(system_time_to_millis(time)), time);
460    }
461}