hashiverse_lib/transport/transport_ownership_proof.rs
1//! # `TransportOwnershipProof` — proving and verifying control of an announced address
2//!
3//! Hashiverse peers announce themselves by address; before today, nothing in the
4//! announce wire format proved the announcer actually *controlled* that address. A
5//! peer with broken TLS / no cert could still announce, get into Kademlia, and
6//! pollute peer lists until every receiver independently RPCed it and pruned. The
7//! [`crate::protocol::payload::payload::AnnounceV2`] variant fixes that by carrying an
8//! opaque proof byte blob next to `peer_self`; the receiver decides whether to
9//! admit the peer based on whether the proof verifies.
10//!
11//! The shape of "a proof" depends on the transport. This module defines the
12//! interface; concrete impls live alongside each `TransportServer`:
13//!
14//! - HTTPS transport (`hashiverse-server-lib::HttpsTransportOwnershipProof`):
15//! serialises the server's current ACME-issued TLS chain plus a binding signature
16//! tying the chain to `peer_self.id`; verification is offline X.509 path validation
17//! against the bundled Mozilla NSS roots (see
18//! [`crate::tools::cert_validation::is_cert_valid`]) plus binding-signature check.
19//! - Mem transport: an empty marker accepted by an empty-marker verifier; tests can
20//! exercise the V2 path end-to-end without real certs.
21//!
22//! Wrong-transport mismatches surface naturally: if peer X sends bytes that
23//! transport Y's verifier can't deserialise, [`TransportOwnershipProof::prove`]
24//! returns `false`. No discriminator field needed on the wire.
25//!
26//! [`TransportServer::get_transport_ownership_proof`][gtop] hands out one
27//! `Arc<dyn TransportOwnershipProof>` per server. The default trait impl returns a
28//! reject-all placeholder so old `TransportServer` impls keep compiling; real
29//! transports override it.
30//!
31//! [gtop]: crate::transport::transport::TransportServer::get_transport_ownership_proof
32
33use crate::protocol::peer::Peer;
34use crate::tools::time::TimeMillis;
35use bytes::Bytes;
36
37/// A per-transport rule for producing our own proof bytes (announce-out) and verifying
38/// other peers' proof bytes (announce-in). A single `Arc<dyn TransportOwnershipProof>`
39/// lives on a `TransportServer` and is used for both directions.
40pub trait TransportOwnershipProof: Send + Sync {
41 /// Announce-out: produce the proof bytes for our own announce. Returns `None` if we
42 /// can't currently prove ownership — e.g. an HTTPS server that hasn't completed its
43 /// first ACME issuance yet. Callers (`maintain_kademlia`) treat `None` as "skip this
44 /// announce tick, try again next interval".
45 ///
46 /// Returns `Bytes` rather than `Vec<u8>` so the produced payload elides cleanly into
47 /// the project's `BytesGatherer`-based wire aggregation without an extra copy.
48 fn make_ownership_proof_payload(&self) -> Option<Bytes>;
49
50 /// Announce-in: validate `proof_payload` (bytes pulled out of an inbound `AnnounceV2`)
51 /// against `peer`. Returns `false` if the bytes can't be deserialised by this impl
52 /// (wrong transport / corrupt blob) or if the proof fails verification (expired cert,
53 /// wrong-IP SAN, …).
54 ///
55 /// V2 only guarantees "someone managed to issue a public-CA cert for `peer.address`".
56 /// A peer that *borrows* a stranger's chain (the chain is public — every TLS
57 /// handshake leaks it) passes this gate, but any RPC the receiver then sends to
58 /// `peer.address` reaches the real server with the real identity at that IP, and the
59 /// response-side identity mismatch trips the existing prune path. V2 specifically
60 /// blocks the original dodgy-peer case (peer has no valid cert at all).
61 fn prove(&self, peer: &Peer, proof_payload: &[u8], now: TimeMillis) -> bool;
62}
63
64/// Reject-all placeholder used as the default for `TransportServer` impls that
65/// haven't been taught about ownership proofs yet (notably the wasm / TCP transports
66/// during the V2 rollout). It cannot produce a proof and rejects every inbound proof
67/// — safe by default: peers using this transport won't be admitted to Kademlia via
68/// V2, and we never accidentally accept somebody else's proof we don't understand.
69pub struct RejectAllTransportOwnershipProof;
70
71impl TransportOwnershipProof for RejectAllTransportOwnershipProof {
72 fn make_ownership_proof_payload(&self) -> Option<Bytes> {
73 None
74 }
75
76 fn prove(&self, _peer: &Peer, _proof_payload: &[u8], _now: TimeMillis) -> bool {
77 false
78 }
79}
80
81/// Empty-marker ownership proof, shared by transports that don't (and can't) cryptographically
82/// model address ownership: the in-memory test transport and the plain-TCP transport used for
83/// local-LAN / private-network deployments. Both ends agree on the empty byte string as the
84/// "I'm a trusted-network peer" token. An HTTPS-shaped proof byte blob sent to one of these
85/// servers fails the `is_empty()` check; conversely an empty-marker proof sent to an HTTPS
86/// server fails its postcard decode (or `is_cert_valid` on an empty chain). Cross-transport
87/// announces fall through naturally without a discriminator field on the wire.
88pub struct EmptyMarkerOwnershipProof;
89
90impl TransportOwnershipProof for EmptyMarkerOwnershipProof {
91 fn make_ownership_proof_payload(&self) -> Option<Bytes> {
92 Some(Bytes::new())
93 }
94
95 fn prove(&self, _peer: &Peer, proof_payload: &[u8], _now: TimeMillis) -> bool {
96 proof_payload.is_empty()
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 fn fake_peer_for_proof_tests() -> Peer {
105 // Proof methods on RejectAllTransportOwnershipProof don't touch the peer; an
106 // unsigned skeleton is enough to satisfy the type system.
107 Peer::zero()
108 }
109
110 #[test]
111 fn reject_all_make_returns_none() {
112 let proof: RejectAllTransportOwnershipProof = RejectAllTransportOwnershipProof;
113 let _peer: Peer = fake_peer_for_proof_tests();
114 assert!(proof.make_ownership_proof_payload().is_none());
115 }
116
117 #[test]
118 fn reject_all_prove_rejects_empty() {
119 let proof: RejectAllTransportOwnershipProof = RejectAllTransportOwnershipProof;
120 let peer: Peer = fake_peer_for_proof_tests();
121 assert!(!proof.prove(&peer, &[], TimeMillis(1_700_000_000_000)));
122 }
123
124 #[test]
125 fn reject_all_prove_rejects_arbitrary_bytes() {
126 let proof: RejectAllTransportOwnershipProof = RejectAllTransportOwnershipProof;
127 let peer: Peer = fake_peer_for_proof_tests();
128 assert!(!proof.prove(&peer, &[1, 2, 3, 4, 5], TimeMillis(1_700_000_000_000)));
129 }
130
131 #[test]
132 fn empty_marker_make_returns_empty() {
133 let proof: EmptyMarkerOwnershipProof = EmptyMarkerOwnershipProof;
134 let payload: Bytes = proof.make_ownership_proof_payload().expect("empty-marker proof is always producible");
135 assert!(payload.is_empty());
136 }
137
138 #[test]
139 fn empty_marker_prove_accepts_empty() {
140 let proof: EmptyMarkerOwnershipProof = EmptyMarkerOwnershipProof;
141 let peer: Peer = fake_peer_for_proof_tests();
142 assert!(proof.prove(&peer, &[], TimeMillis(1_700_000_000_000)));
143 }
144
145 #[test]
146 fn empty_marker_prove_rejects_nonempty() {
147 // An HTTPS-shaped (non-empty) byte blob sent to a trusted-network (mem / TCP) server
148 // is rejected — this is how cross-transport mismatch surfaces without an on-wire
149 // discriminator.
150 let proof: EmptyMarkerOwnershipProof = EmptyMarkerOwnershipProof;
151 let peer: Peer = fake_peer_for_proof_tests();
152 assert!(!proof.prove(&peer, &[1, 2, 3], TimeMillis(1_700_000_000_000)));
153 }
154}