Skip to main content

hashiverse_lib/protocol/
peer.rs

1//! # Peer records — identity, reputation, and PoW credentials for gossip
2//!
3//! Three types make up a peer's on-the-wire reputation:
4//!
5//! - [`Peer`] — the self-signed identity record gossiped through the Kademlia DHT. Carries
6//!   the peer's Ed25519 public key, post-quantum commitment, network address, and
7//!   **three** PoW tokens (see [`PeerPow`]): the initial "birth certificate", the best
8//!   PoW observed during the current rolling day, and during the current rolling month.
9//!   Rolling PoW means a peer that has continued to do work recently is weighted higher
10//!   than one that submitted a good initial PoW and went quiet, without every peer
11//!   having to redo heavy work on every gossip cycle.
12//! - [`PeerPow`] — a PoW token bound to a specific `content_hash` and timestamp. Because
13//!   the content_hash is part of the proof, these tokens can't be replayed to sign a
14//!   *different* message.
15//! - [`ClientPow`] — bundles a peer's public key + PQ commitment with its *hardest-ever*
16//!   PoW, used to gate trust-sensitive operations (healing, feedback amplification).
17//!
18//! Every field uses compact `#[serde(rename)]` wire keys so gossiped peer blobs stay
19//! small even at DHT scale.
20
21use crate::tools::server_id::ServerId;
22use crate::tools::time::{DurationMillis, MILLIS_IN_DAY, MILLIS_IN_MONTH, TimeMillis, TimeMillisBytes};
23use crate::tools::time_provider::time_provider::TimeProvider;
24use crate::tools::types::{Hash, Id, PQCommitmentBytes, Pow, Salt, Signature, SignatureKey, VerificationKey, VerificationKeyBytes};
25use crate::tools::{config, hashing, signing, tools};
26use crate::{anyhow_assert_eq, anyhow_assert_ge};
27use serde::{Deserialize, Serialize};
28use std::fmt;
29
30/// A proof-of-work token sponsored by a specific peer and bound to a specific request body.
31///
32/// `PeerPow` is the standard "I did work, and I did it for *this* request" credential. It
33/// fixes four things into the PoW input: the sponsor's peer [`Id`], the time the work was
34/// performed, a `content_hash` over the request body, and a [`Salt`] that the worker varied
35/// to find a solution. Pinning the content hash is what prevents an attacker from replaying
36/// a valid PoW against a different request — re-using the same `salt` against a new body
37/// would produce a different hash and fail verification.
38///
39/// `PeerPow` is worn by [`Peer`] itself (three of them: the initial identity PoW plus
40/// rolling day/month "best-effort" PoWs) and by every authenticated RPC (see
41/// [`crate::protocol::rpc`]). Because its inputs are canonical and verification is cheap,
42/// receivers can re-derive and compare the PoW without keeping state.
43#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
44pub struct PeerPow {
45    #[serde(rename = "sp")]
46    pub sponsor_id: Id,
47    #[serde(rename = "t")]
48    pub timestamp: TimeMillis,
49    #[serde(rename = "ch")]
50    pub content_hash: Hash, // This is the hash of the content of the request that the pow is protecting.  Stops replay attacks with the same pow being used for different requests.
51    #[serde(rename = "s")]
52    pub salt: Salt,
53    #[serde(rename = "z")]
54    pub pow: Pow,
55}
56
57impl PeerPow {
58    pub fn new(sponsor_id: Id, verification_key: &VerificationKeyBytes, pq_commitment_bytes: &PQCommitmentBytes, timestamp: TimeMillis, content_hash: Hash, salt: Salt) -> anyhow::Result<PeerPow> {
59        let (pow, _) = Self::pow(&sponsor_id, verification_key, pq_commitment_bytes, &timestamp.encode_be(), &content_hash, &salt)?;
60        Ok(Self {
61            sponsor_id,
62            timestamp,
63            content_hash,
64            salt,
65            pow,
66        })
67    }
68
69    pub fn zero() -> Self {
70        PeerPow {
71            sponsor_id: Id::zero(),
72            timestamp: TimeMillis::zero(),
73            content_hash: Hash::zero(),
74            salt: Salt::zero(),
75            pow: Pow(0),
76        }
77    }
78
79    pub fn random() -> Self {
80        Self {
81            sponsor_id: Id::random(),
82            timestamp: TimeMillis::random(),
83            content_hash: Hash::random(),
84            salt: Salt::random(),
85            pow: Pow(tools::random_u8()),
86        }
87    }
88
89    pub fn verify(&self, verification_key: &VerificationKeyBytes, pq_commitment_bytes: &PQCommitmentBytes) -> anyhow::Result<()> {
90        let (pow, _) = Self::pow(&self.sponsor_id, verification_key, pq_commitment_bytes, &self.timestamp.encode_be(), &self.content_hash, &self.salt)?;
91        match pow == self.pow {
92            true => Ok(()),
93            false => Err(anyhow::anyhow!("pow does not match inputs: {} != {}", self.pow, pow)),
94        }
95    }
96
97    pub fn pow_decayed_day(&self, current_time_millis: TimeMillis) -> f64 {
98        self.pow_decayed(current_time_millis, MILLIS_IN_DAY)
99    }
100
101    pub fn pow_decayed_month(&self, current_time_millis: TimeMillis) -> f64 {
102        self.pow_decayed(current_time_millis, MILLIS_IN_MONTH)
103    }
104
105    /// `pow` halved once per `pow_halflife_millis` since `timestamp`.  A future `timestamp` is treated as "now"
106    /// so the result never exceeds `pow` — otherwise a self-chosen far-future timestamp would give an
107    /// unbounded (or, with pow=0, NaN) score.
108    fn pow_decayed(&self, current_time_millis: TimeMillis, pow_halflife_millis: DurationMillis) -> f64 {
109        let elapsed_millis = (current_time_millis - self.timestamp).max(DurationMillis(0));
110        (self.pow.0 as f64) / 2.0f64.powf(elapsed_millis.0 as f64 / pow_halflife_millis.0 as f64)
111    }
112
113    pub fn pow(sponsor_id: &Id, verification_key: &VerificationKeyBytes, pq_commitment_bytes: &PQCommitmentBytes, timestamp: &TimeMillisBytes, hash: &Hash, salt: &Salt) -> anyhow::Result<(Pow, Hash)> {
114        ServerId::pow_measure(sponsor_id, verification_key, pq_commitment_bytes, timestamp, hash, salt)
115    }
116
117    pub fn own_pow(&self, verification_key: &VerificationKeyBytes, pq_commitment_bytes: &PQCommitmentBytes) -> anyhow::Result<(Pow, Hash)> {
118        Self::pow(&self.sponsor_id, verification_key, pq_commitment_bytes, &self.timestamp.encode_be(), &self.content_hash, &self.salt)
119    }
120}
121
122/// A client-side best-ever proof-of-work token, used as a reputation credential in the
123/// trust-sensitive parts of the protocol.
124///
125/// Unlike [`PeerPow`], which is scoped to a single request, `ClientPow` is the *hardest*
126/// PoW a particular client has ever produced against a canonical sponsor target. It is
127/// carried on feedback and healing requests — the operations that let peers influence what
128/// content is propagated or suppressed — so the recipient can weight the sender's opinion
129/// by how much work they have ever invested in an identity. This makes Sybil-style attacks
130/// expensive: voting with many fresh clients means doing all that work many times.
131///
132/// The struct bundles the peer's verification key, PQ commitment, and the underlying
133/// [`PeerPow`] so a verifier has everything it needs in one place.
134#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
135pub struct ClientPow {
136    pub peer_verification_key: VerificationKeyBytes,
137    pub peer_pq_commitment_bytes: PQCommitmentBytes,
138    pub peer_pow: PeerPow,
139}
140
141impl ClientPow {
142    pub fn measure(peer_verification_key: &VerificationKeyBytes, peer_pq_commitment_bytes: &PQCommitmentBytes, sponsor_id: &Id, timestamp: TimeMillis, content_hash: &Hash, salt: &Salt) -> anyhow::Result<Self> {
143        let (pow, _) = ServerId::pow_measure(sponsor_id, peer_verification_key, peer_pq_commitment_bytes, &timestamp.encode_be(), content_hash, salt)?;
144        Ok(Self {
145            peer_verification_key: *peer_verification_key,
146            peer_pq_commitment_bytes: *peer_pq_commitment_bytes,
147            peer_pow: PeerPow {
148                sponsor_id: *sponsor_id,
149                timestamp,
150                content_hash: *content_hash,
151                salt: *salt,
152                pow,
153            },
154        })
155    }
156
157    pub fn verify(&self) -> anyhow::Result<()> {
158        self.peer_pow.verify(&self.peer_verification_key, &self.peer_pq_commitment_bytes)
159    }
160}
161
162impl fmt::Display for PeerPow {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        write!(f, "timestamp={} hash={} salt={} pow={}", self.timestamp, hex::encode(self.content_hash), hex::encode(self.salt), self.pow,)
165    }
166}
167
168/// A single node's self-signed identity record, gossiped through the Kademlia DHT.
169///
170/// `Peer` is the unit of peer discovery. Each server publishes one: its [`Id`], its Ed25519
171/// verification key, its post-quantum commitment, its current network address, its build
172/// version, and three [`PeerPow`] tokens — one fixed "initial" PoW that was produced at
173/// startup and never changes (so it acts as a long-term identity credential), plus rolling
174/// "best seen this day" and "best seen this month" PoWs that keep the record fresh and let
175/// other peers weight trust by recent work. The whole thing is covered by a `signature`
176/// so tampering is detectable.
177///
178/// Other peers store, forward, and compare `Peer` records to decide who to talk to, which
179/// is why the PoW fields have time decay built in (`pow_decayed_day`, `pow_decayed_month`)
180/// — stale records age out naturally.
181#[derive(Serialize, Deserialize, PartialEq, Clone)]
182pub struct Peer {
183    pub id: Id,
184
185    #[serde(rename = "vkb")]
186    pub verification_key_bytes: VerificationKeyBytes,
187    #[serde(rename = "pqcb")]
188    pub pq_commitment_bytes: PQCommitmentBytes,
189
190    #[serde(rename = "pi")]
191    pub pow_initial: PeerPow, // The pow generated when the server first started up.  Never changes.
192    #[serde(rename = "pcd")]
193    pub pow_current_day: PeerPow, // The best pow seen this day (approximately)
194    #[serde(rename = "pcm")]
195    pub pow_current_month: PeerPow, // The best pow seen this month (approximately)
196
197    #[serde(rename = "ver")]
198    pub version: String, // The compile version of the Peer
199    #[serde(rename = "addr")]
200    pub address: String, // The last known public address at which this server knows itself.
201
202    #[serde(rename = "ts")]
203    pub timestamp: TimeMillis, // The last time this record was updated - used for other Peers to overwrite stale records.
204
205    #[serde(rename = "sig")]
206    pub signature: Signature, // sign( hash ( id, pow_initial.pow(signature_pub), pow_current.pow(signature_pub), address, signature_timestamp ) )
207}
208
209impl fmt::Debug for Peer {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        fmt::Display::fmt(self, f)
212    }
213}
214
215impl fmt::Display for Peer {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        write!(
218            f,
219            "[ id={} version={} address={} pows=[ {} {} {} ] timestamp={} ]",
220            self.id, self.version, self.address, self.pow_initial.pow, self.pow_current_day.pow, self.pow_current_month.pow, self.timestamp
221        )
222
223        // write!(
224        //     f,
225        //     "[ id={} signature_pub={} encryption_pub={} pow_initial={} pow_current={} address={} signature_timestamp={} signature={} ]",
226        //     hex::encode(self.id),
227        //     hex::encode(self.signature_pub),
228        //     hex::encode(self.encryption_pub),
229        //     self.pow_initial,
230        //     self.pow_current,
231        //     self.address,
232        //     self.signature_timestamp,
233        //     hex::encode(self.signature),
234        // )
235    }
236}
237
238impl Peer {
239    pub fn zero() -> Peer {
240        Peer {
241            id: Id::zero(),
242            verification_key_bytes: VerificationKeyBytes::zero(),
243            pq_commitment_bytes: PQCommitmentBytes::zero(),
244
245            pow_initial: PeerPow::zero(),
246            pow_current_day: PeerPow::zero(),
247            pow_current_month: PeerPow::zero(),
248
249            version: env!("CARGO_PKG_VERSION").to_string(),
250            address: String::new(),
251
252            timestamp: TimeMillis::zero(),
253
254            signature: Signature::zero(),
255        }
256    }
257
258    /// How much we value this peer right now: rewards being currently active (decayed daily pow) while also
259    /// favouring peers that have been active for a while (decayed monthly pow).  Shared by the server's Kademlia
260    /// and the client's PeerTracker eviction so both sides rank peers identically.
261    pub fn score(&self, time_millis: TimeMillis) -> f64 {
262        self.pow_current_day.pow_decayed_day(time_millis) + self.pow_current_month.pow_decayed_month(time_millis)
263    }
264
265    pub fn signature_hash_generate(&self) -> anyhow::Result<Hash> {
266        Ok(hashing::hash_multiple(&[
267            self.id.as_ref(),
268            self.verification_key_bytes.as_ref(),
269            self.pq_commitment_bytes.as_ref(),
270            self.pow_initial.own_pow(&self.verification_key_bytes, &self.pq_commitment_bytes)?.1.as_ref(),
271            self.pow_current_day.own_pow(&self.verification_key_bytes, &self.pq_commitment_bytes)?.1.as_ref(),
272            self.pow_current_month.own_pow(&self.verification_key_bytes, &self.pq_commitment_bytes)?.1.as_ref(),
273            self.address.as_bytes(),
274            self.timestamp.encode_be().as_ref(),
275        ]))
276    }
277
278    pub fn sign(&mut self, time_provider: &dyn TimeProvider, signature_key: &SignatureKey) -> anyhow::Result<()> {
279        self.timestamp = time_provider.current_time_millis();
280
281        let signature_hash = self.signature_hash_generate()?;
282        self.signature = signing::sign(signature_key, signature_hash.as_ref());
283        Ok(())
284    }
285
286    pub fn verify(&self) -> anyhow::Result<()> {
287        self.verify_signature()?;
288        self.verify_server_id()?;
289        self.verify_pows()?;
290        Ok(())
291    }
292
293    /// Rejects records whose timestamps are further into our future than honest clock drift allows
294    /// (see [`config::PEER_MAX_FUTURE_TIMESTAMP_MILLIS`]).  Old timestamps are fine — PoW decay handles them.
295    /// Separate from [`Self::verify`] because it needs the current time and only matters where peers are scored.
296    pub fn verify_timestamps(&self, current_time_millis: TimeMillis) -> anyhow::Result<()> {
297        let latest_allowed_time_millis = current_time_millis + config::PEER_MAX_FUTURE_TIMESTAMP_MILLIS;
298        let timestamps = [
299            ("timestamp", self.timestamp),
300            ("pow_initial.timestamp", self.pow_initial.timestamp),
301            ("pow_current_day.timestamp", self.pow_current_day.timestamp),
302            ("pow_current_month.timestamp", self.pow_current_month.timestamp),
303        ];
304        for (field_name, timestamp) in timestamps {
305            if timestamp > latest_allowed_time_millis {
306                anyhow::bail!("peer {} is too far in the future: {}={} > now={} + {}", field_name, field_name, timestamp, current_time_millis, config::PEER_MAX_FUTURE_TIMESTAMP_MILLIS);
307            }
308        }
309        Ok(())
310    }
311
312    fn verify_signature(&self) -> anyhow::Result<()> {
313        let signature_hash = self.signature_hash_generate()?;
314        signing::verify(&VerificationKey::from_bytes(&self.verification_key_bytes)?, &self.signature, signature_hash.as_ref())?;
315
316        Ok(())
317    }
318
319    fn verify_server_id(&self) -> anyhow::Result<()> {
320        let (pow, pow_hash) = ServerId::pow_measure(
321            &self.pow_initial.sponsor_id,
322            &self.verification_key_bytes,
323            &self.pq_commitment_bytes,
324            &self.pow_initial.timestamp.encode_be(),
325            &self.pow_initial.content_hash,
326            &self.pow_initial.salt,
327        )?;
328        anyhow_assert_ge!(&pow, &config::SERVER_KEY_POW_MIN, "insufficient server_id pow");
329
330        let id = ServerId::server_pow_hash_to_id(pow_hash)?;
331
332        anyhow_assert_eq!(&self.id, &id, "served_id mismatch");
333
334        Ok(())
335    }
336
337    fn verify_pows(&self) -> anyhow::Result<()> {
338        self.pow_initial.verify(&self.verification_key_bytes, &self.pq_commitment_bytes)?;
339        self.pow_current_day.verify(&self.verification_key_bytes, &self.pq_commitment_bytes)?;
340        self.pow_current_month.verify(&self.verification_key_bytes, &self.pq_commitment_bytes)?;
341        Ok(())
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use crate::protocol::peer::{Peer, PeerPow};
348    use crate::tools::server_id::ServerId;
349    use crate::tools::config;
350    use crate::tools::time::{DurationMillis, TimeMillis, MILLIS_IN_DAY, MILLIS_IN_MONTH, MILLIS_IN_YEAR};
351    use crate::tools::time_provider::time_provider::{RealTimeProvider, TimeProvider};
352    use crate::tools::pow_generator::single_threaded_pow_generator::SingleThreadedPowGenerator;
353    use crate::tools::tools;
354    use crate::tools::types::{Pow, VerificationKeyBytes};
355
356    async fn get_random_ingredients() -> anyhow::Result<(ServerId, Peer)> {
357        let time_provider = RealTimeProvider;
358        let pow_generator = SingleThreadedPowGenerator::new();
359        let server_id = ServerId::new("own_pow", &time_provider, Pow(0), true, &pow_generator).await?;
360
361        let mut peer = server_id.to_peer(&time_provider)?;
362        peer.pow_current_day = PeerPow::random();
363        peer.pow_current_month = PeerPow::random();
364        peer.address = tools::random_base64(16);
365
366        // Sign again now that we have changed some of the data
367        peer.sign(&time_provider, &server_id.keys.signature_key)?;
368
369        Ok((server_id, peer))
370    }
371
372    #[tokio::test]
373    async fn signing_test() -> anyhow::Result<()> {
374        let (_, peer) = get_random_ingredients().await?;
375
376        // Now verify the signature
377        peer.verify_signature()?;
378        Ok(())
379    }
380
381    #[tokio::test]
382    async fn signing_fail_test() -> anyhow::Result<()> {
383        {
384            let (_, mut peer) = get_random_ingredients().await?;
385            peer.verification_key_bytes = VerificationKeyBytes::zero();
386            peer.verify_signature().unwrap_err();
387        }
388
389        {
390            let (_, mut peer) = get_random_ingredients().await?;
391            peer.pow_initial = PeerPow::random();
392            peer.verify_signature().unwrap_err();
393        }
394
395        {
396            let (_, mut peer) = get_random_ingredients().await?;
397            peer.pow_current_day = PeerPow::random();
398            peer.verify_signature().unwrap_err();
399        }
400
401        {
402            let (_, mut peer) = get_random_ingredients().await?;
403            peer.pow_current_month = PeerPow::random();
404            peer.verify_signature().unwrap_err();
405        }
406
407        {
408            let (_, mut peer) = get_random_ingredients().await?;
409            peer.address = tools::random_base64(16);
410            peer.verify_signature().unwrap_err();
411        }
412
413        {
414            let (_, mut peer) = get_random_ingredients().await?;
415            peer.timestamp = TimeMillis::random();
416            peer.verify_signature().unwrap_err();
417        }
418
419        Ok(())
420    }
421
422    fn peer_pow_at(timestamp: TimeMillis, pow: Pow) -> PeerPow {
423        PeerPow { timestamp, pow, ..PeerPow::zero() }
424    }
425
426    // Regression for C4: a self-chosen far-future timestamp used to make decay divide by ~0 → inf score.
427    #[test]
428    fn pow_decayed_is_bounded_for_future_timestamp() {
429        let now = RealTimeProvider.current_time_millis();
430        let far_future_peer_pow = peer_pow_at(now + MILLIS_IN_YEAR.const_mul(75), Pow(20));
431        assert_eq!(far_future_peer_pow.pow_decayed_day(now), 20.0);
432        assert_eq!(far_future_peer_pow.pow_decayed_month(now), 20.0);
433    }
434
435    // With pow=0 the unclamped formula was 0/0 = NaN, which breaks every score comparison.
436    #[test]
437    fn pow_decayed_zero_pow_future_timestamp_is_not_nan() {
438        let now = RealTimeProvider.current_time_millis();
439        let far_future_peer_pow = peer_pow_at(now + MILLIS_IN_YEAR.const_mul(75), Pow(0));
440        assert_eq!(far_future_peer_pow.pow_decayed_day(now), 0.0);
441        assert_eq!(far_future_peer_pow.pow_decayed_month(now), 0.0);
442    }
443
444    #[test]
445    fn pow_decayed_still_halves_per_halflife() {
446        let now = RealTimeProvider.current_time_millis();
447        assert_eq!(peer_pow_at(now, Pow(20)).pow_decayed_day(now), 20.0);
448        assert_eq!(peer_pow_at(now - MILLIS_IN_DAY, Pow(20)).pow_decayed_day(now), 10.0);
449        assert_eq!(peer_pow_at(now - MILLIS_IN_DAY.const_mul(2), Pow(20)).pow_decayed_day(now), 5.0);
450        assert_eq!(peer_pow_at(now - MILLIS_IN_MONTH, Pow(20)).pow_decayed_month(now), 10.0);
451    }
452
453    #[tokio::test]
454    async fn verify_timestamps_accepts_within_drift_and_rejects_beyond() -> anyhow::Result<()> {
455        let (_, honest_peer) = get_random_ingredients().await?;
456        let now = RealTimeProvider.current_time_millis();
457        let latest_allowed_time_millis = now + config::PEER_MAX_FUTURE_TIMESTAMP_MILLIS;
458        let too_far_time_millis = latest_allowed_time_millis + DurationMillis(1);
459
460        let setters: [(&str, fn(&mut Peer, TimeMillis)); 4] = [
461            ("timestamp", |peer, timestamp| peer.timestamp = timestamp),
462            ("pow_initial", |peer, timestamp| peer.pow_initial.timestamp = timestamp),
463            ("pow_current_day", |peer, timestamp| peer.pow_current_day.timestamp = timestamp),
464            ("pow_current_month", |peer, timestamp| peer.pow_current_month.timestamp = timestamp),
465        ];
466        for (field_name, set_timestamp) in setters {
467            let mut peer = honest_peer.clone();
468
469            set_timestamp(&mut peer, now - MILLIS_IN_YEAR);
470            assert!(peer.verify_timestamps(now).is_ok(), "{} in the past must be accepted", field_name);
471
472            set_timestamp(&mut peer, latest_allowed_time_millis);
473            assert!(peer.verify_timestamps(now).is_ok(), "{} at the drift limit must be accepted", field_name);
474
475            set_timestamp(&mut peer, too_far_time_millis);
476            assert!(peer.verify_timestamps(now).is_err(), "{} beyond the drift limit must be rejected", field_name);
477        }
478        Ok(())
479    }
480}