Skip to main content

agent_uri_dht/
proof.rs

1//! Proof that the holder of an agent's key authorized a write.
2//!
3//! A registration record is world-readable, so knowing an agent's URI is not
4//! evidence of anything, and neither is holding a copy of the attestation
5//! token stored alongside it. Every write must therefore carry a
6//! [`MutationProof`]: an Ed25519 signature, made by the agent's own key, over
7//! the exact operation requested. Registration signs the record it creates;
8//! later writes sign against the record they found.
9//!
10//! # What the signature covers
11//!
12//! The signed bytes are derived entirely from the arguments of the call being
13//! authorized, never from data the proof itself carries. A proof cannot
14//! disagree with the operation it accompanies, because there is nothing in it
15//! to disagree with: it is a sequence number and a signature.
16//!
17//! Every mutation names the **resulting** record's `expires_at`, not only the
18//! arguments that produced it. A store that holds the record and applies the
19//! change itself has no use for that: it computes the new expiry locally and
20//! there is nothing for anyone to substitute. On a wire protocol the resulting
21//! record travels whole, so any field outside the payload is a field an
22//! observer can rewrite before passing the record on. Leaving `expires_at`
23//! outside let an attacker who watched a legitimate migration hold a real,
24//! agent-signed record open past the moment the agent chose to let it lapse
25//! (issue #81).
26//!
27//! # Replay
28//!
29//! Proofs travel in the clear alongside the operations they authorize, so any
30//! node on the path can keep one. Two bindings make a kept proof worthless.
31//!
32//! The **sequence number** orders writes within one record's life: a record
33//! only accepts a write whose sequence is strictly greater than the one it
34//! already holds, so a proof cannot be applied twice.
35//!
36//! The record's **registration time** identifies the record instance. Sequence
37//! numbers alone would not survive deregistration, because a re-registered
38//! record starts its history over at zero and a captured proof would sit above
39//! it again. Binding the instance means a proof signed against the record an
40//! agent had yesterday cannot touch the record it has today.
41//!
42//! Together these say a proof authorizes one operation, on one record, at one
43//! point in that record's history, and nothing else.
44//!
45//! One residual is worth naming. Instance identity is the registration time at
46//! millisecond resolution, matching what survives serialization, so two
47//! registrations of the same URI inside one millisecond are indistinguishable
48//! to a proof. On a network that needs an agent to re-register within a
49//! millisecond of its own deregistration, but a coarse system clock widens the
50//! window. An agent that continues its sequence numbering across
51//! re-registration, via
52//! [`Registration::with_sequence`](crate::Registration::with_sequence), does
53//! not depend on the clock at all. Note that what a captured proof buys is
54//! re-eviction, a denial of service: repointing an agent needs a proof over
55//! the attacker's own endpoints, which no capture supplies.
56
57use std::time::{Duration, SystemTime};
58
59use agent_uri::AgentUri;
60use agent_uri_attestation::{Signature, SigningKey, VerifyingKey};
61
62use crate::registration::system_time_to_millis;
63use crate::{DhtError, Endpoint, Registration};
64
65/// Domain separator. A signature minted for a DHT mutation must never verify
66/// as authorization for anything else the agent key is used to sign.
67///
68/// The version moved to `v2` when `expires_at` entered the update and refresh
69/// payloads. Every proof signed under `v1` is now unverifiable, which is the
70/// point: a `v1` update proof says nothing about the expiry of the record it
71/// produces, and accepting one would leave that field open on the wire.
72const DOMAIN: &[u8] = b"agent-uri/dht-mutation/v2\x00";
73
74/// The operation a proof authorizes.
75///
76/// The kind is part of the signed payload, so a proof for one operation cannot
77/// be presented as authorization for another. Without this, a captured refresh
78/// would double as a deregistration.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80pub enum MutationKind {
81    /// Creating the record.
82    Register,
83    /// Replacing the record's endpoints.
84    UpdateEndpoint,
85    /// Extending the record's lifetime.
86    Refresh,
87    /// Removing the record.
88    Deregister,
89}
90
91impl MutationKind {
92    /// The tag written into the signed payload.
93    ///
94    /// These values are wire format. Renumbering one would make every proof in
95    /// flight authorize a different operation than its signer intended.
96    const fn tag(self) -> u8 {
97        match self {
98            Self::UpdateEndpoint => 1,
99            Self::Refresh => 2,
100            Self::Deregister => 3,
101            Self::Register => 4,
102        }
103    }
104
105    /// Returns the operation name, for error messages and logs.
106    #[must_use]
107    pub const fn as_str(self) -> &'static str {
108        match self {
109            Self::UpdateEndpoint => "update_endpoint",
110            Self::Refresh => "refresh",
111            Self::Deregister => "deregister",
112            Self::Register => "register",
113        }
114    }
115}
116
117impl std::fmt::Display for MutationKind {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.write_str(self.as_str())
120    }
121}
122
123/// A write to an existing registration, with the parameters that write carries.
124///
125/// This mirrors the mutating [`crate::Dht`] methods so that the parameters
126/// which distinguish two calls of the same kind (which endpoints, how much
127/// TTL) are inside what the signature covers, and it names the resulting
128/// record's `expires_at` so that no field of the record a write produces sits
129/// outside the signature.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Mutation<'a> {
132    /// Create the record with exactly this content.
133    ///
134    /// Registration is a write like any other and needs the same
135    /// authorization. Without it, an attestation token lifted from a public
136    /// record would be enough to re-register that URI pointing anywhere: the
137    /// token binds the agent key, but nothing would bind the endpoints.
138    Register {
139        /// The key the new record will name as its only writer.
140        agent_key: &'a VerifyingKey,
141        /// The endpoints the new record will hold.
142        endpoints: &'a [Endpoint],
143        /// When the new record will expire.
144        expires_at: SystemTime,
145    },
146    /// Replace the record's endpoints with these.
147    ///
148    /// A migration does not change when the record lapses, so `expires_at` is
149    /// the expiry the record already carries. A store derives it from the
150    /// record it holds rather than accepting it from the caller, so the two
151    /// cannot describe the same write differently.
152    UpdateEndpoint {
153        /// The endpoints the record will hold.
154        endpoints: &'a [Endpoint],
155        /// When the record will expire, unchanged by this write.
156        expires_at: SystemTime,
157    },
158    /// Extend the record's lifetime to this instant.
159    ///
160    /// `expires_at` is what a store applies. `ttl` is what the agent asked
161    /// for, and is signed alongside it because it is an argument of the call:
162    /// a backend that reports the requested lifetime, or carries it on the
163    /// wire, must not be able to report one the agent never asked for. The
164    /// instant is what the record ends up holding, so the instant is what
165    /// stores act on. Only the signer knows both, because only the signer
166    /// knows when it signed.
167    Refresh {
168        /// The lifetime the agent asked for.
169        ttl: Duration,
170        /// When the record will expire once this write is applied.
171        expires_at: SystemTime,
172    },
173    /// Remove the record.
174    Deregister,
175}
176
177impl Mutation<'_> {
178    /// Returns the kind of operation this mutation performs.
179    #[must_use]
180    pub const fn kind(&self) -> MutationKind {
181        match self {
182            Self::Register { .. } => MutationKind::Register,
183            Self::UpdateEndpoint { .. } => MutationKind::UpdateEndpoint,
184            Self::Refresh { .. } => MutationKind::Refresh,
185            Self::Deregister => MutationKind::Deregister,
186        }
187    }
188}
189
190/// Which record, and where in that record's history, a write applies.
191///
192/// A sequence number alone identifies a position but not a record: a
193/// deregistered and re-registered URI starts counting again from zero, and a
194/// proof captured before the gap would sit above the new record's history. The
195/// registration time pins the instance so it cannot.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub struct RecordVersion {
198    registered_at: SystemTime,
199    sequence: u64,
200}
201
202impl RecordVersion {
203    /// Names a version explicitly, for a caller holding the parts rather than
204    /// the record.
205    #[must_use]
206    pub const fn new(registered_at: SystemTime, sequence: u64) -> Self {
207        Self {
208            registered_at,
209            sequence,
210        }
211    }
212
213    /// The version `record` currently holds.
214    #[must_use]
215    pub fn of(record: &Registration) -> Self {
216        Self::new(record.registered_at(), record.sequence())
217    }
218
219    /// The version the next write to `record` claims.
220    #[must_use]
221    pub fn next(record: &Registration) -> Self {
222        Self::new(record.registered_at(), record.sequence().saturating_add(1))
223    }
224
225    /// Returns the registration time identifying the record instance.
226    #[must_use]
227    pub const fn registered_at(&self) -> SystemTime {
228        self.registered_at
229    }
230
231    /// Returns the sequence number.
232    #[must_use]
233    pub const fn sequence(&self) -> u64 {
234        self.sequence
235    }
236}
237
238/// Appends a length-prefixed byte string.
239///
240/// Every variable-length field is prefixed with its length so that the
241/// encoding is injective. Concatenating `"a" + "bc"` and `"ab" + "c"` must not
242/// produce the same bytes, or one signature would authorize two operations.
243fn push_field(out: &mut Vec<u8>, field: &[u8]) {
244    let len = u64::try_from(field.len()).expect("a field length fits in u64");
245    out.extend_from_slice(&len.to_be_bytes());
246    out.extend_from_slice(field);
247}
248
249fn push_endpoint(out: &mut Vec<u8>, endpoint: &Endpoint) {
250    // The three components go in separately rather than via the rendered URI:
251    // injectivity then holds regardless of how `Endpoint` chooses to format
252    // itself, and a change to that formatting cannot silently alias two
253    // endpoints onto one signed payload.
254    push_field(out, endpoint.protocol().as_bytes());
255    push_field(out, endpoint.address().as_bytes());
256    match endpoint.path() {
257        Some(path) => {
258            out.push(1);
259            push_field(out, path.as_bytes());
260        }
261        None => out.push(0),
262    }
263}
264
265fn push_endpoints(out: &mut Vec<u8>, endpoints: &[Endpoint]) {
266    let count = u64::try_from(endpoints.len()).expect("an endpoint count fits in u64");
267    out.extend_from_slice(&count.to_be_bytes());
268    for endpoint in endpoints {
269        push_endpoint(out, endpoint);
270    }
271}
272
273/// Returns the exact bytes a proof for this mutation signs.
274///
275/// Pure and deterministic: signer and verifier compute it independently from
276/// the same arguments and must agree byte for byte.
277///
278/// # Panics
279///
280/// Panics if a field length or endpoint count does not fit in a `u64`, which
281/// requires a `usize` wider than 64 bits. No supported target has one.
282#[must_use]
283pub fn signing_payload(
284    agent_uri: &AgentUri,
285    version: RecordVersion,
286    mutation: &Mutation<'_>,
287) -> Vec<u8> {
288    let mut out = Vec::from(DOMAIN);
289    out.push(mutation.kind().tag());
290    out.extend_from_slice(&system_time_to_millis(version.registered_at()).to_be_bytes());
291    out.extend_from_slice(&version.sequence().to_be_bytes());
292    push_field(&mut out, agent_uri.canonical().as_bytes());
293
294    match mutation {
295        Mutation::Register {
296            agent_key,
297            endpoints,
298            expires_at,
299        } => {
300            // The key goes in so the payload is self-certifying: the signature
301            // commits to the key it must be checked against.
302            push_field(&mut out, &agent_key.to_bytes());
303            out.extend_from_slice(&system_time_to_millis(*expires_at).to_be_bytes());
304            push_endpoints(&mut out, endpoints);
305        }
306        Mutation::UpdateEndpoint {
307            endpoints,
308            expires_at,
309        } => {
310            out.extend_from_slice(&system_time_to_millis(*expires_at).to_be_bytes());
311            push_endpoints(&mut out, endpoints);
312        }
313        Mutation::Refresh { ttl, expires_at } => {
314            out.extend_from_slice(&system_time_to_millis(*expires_at).to_be_bytes());
315            out.extend_from_slice(&ttl.as_secs().to_be_bytes());
316            out.extend_from_slice(&ttl.subsec_nanos().to_be_bytes());
317        }
318        Mutation::Deregister => {}
319    }
320
321    out
322}
323
324/// Authorization for one write to one registration.
325///
326/// Carries only what the record cannot derive from the call itself: the
327/// sequence number claiming a position in the record's history, and the
328/// signature over the whole request. Everything else the signature covers
329/// (which agent, which record instance, which operation, which arguments) the
330/// store already holds or was just handed, so a proof has nothing in it that
331/// could contradict the write it accompanies.
332///
333/// # Examples
334///
335/// ```
336/// use agent_uri::AgentUri;
337/// use agent_uri_attestation::SigningKey;
338/// use agent_uri_dht::{Endpoint, Mutation, MutationProof, Registration};
339///
340/// let key = SigningKey::generate();
341/// let uri = AgentUri::parse(
342///     "agent://anthropic.com/assistant/chat/llm_01h455vb4pex5vsknk084sn02q"
343/// ).unwrap();
344///
345/// // In practice this record comes back from a lookup.
346/// let record = Registration::new(
347///     uri,
348///     key.verifying_key(),
349///     vec![Endpoint::https("us-east-1.agent.anthropic.com")],
350/// );
351///
352/// let endpoints = vec![Endpoint::https("eu-west-1.agent.anthropic.com")];
353/// // Migrating does not change when the record lapses, so the expiry signed
354/// // is the one the record already carries.
355/// let mutation = Mutation::UpdateEndpoint {
356///     endpoints: &endpoints,
357///     expires_at: record.expires_at(),
358/// };
359/// let proof = MutationProof::sign_next(&key, &record, &mutation);
360///
361/// assert!(proof.authorizes(&record, &mutation).is_ok());
362/// ```
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub struct MutationProof {
365    sequence: u64,
366    signature: Signature,
367}
368
369impl MutationProof {
370    /// Signs the registration of `record`.
371    ///
372    /// Covers the record as submitted: its key, endpoints, expiry, instance
373    /// identity, and starting sequence. A relay can therefore forward a
374    /// registration but not alter one.
375    #[must_use]
376    pub fn sign_registration(key: &SigningKey, record: &Registration) -> Self {
377        Self::sign(
378            key,
379            record.agent_uri(),
380            RecordVersion::of(record),
381            &Self::registration_of(record),
382        )
383    }
384
385    /// The mutation a registration proof covers.
386    ///
387    /// Derived from the record rather than supplied, so signer and store
388    /// cannot describe the same registration two different ways.
389    fn registration_of(record: &Registration) -> Mutation<'_> {
390        Mutation::Register {
391            agent_key: record.agent_key(),
392            endpoints: record.endpoints(),
393            expires_at: record.expires_at(),
394        }
395    }
396
397    /// Checks that this proof authorizes the registration of `record`.
398    ///
399    /// Unlike a later write there is no history to advance past, so only the
400    /// signature is at issue: the record's own key must have signed the record
401    /// exactly as submitted.
402    ///
403    /// # Errors
404    ///
405    /// Returns [`DhtError::Unauthorized`] if the signature does not answer for
406    /// this record.
407    pub fn authorizes_registration(&self, record: &Registration) -> Result<(), DhtError> {
408        let version = RecordVersion::new(record.registered_at(), self.sequence);
409        record
410            .agent_key()
411            .verify(
412                &signing_payload(record.agent_uri(), version, &Self::registration_of(record)),
413                &self.signature,
414            )
415            .map_err(|_| DhtError::Unauthorized {
416                agent_uri: record.agent_uri().canonical(),
417                operation: MutationKind::Register,
418            })
419    }
420
421    /// Signs the next write to `record`.
422    ///
423    /// The usual way to build a proof: look the record up, then sign against
424    /// what you read. Signing against a stale copy produces a proof the store
425    /// will refuse rather than one it will misapply.
426    #[must_use]
427    pub fn sign_next(key: &SigningKey, record: &Registration, mutation: &Mutation<'_>) -> Self {
428        Self::sign(
429            key,
430            record.agent_uri(),
431            RecordVersion::next(record),
432            mutation,
433        )
434    }
435
436    /// Signs a write against an explicitly named version.
437    ///
438    /// For a caller that cached a record's version rather than the record.
439    #[must_use]
440    pub fn sign(
441        key: &SigningKey,
442        agent_uri: &AgentUri,
443        version: RecordVersion,
444        mutation: &Mutation<'_>,
445    ) -> Self {
446        Self {
447            sequence: version.sequence(),
448            signature: key.sign(&signing_payload(agent_uri, version, mutation)),
449        }
450    }
451
452    /// Reassembles a proof received over the wire.
453    #[must_use]
454    pub const fn from_parts(sequence: u64, signature: Signature) -> Self {
455        Self {
456            sequence,
457            signature,
458        }
459    }
460
461    /// Returns the sequence number this proof claims.
462    #[must_use]
463    pub const fn sequence(&self) -> u64 {
464        self.sequence
465    }
466
467    /// Returns the signature.
468    #[must_use]
469    pub const fn signature(&self) -> Signature {
470        self.signature
471    }
472
473    /// Checks that this proof authorizes `mutation` against `record`.
474    ///
475    /// The whole authorization rule, in one place: the signature must be the
476    /// record's own key over exactly this record instance, position, operation
477    /// and arguments, and the position must advance. A store calls this and
478    /// has nothing further to decide.
479    ///
480    /// # Errors
481    ///
482    /// Returns [`DhtError::Unauthorized`] if the signature does not answer for
483    /// this write, or [`DhtError::StaleSequence`] if it does but the write
484    /// does not advance the record's history.
485    pub fn authorizes(
486        &self,
487        record: &Registration,
488        mutation: &Mutation<'_>,
489    ) -> Result<(), DhtError> {
490        let version = RecordVersion::new(record.registered_at(), self.sequence);
491
492        // Signature first. A stale-sequence answer to an unsigned write would
493        // tell an attacker the record's position for free.
494        record
495            .agent_key()
496            .verify(
497                &signing_payload(record.agent_uri(), version, mutation),
498                &self.signature,
499            )
500            .map_err(|_| DhtError::Unauthorized {
501                agent_uri: record.agent_uri().canonical(),
502                operation: mutation.kind(),
503            })?;
504
505        if self.sequence <= record.sequence() {
506            return Err(DhtError::StaleSequence {
507                agent_uri: record.agent_uri().canonical(),
508                presented: self.sequence,
509                current: record.sequence(),
510            });
511        }
512
513        Ok(())
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use agent_uri_attestation::VerifyingKey;
521
522    fn test_uri() -> AgentUri {
523        AgentUri::parse("agent://anthropic.com/assistant/chat/llm_01h455vb4pex5vsknk084sn02q")
524            .unwrap()
525    }
526
527    fn other_uri() -> AgentUri {
528        AgentUri::parse("agent://anthropic.com/assistant/chat/llm_01h455vb4pex5vsknk084sn02r")
529            .unwrap()
530    }
531
532    fn record(uri: &AgentUri, key: &VerifyingKey) -> Registration {
533        Registration::new(uri.clone(), key.clone(), vec![Endpoint::https("a.example")])
534    }
535
536    /// A migration of `record` to `endpoints`, naming the expiry a store would
537    /// read off the record.
538    fn migration<'a>(record: &Registration, endpoints: &'a [Endpoint]) -> Mutation<'a> {
539        Mutation::UpdateEndpoint {
540            endpoints,
541            expires_at: record.expires_at(),
542        }
543    }
544
545    /// A migration to `endpoints` at a fixed expiry, for tests about how the
546    /// payload encodes the endpoints rather than about the expiry.
547    fn moved_to(endpoints: &[Endpoint]) -> Mutation<'_> {
548        Mutation::UpdateEndpoint {
549            endpoints,
550            expires_at: SystemTime::UNIX_EPOCH,
551        }
552    }
553
554    /// A renewal to a fixed instant, so that a test asserting on the payload
555    /// does not depend on when it ran.
556    fn renewal(ttl: Duration) -> Mutation<'static> {
557        Mutation::Refresh {
558            ttl,
559            expires_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000),
560        }
561    }
562
563    #[test]
564    fn a_proof_authorizes_the_mutation_it_signed() {
565        let key = SigningKey::generate();
566        let record = record(&test_uri(), &key.verifying_key());
567        let endpoints = vec![Endpoint::https("b.example")];
568        let mutation = migration(&record, &endpoints);
569
570        let proof = MutationProof::sign_next(&key, &record, &mutation);
571
572        assert!(proof.authorizes(&record, &mutation).is_ok());
573    }
574
575    #[test]
576    fn a_proof_does_not_authorize_different_endpoints() {
577        // This is the hijack the proof exists to stop: same agent, same
578        // operation, endpoints swapped for the attacker's.
579        let key = SigningKey::generate();
580        let record = record(&test_uri(), &key.verifying_key());
581        let honest = vec![Endpoint::https("honest.example")];
582        let attacker = vec![Endpoint::https("attacker.example")];
583        let proof = MutationProof::sign_next(&key, &record, &migration(&record, &honest));
584
585        let result = proof.authorizes(&record, &migration(&record, &attacker));
586
587        assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
588    }
589
590    #[test]
591    fn a_migration_proof_does_not_authorize_a_different_expiry() {
592        // The gap #81 closed. On a wire protocol the record a migration
593        // produces travels whole, so an expiry outside the signature is one
594        // any relay can rewrite: an observer could hold a real, agent-signed
595        // record open long past the moment the agent chose to let it lapse.
596        let key = SigningKey::generate();
597        let record = record(&test_uri(), &key.verifying_key());
598        let endpoints = vec![Endpoint::https("b.example")];
599        let proof = MutationProof::sign_next(&key, &record, &migration(&record, &endpoints));
600
601        let result = proof.authorizes(
602            &record,
603            &Mutation::UpdateEndpoint {
604                endpoints: &endpoints,
605                expires_at: record.expires_at() + Duration::from_hours(23),
606            },
607        );
608
609        assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
610    }
611
612    #[test]
613    fn a_refresh_proof_does_not_authorize_a_different_expiry() {
614        // Same gap on the other write that moves an expiry. The TTL matching
615        // is not enough: the instant is what the record ends up holding.
616        let key = SigningKey::generate();
617        let record = record(&test_uri(), &key.verifying_key());
618        let ttl = Duration::from_hours(1);
619        let proof = MutationProof::sign_next(&key, &record, &renewal(ttl));
620
621        let Mutation::Refresh { expires_at, .. } = renewal(ttl) else {
622            unreachable!("renewal builds a refresh")
623        };
624        let result = proof.authorizes(
625            &record,
626            &Mutation::Refresh {
627                ttl,
628                expires_at: expires_at + Duration::from_hours(23),
629            },
630        );
631
632        assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
633    }
634
635    #[test]
636    fn a_proof_does_not_cross_operations() {
637        let key = SigningKey::generate();
638        let record = record(&test_uri(), &key.verifying_key());
639        let proof = MutationProof::sign_next(&key, &record, &renewal(Duration::from_hours(1)));
640
641        let result = proof.authorizes(&record, &Mutation::Deregister);
642
643        assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
644    }
645
646    #[test]
647    fn a_proof_does_not_transfer_to_another_agent() {
648        let key = SigningKey::generate();
649        let mine = record(&test_uri(), &key.verifying_key());
650        // Same key, same instant, same sequence: only the URI differs.
651        let theirs = Registration::new(
652            other_uri(),
653            key.verifying_key(),
654            vec![Endpoint::https("a.example")],
655        )
656        .with_registered_at(mine.registered_at());
657        let proof = MutationProof::sign_next(&key, &mine, &Mutation::Deregister);
658
659        let result = proof.authorizes(&theirs, &Mutation::Deregister);
660
661        assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
662    }
663
664    #[test]
665    fn a_proof_does_not_transfer_to_another_sequence() {
666        let key = SigningKey::generate();
667        let record = record(&test_uri(), &key.verifying_key());
668        let proof = MutationProof::sign_next(&key, &record, &Mutation::Deregister);
669        let renumbered = MutationProof::from_parts(proof.sequence() + 1, proof.signature());
670
671        let result = renumbered.authorizes(&record, &Mutation::Deregister);
672
673        assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
674    }
675
676    #[test]
677    fn a_proof_does_not_transfer_to_another_record_instance() {
678        // The reason the record's registration time is signed. Sequence numbers
679        // start over when a URI is deregistered and registered again, so
680        // without this a captured proof would sit above the new record's
681        // history and evict the agent every time it came back.
682        let key = SigningKey::generate();
683        let before = record(&test_uri(), &key.verifying_key())
684            .with_registered_at(SystemTime::now() - Duration::from_hours(1));
685        let after = record(&test_uri(), &key.verifying_key());
686        let captured = MutationProof::sign_next(&key, &before, &Mutation::Deregister);
687
688        let result = captured.authorizes(&after, &Mutation::Deregister);
689
690        assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
691    }
692
693    #[test]
694    fn another_key_cannot_authorize_a_mutation() {
695        let agent = SigningKey::generate();
696        let attacker = SigningKey::generate();
697        let record = record(&test_uri(), &agent.verifying_key());
698        let proof = MutationProof::sign_next(&attacker, &record, &Mutation::Deregister);
699
700        let result = proof.authorizes(&record, &Mutation::Deregister);
701
702        assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
703    }
704
705    #[test]
706    fn a_refresh_proof_is_bound_to_its_ttl() {
707        // The TTL is bound as well as the resulting instant. A store applies
708        // the instant, but the TTL is an argument of the call and a backend
709        // that reports the requested lifetime, or carries it on the wire, must
710        // not be able to report one the agent never asked for.
711        let key = SigningKey::generate();
712        let record = record(&test_uri(), &key.verifying_key());
713        let short = Duration::from_mins(1);
714        let proof = MutationProof::sign_next(&key, &record, &renewal(short));
715
716        let Mutation::Refresh { expires_at, .. } = renewal(short) else {
717            unreachable!("renewal builds a refresh")
718        };
719        let result = proof.authorizes(
720            &record,
721            &Mutation::Refresh {
722                ttl: Duration::from_hours(24),
723                expires_at,
724            },
725        );
726
727        assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
728    }
729
730    #[test]
731    fn a_signed_proof_that_does_not_advance_is_stale_not_unauthorized() {
732        // The two rejections mean different things to a caller: one says fix
733        // your key, the other says re-read and try again.
734        let key = SigningKey::generate();
735        let record = record(&test_uri(), &key.verifying_key());
736        let proof = MutationProof::sign(
737            &key,
738            record.agent_uri(),
739            RecordVersion::of(&record),
740            &Mutation::Deregister,
741        );
742
743        let result = proof.authorizes(&record, &Mutation::Deregister);
744
745        assert!(matches!(
746            result,
747            Err(DhtError::StaleSequence {
748                presented: 0,
749                current: 0,
750                ..
751            })
752        ));
753    }
754
755    #[test]
756    fn an_unsigned_write_is_refused_before_the_sequence_is_examined() {
757        // Answering "stale sequence" to a write nobody signed would hand out
758        // the record's position for free.
759        let agent = SigningKey::generate();
760        let attacker = SigningKey::generate();
761        let record = record(&test_uri(), &agent.verifying_key());
762        let proof = MutationProof::sign(
763            &attacker,
764            record.agent_uri(),
765            RecordVersion::of(&record),
766            &Mutation::Deregister,
767        );
768
769        let result = proof.authorizes(&record, &Mutation::Deregister);
770
771        assert!(matches!(result, Err(DhtError::Unauthorized { .. })));
772    }
773
774    #[test]
775    fn the_next_version_advances_exactly_one_past_the_current() {
776        let key = SigningKey::generate();
777        let mut record = record(&test_uri(), &key.verifying_key());
778        record.set_sequence(41);
779
780        assert_eq!(RecordVersion::of(&record).sequence(), 41);
781        assert_eq!(RecordVersion::next(&record).sequence(), 42);
782        assert_eq!(
783            RecordVersion::next(&record).registered_at(),
784            record.registered_at()
785        );
786    }
787
788    #[test]
789    fn the_next_version_saturates_rather_than_wrapping() {
790        // Wrapping would hand a record back to every proof ever captured for it.
791        let key = SigningKey::generate();
792        let mut record = record(&test_uri(), &key.verifying_key());
793        record.set_sequence(u64::MAX);
794
795        assert_eq!(RecordVersion::next(&record).sequence(), u64::MAX);
796    }
797
798    #[test]
799    fn endpoint_lists_do_not_alias_through_concatenation() {
800        // Length prefixes are the reason. Without them, ["ab", "c"] and
801        // ["a", "bc"] would flatten to the same bytes and one signature would
802        // authorize both.
803        let uri = test_uri();
804        let version = RecordVersion::new(SystemTime::UNIX_EPOCH, 1);
805        let split_one = vec![Endpoint::https("ab"), Endpoint::https("c")];
806        let split_two = vec![Endpoint::https("a"), Endpoint::https("bc")];
807
808        assert_ne!(
809            signing_payload(&uri, version, &moved_to(&split_one)),
810            signing_payload(&uri, version, &moved_to(&split_two))
811        );
812    }
813
814    #[test]
815    fn an_endpoint_path_does_not_alias_into_the_address() {
816        let uri = test_uri();
817        let version = RecordVersion::new(SystemTime::UNIX_EPOCH, 1);
818        let with_path = vec![Endpoint::https_with_path("a.example", "/v1")];
819        let in_address = vec![Endpoint::https("a.example/v1")];
820
821        assert_ne!(
822            signing_payload(&uri, version, &moved_to(&with_path)),
823            signing_payload(&uri, version, &moved_to(&in_address))
824        );
825    }
826
827    #[test]
828    fn the_payload_is_domain_separated() {
829        let payload = signing_payload(
830            &test_uri(),
831            RecordVersion::new(SystemTime::UNIX_EPOCH, 1),
832            &Mutation::Deregister,
833        );
834        assert!(payload.starts_with(DOMAIN));
835    }
836
837    #[test]
838    fn the_payload_is_deterministic() {
839        let uri = test_uri();
840        let version = RecordVersion::new(SystemTime::UNIX_EPOCH, 7);
841        assert_eq!(
842            signing_payload(&uri, version, &Mutation::Deregister),
843            signing_payload(&uri, version, &Mutation::Deregister)
844        );
845    }
846
847    #[test]
848    fn the_payload_survives_the_millisecond_rounding_serde_applies() {
849        // Signer and verifier may sit on opposite sides of a serialization, so
850        // sub-millisecond precision in the payload would make a proof stop
851        // verifying purely because the record went over the wire.
852        let uri = test_uri();
853        let base = SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_123);
854
855        assert_eq!(
856            signing_payload(&uri, RecordVersion::new(base, 1), &Mutation::Deregister),
857            signing_payload(
858                &uri,
859                RecordVersion::new(base + Duration::from_nanos(500), 1),
860                &Mutation::Deregister
861            )
862        );
863    }
864
865    #[test]
866    fn a_registration_proof_does_not_double_as_a_later_write() {
867        // Both are signed at the same version by the same key, so only the
868        // operation tag separates them.
869        let key = SigningKey::generate();
870        let record = record(&test_uri(), &key.verifying_key());
871        let proof = MutationProof::sign_registration(&key, &record);
872
873        assert!(proof.authorizes_registration(&record).is_ok());
874        assert!(matches!(
875            proof.authorizes(&record, &Mutation::Deregister),
876            Err(DhtError::Unauthorized { .. })
877        ));
878    }
879
880    #[test]
881    fn a_registration_proof_is_bound_to_the_key_the_record_names() {
882        // The key is inside the signed payload, so a record cannot be handed a
883        // proof that was made for a record naming somebody else.
884        let key = SigningKey::generate();
885        let other = SigningKey::generate();
886        let mine = record(&test_uri(), &key.verifying_key());
887        let proof = MutationProof::sign_registration(&key, &mine);
888
889        // Same everything except the key the record names.
890        let swapped = Registration::new(
891            test_uri(),
892            other.verifying_key(),
893            vec![Endpoint::https("a.example")],
894        )
895        .with_registered_at(mine.registered_at());
896
897        assert!(matches!(
898            proof.authorizes_registration(&swapped),
899            Err(DhtError::Unauthorized { .. })
900        ));
901    }
902
903    #[test]
904    fn mutation_kinds_have_distinct_tags() {
905        let tags = [
906            MutationKind::Register.tag(),
907            MutationKind::UpdateEndpoint.tag(),
908            MutationKind::Refresh.tag(),
909            MutationKind::Deregister.tag(),
910        ];
911        let mut sorted = tags.to_vec();
912        sorted.sort_unstable();
913        sorted.dedup();
914        assert_eq!(sorted.len(), tags.len());
915    }
916}