Skip to main content

fips_core/endpoint/
recent_peers.rs

1//! Store-agnostic persistence model for recently authenticated FIPS peers.
2//!
3//! This cache is routing memory, not an authorization or membership source.
4//! Cached routes may only augment an application's existing [`PeerConfig`]
5//! entries; they must never create configured peers by themselves.
6
7use super::status::{FipsEndpointPeer, is_reusable_udp_socket_addr};
8use crate::PeerIdentity;
9use crate::config::{PeerAddress, PeerAddressProvenance, PeerConfig};
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, HashSet};
12use std::net::SocketAddr;
13use thiserror::Error;
14
15/// Current recent-peers JSON schema version.
16pub const RECENT_PEERS_VERSION: u32 = 1;
17/// Maximum number of remote identities retained in one cache.
18pub const RECENT_PEERS_MAX_PEERS: usize = 256;
19/// Maximum number of restart endpoints retained for one remote identity.
20pub const RECENT_PEERS_MAX_ENDPOINTS_PER_PEER: usize = 4;
21
22/// Store-agnostic version-1 recent-peer document.
23///
24/// The serialized object contains exactly `version`, `local_npub`, `scope`,
25/// and `peers`. Applications choose where and how to persist that JSON.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct RecentPeers {
29    pub version: u32,
30    pub local_npub: String,
31    pub scope: String,
32    pub peers: BTreeMap<String, RecentPeer>,
33}
34
35/// Authentication recency and reusable endpoints for one remote identity.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct RecentPeer {
39    pub last_authenticated_at_ms: u64,
40    pub endpoints: Vec<RecentPeerEndpoint>,
41}
42
43/// One previously authenticated, restart-safe transport endpoint.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct RecentPeerEndpoint {
47    pub transport: RecentPeerTransport,
48    pub addr: String,
49    pub last_authenticated_at_ms: u64,
50}
51
52/// Transport kinds allowed in the version-1 recent-peer schema.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum RecentPeerTransport {
56    Udp,
57}
58
59/// Validation or JSON codec error for a recent-peer document.
60#[derive(Debug, Error)]
61pub enum RecentPeersError {
62    #[error("invalid recent-peers JSON: {0}")]
63    Json(#[from] serde_json::Error),
64
65    #[error("unsupported recent-peers version {actual}; expected {RECENT_PEERS_VERSION}")]
66    UnsupportedVersion { actual: u32 },
67
68    #[error("invalid canonical local npub '{npub}': {reason}")]
69    InvalidLocalNpub { npub: String, reason: String },
70
71    #[error("recent-peers local identity mismatch: expected '{expected}', found '{actual}'")]
72    LocalIdentityMismatch { expected: String, actual: String },
73
74    #[error("recent-peers scope mismatch: expected '{expected}', found '{actual}'")]
75    ScopeMismatch { expected: String, actual: String },
76
77    #[error("invalid canonical peer npub '{npub}': {reason}")]
78    InvalidPeerNpub { npub: String, reason: String },
79
80    #[error("recent-peers document contains the local identity as a remote peer")]
81    LocalIdentityAsPeer,
82
83    #[error("recent-peers document has {actual} peers; maximum is {max}")]
84    TooManyPeers { actual: usize, max: usize },
85
86    #[error("recent peer '{npub}' has {actual} endpoints; maximum is {max}")]
87    TooManyEndpoints {
88        npub: String,
89        actual: usize,
90        max: usize,
91    },
92
93    #[error("recent peer '{npub}' has unusable UDP endpoint '{addr}'")]
94    UnusableUdpEndpoint { npub: String, addr: String },
95
96    #[error("recent peer '{npub}' repeats UDP endpoint '{addr}'")]
97    DuplicateUdpEndpoint { npub: String, addr: String },
98
99    #[error(
100        "recent peer '{npub}' endpoint timestamp {endpoint_at_ms} is newer than peer timestamp {peer_at_ms}"
101    )]
102    EndpointNewerThanPeer {
103        npub: String,
104        endpoint_at_ms: u64,
105        peer_at_ms: u64,
106    },
107}
108
109impl RecentPeers {
110    /// Create an empty version-1 cache for one local identity and app scope.
111    pub fn new(
112        local_npub: impl Into<String>,
113        scope: impl Into<String>,
114    ) -> Result<Self, RecentPeersError> {
115        let local_npub = local_npub.into();
116        validate_canonical_npub(&local_npub).map_err(|reason| {
117            RecentPeersError::InvalidLocalNpub {
118                npub: local_npub.clone(),
119                reason,
120            }
121        })?;
122        Ok(Self {
123            version: RECENT_PEERS_VERSION,
124            local_npub,
125            scope: scope.into(),
126            peers: BTreeMap::new(),
127        })
128    }
129
130    /// Strictly decode and validate a cache for the expected identity/scope.
131    ///
132    /// Unknown fields, unknown transports, wrong versions, invalid identities,
133    /// unsafe endpoints, and documents exceeding the schema limits are
134    /// rejected. Identity and scope binding prevents one app or local key from
135    /// accidentally consuming another's routing memory.
136    pub fn from_json(
137        json: &str,
138        expected_local_npub: &str,
139        expected_scope: &str,
140    ) -> Result<Self, RecentPeersError> {
141        let recent: Self = serde_json::from_str(json)?;
142        recent.validate()?;
143
144        validate_canonical_npub(expected_local_npub).map_err(|reason| {
145            RecentPeersError::InvalidLocalNpub {
146                npub: expected_local_npub.to_string(),
147                reason,
148            }
149        })?;
150        if recent.local_npub != expected_local_npub {
151            return Err(RecentPeersError::LocalIdentityMismatch {
152                expected: expected_local_npub.to_string(),
153                actual: recent.local_npub,
154            });
155        }
156        if recent.scope != expected_scope {
157            return Err(RecentPeersError::ScopeMismatch {
158                expected: expected_scope.to_string(),
159                actual: recent.scope,
160            });
161        }
162        Ok(recent)
163    }
164
165    /// Validate and encode the canonical compact version-1 JSON document.
166    pub fn to_json(&self) -> Result<String, RecentPeersError> {
167        self.validate()?;
168        Ok(serde_json::to_string(self)?)
169    }
170
171    /// Validate and encode an indented version-1 JSON document.
172    pub fn to_json_pretty(&self) -> Result<String, RecentPeersError> {
173        self.validate()?;
174        Ok(serde_json::to_string_pretty(self)?)
175    }
176
177    /// Local identity to which this routing cache is bound.
178    pub fn local_npub(&self) -> &str {
179        &self.local_npub
180    }
181
182    /// Application discovery scope to which this routing cache is bound.
183    pub fn scope(&self) -> &str {
184        &self.scope
185    }
186
187    /// Validate all schema invariants without decoding or encoding JSON.
188    pub fn validate(&self) -> Result<(), RecentPeersError> {
189        if self.version != RECENT_PEERS_VERSION {
190            return Err(RecentPeersError::UnsupportedVersion {
191                actual: self.version,
192            });
193        }
194        validate_canonical_npub(&self.local_npub).map_err(|reason| {
195            RecentPeersError::InvalidLocalNpub {
196                npub: self.local_npub.clone(),
197                reason,
198            }
199        })?;
200        if self.peers.len() > RECENT_PEERS_MAX_PEERS {
201            return Err(RecentPeersError::TooManyPeers {
202                actual: self.peers.len(),
203                max: RECENT_PEERS_MAX_PEERS,
204            });
205        }
206
207        for (npub, peer) in &self.peers {
208            validate_canonical_npub(npub).map_err(|reason| RecentPeersError::InvalidPeerNpub {
209                npub: npub.clone(),
210                reason,
211            })?;
212            if npub == &self.local_npub {
213                return Err(RecentPeersError::LocalIdentityAsPeer);
214            }
215            validate_peer(npub, peer)?;
216        }
217        Ok(())
218    }
219
220    /// Record one currently authenticated endpoint snapshot.
221    ///
222    /// A connected peer's identity is retained regardless of transport. Only
223    /// [`FipsEndpointPeer::authenticated_udp_restart_addr`] can add a restart
224    /// endpoint. A disconnected status/retry entry is ignored because it is
225    /// not fresh authentication evidence.
226    ///
227    /// Returns `true` only when the cache contents changed.
228    pub fn observe_authenticated_peer(
229        &mut self,
230        peer: &FipsEndpointPeer,
231        authenticated_at_ms: u64,
232    ) -> Result<bool, RecentPeersError> {
233        if !peer.connected {
234            return Ok(false);
235        }
236        validate_canonical_npub(&peer.npub).map_err(|reason| {
237            RecentPeersError::InvalidPeerNpub {
238                npub: peer.npub.clone(),
239                reason,
240            }
241        })?;
242        if peer.npub == self.local_npub {
243            return Err(RecentPeersError::LocalIdentityAsPeer);
244        }
245
246        let previous = self.peers.get(&peer.npub).cloned();
247        let restart_addr = peer.authenticated_udp_restart_addr();
248        let entry = self.peers.entry(peer.npub.clone()).or_insert(RecentPeer {
249            last_authenticated_at_ms: authenticated_at_ms,
250            endpoints: Vec::new(),
251        });
252        entry.last_authenticated_at_ms = entry.last_authenticated_at_ms.max(authenticated_at_ms);
253
254        if let Some(addr) = restart_addr {
255            observe_udp_endpoint(entry, addr, authenticated_at_ms);
256        }
257        self.retain_newest_peers();
258        Ok(self.peers.get(&peer.npub) != previous.as_ref())
259    }
260
261    /// Remove identity and endpoint observations older than `ttl_ms`.
262    ///
263    /// Identity-only records are intentionally retained for the same TTL as
264    /// routed records. Future timestamps survive clock rollback by using
265    /// saturating age arithmetic.
266    pub fn prune(&mut self, now_ms: u64, ttl_ms: u64) {
267        self.peers.retain(|_, peer| {
268            peer.endpoints.retain(|endpoint| {
269                now_ms.saturating_sub(endpoint.last_authenticated_at_ms) <= ttl_ms
270            });
271            now_ms.saturating_sub(peer.last_authenticated_at_ms) <= ttl_ms
272        });
273    }
274
275    /// Merge cached routes into already-authorized peer configurations.
276    ///
277    /// This method cannot create membership: it only mutates entries in the
278    /// supplied slice and never calls [`crate::endpoint::FipsEndpoint::update_peers`].
279    /// Applications remain responsible for deciding which peers belong in
280    /// that slice. Cached addresses use authenticated provenance and preserve
281    /// their observation timestamp for retry ranking.
282    ///
283    /// Returns the number of addresses added or refreshed.
284    pub fn merge_into_peer_configs(&self, peer_configs: &mut [PeerConfig]) -> usize {
285        let mut merged = 0;
286        for config in peer_configs {
287            let Some(canonical_npub) = peer_config_canonical_npub(&config.npub) else {
288                continue;
289            };
290            let Some(recent) = self.peers.get(&canonical_npub) else {
291                continue;
292            };
293            for endpoint in &recent.endpoints {
294                merged += merge_endpoint(config, endpoint);
295            }
296        }
297        merged
298    }
299
300    fn retain_newest_peers(&mut self) {
301        if self.peers.len() <= RECENT_PEERS_MAX_PEERS {
302            return;
303        }
304        let mut newest = self
305            .peers
306            .iter()
307            .map(|(npub, peer)| (peer.last_authenticated_at_ms, npub.clone()))
308            .collect::<Vec<_>>();
309        newest.sort_by(|left, right| right.cmp(left));
310        let keep = newest
311            .into_iter()
312            .take(RECENT_PEERS_MAX_PEERS)
313            .map(|(_, npub)| npub)
314            .collect::<HashSet<_>>();
315        self.peers.retain(|npub, _| keep.contains(npub));
316    }
317}
318
319fn validate_canonical_npub(npub: &str) -> Result<(), String> {
320    let identity = PeerIdentity::from_npub(npub).map_err(|error| error.to_string())?;
321    if identity.npub() != npub {
322        return Err("npub is not in canonical lowercase NIP-19 form".to_string());
323    }
324    Ok(())
325}
326
327fn validate_peer(npub: &str, peer: &RecentPeer) -> Result<(), RecentPeersError> {
328    if peer.endpoints.len() > RECENT_PEERS_MAX_ENDPOINTS_PER_PEER {
329        return Err(RecentPeersError::TooManyEndpoints {
330            npub: npub.to_string(),
331            actual: peer.endpoints.len(),
332            max: RECENT_PEERS_MAX_ENDPOINTS_PER_PEER,
333        });
334    }
335    let mut addresses = HashSet::new();
336    for endpoint in &peer.endpoints {
337        if endpoint.last_authenticated_at_ms > peer.last_authenticated_at_ms {
338            return Err(RecentPeersError::EndpointNewerThanPeer {
339                npub: npub.to_string(),
340                endpoint_at_ms: endpoint.last_authenticated_at_ms,
341                peer_at_ms: peer.last_authenticated_at_ms,
342            });
343        }
344        let addr = endpoint.addr.parse::<SocketAddr>().map_err(|_| {
345            RecentPeersError::UnusableUdpEndpoint {
346                npub: npub.to_string(),
347                addr: endpoint.addr.clone(),
348            }
349        })?;
350        if !is_reusable_udp_socket_addr(&addr) {
351            return Err(RecentPeersError::UnusableUdpEndpoint {
352                npub: npub.to_string(),
353                addr: endpoint.addr.clone(),
354            });
355        }
356        if !addresses.insert(addr) {
357            return Err(RecentPeersError::DuplicateUdpEndpoint {
358                npub: npub.to_string(),
359                addr: endpoint.addr.clone(),
360            });
361        }
362    }
363    Ok(())
364}
365
366fn peer_config_canonical_npub(value: &str) -> Option<String> {
367    if let Ok(identity) = PeerIdentity::from_npub(value) {
368        return Some(identity.npub());
369    }
370
371    value
372        .parse::<secp256k1::XOnlyPublicKey>()
373        .ok()
374        .map(PeerIdentity::from_pubkey)
375        .map(|identity| identity.npub())
376}
377
378fn observe_udp_endpoint(peer: &mut RecentPeer, addr: SocketAddr, authenticated_at_ms: u64) {
379    if let Some(endpoint) = peer.endpoints.iter_mut().find(|endpoint| {
380        endpoint
381            .addr
382            .parse::<SocketAddr>()
383            .is_ok_and(|stored| stored == addr)
384    }) {
385        endpoint.addr = addr.to_string();
386        endpoint.last_authenticated_at_ms =
387            endpoint.last_authenticated_at_ms.max(authenticated_at_ms);
388    } else {
389        peer.endpoints.push(RecentPeerEndpoint {
390            transport: RecentPeerTransport::Udp,
391            addr: addr.to_string(),
392            last_authenticated_at_ms: authenticated_at_ms,
393        });
394    }
395    peer.endpoints.sort_by(|left, right| {
396        right
397            .last_authenticated_at_ms
398            .cmp(&left.last_authenticated_at_ms)
399            .then_with(|| left.addr.cmp(&right.addr))
400    });
401    peer.endpoints.truncate(RECENT_PEERS_MAX_ENDPOINTS_PER_PEER);
402}
403
404fn merge_endpoint(config: &mut PeerConfig, endpoint: &RecentPeerEndpoint) -> usize {
405    let cached_addr = endpoint.addr.parse::<SocketAddr>().ok();
406    let existing = config.addresses.iter_mut().find(|candidate| {
407        candidate.transport == "udp"
408            && candidate
409                .addr
410                .parse::<SocketAddr>()
411                .ok()
412                .zip(cached_addr)
413                .is_some_and(|(candidate, cached)| candidate == cached)
414    });
415
416    if let Some(existing) = existing {
417        if existing.provenance == PeerAddressProvenance::Configured {
418            return 0;
419        }
420        let prior_provenance = existing.provenance;
421        let prior_seen_at_ms = existing.seen_at_ms;
422        existing.provenance = PeerAddressProvenance::Authenticated;
423        existing.seen_at_ms = Some(
424            existing
425                .seen_at_ms
426                .unwrap_or_default()
427                .max(endpoint.last_authenticated_at_ms),
428        );
429        return usize::from(
430            existing.provenance != prior_provenance || existing.seen_at_ms != prior_seen_at_ms,
431        );
432    }
433
434    config.addresses.push(
435        PeerAddress::new("udp", &endpoint.addr)
436            .authenticated()
437            .with_seen_at_ms(endpoint.last_authenticated_at_ms),
438    );
439    1
440}
441
442#[cfg(test)]
443mod tests;