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}