Skip to main content

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}