use std::time::{Duration, Instant};
use dashmap::DashMap;
use super::auth::{ExpectedBinding, VerifiedSubnetContext};
use crate::adapter::net::identity::EntityId;
pub const SUBNET_CHALLENGE_TTL: Duration = Duration::from_secs(30);
pub const MAX_CHALLENGES_PER_PEER: usize = 4;
pub const MAX_CHALLENGE_PEERS: usize = 4096;
pub fn unix_now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug, Clone)]
struct PendingChallenge {
nonce: [u8; 32],
session_id: u64,
issued_at: Instant,
}
#[derive(Debug, Default)]
pub struct SubnetChallengeStore {
by_peer: DashMap<u64, Vec<PendingChallenge>>,
}
impl SubnetChallengeStore {
pub fn new() -> Self {
Self::default()
}
pub fn issue(&self, node_id: u64, session_id: u64, now: Instant) -> Option<[u8; 32]> {
if !self.by_peer.contains_key(&node_id) && self.by_peer.len() >= MAX_CHALLENGE_PEERS {
self.by_peer.retain(|_, slot| {
slot.retain(|c| now.duration_since(c.issued_at) < SUBNET_CHALLENGE_TTL);
!slot.is_empty()
});
if self.by_peer.len() >= MAX_CHALLENGE_PEERS {
return None;
}
}
let mut nonce = [0u8; 32];
if let Err(e) = getrandom::fill(&mut nonce) {
eprintln!(
"FATAL: subnet challenge getrandom failure ({e:?}); aborting to avoid a predictable challenge"
);
std::process::abort();
}
let mut slot = self.by_peer.entry(node_id).or_default();
slot.retain(|c| now.duration_since(c.issued_at) < SUBNET_CHALLENGE_TTL);
if slot.len() >= MAX_CHALLENGES_PER_PEER {
slot.remove(0);
}
slot.push(PendingChallenge {
nonce,
session_id,
issued_at: now,
});
Some(nonce)
}
pub fn consume(
&self,
node_id: u64,
session_id: u64,
nonce: &[u8; 32],
verifier: &EntityId,
now: Instant,
) -> Option<ExpectedBinding> {
let mut slot = self.by_peer.get_mut(&node_id)?;
slot.retain(|c| now.duration_since(c.issued_at) < SUBNET_CHALLENGE_TTL);
let idx = slot.iter().position(|c| {
c.session_id == session_id
&& bool::from(subtle::ConstantTimeEq::ct_eq(&c.nonce[..], &nonce[..]))
})?;
let found = slot.remove(idx);
let empty = slot.is_empty();
drop(slot);
if empty {
self.by_peer.remove_if(&node_id, |_, v| v.is_empty());
}
Some(ExpectedBinding {
session_id: found.session_id,
verifier: verifier.clone(),
verifier_nonce: found.nonce,
})
}
pub fn forget_peer(&self, node_id: u64) {
self.by_peer.remove(&node_id);
}
pub fn len(&self) -> usize {
self.by_peer.iter().map(|e| e.value().len()).sum()
}
pub fn is_empty(&self) -> bool {
self.by_peer.is_empty()
}
}
#[derive(Debug, Default)]
pub struct SubnetContextStore {
by_peer: DashMap<u64, VerifiedSubnetContext>,
}
impl SubnetContextStore {
pub fn new() -> Self {
Self::default()
}
pub fn install(&self, node_id: u64, ctx: VerifiedSubnetContext) {
self.by_peer.insert(node_id, ctx);
}
pub fn get_for_session(&self, node_id: u64, session_id: u64) -> Option<VerifiedSubnetContext> {
let entry = self.by_peer.get(&node_id)?;
(entry.session_id == session_id).then(|| entry.clone())
}
pub fn forget_peer(&self, node_id: u64) {
self.by_peer.remove(&node_id);
}
pub fn invalidate_stale_epoch(&self, authority: &EntityId, current: u64) -> usize {
let stale: Vec<u64> = self
.by_peer
.iter()
.filter(|e| &e.value().authority == authority && e.value().subnet_auth_epoch < current)
.map(|e| *e.key())
.collect();
for node_id in &stale {
self.by_peer.remove(node_id);
}
stale.len()
}
pub fn invalidate_stale_topology(&self, current_topology_epoch: u32) -> usize {
let stale: Vec<u64> = self
.by_peer
.iter()
.filter(|e| e.value().topology_epoch != current_topology_epoch)
.map(|e| *e.key())
.collect();
for node_id in &stale {
self.by_peer.remove(node_id);
}
stale.len()
}
pub fn len(&self) -> usize {
self.by_peer.len()
}
pub fn is_empty(&self) -> bool {
self.by_peer.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_peer_ceiling_reclaims_expired_peers_before_refusing() {
let store = SubnetChallengeStore::new();
let t0 = Instant::now();
for peer in 0..MAX_CHALLENGE_PEERS as u64 {
assert!(store.issue(peer, 1, t0).is_some(), "fill peer {peer}");
}
let newcomer = MAX_CHALLENGE_PEERS as u64 + 7;
assert!(store.issue(newcomer, 1, t0).is_none());
assert!(store.issue(0, 2, t0).is_some());
let expired = t0 + SUBNET_CHALLENGE_TTL;
assert!(
store.issue(newcomer, 1, expired).is_some(),
"an all-expired ceiling must not refuse a new peer"
);
assert_eq!(store.len(), 1, "only the newcomer's challenge survives");
}
#[test]
fn the_ceiling_sweep_spares_live_challenges() {
let store = SubnetChallengeStore::new();
let t0 = Instant::now();
for peer in 0..(MAX_CHALLENGE_PEERS as u64 - 1) {
assert!(store.issue(peer, 1, t0).is_some());
}
let live_peer = MAX_CHALLENGE_PEERS as u64;
let mid = t0 + SUBNET_CHALLENGE_TTL / 2;
let live_nonce = store.issue(live_peer, 9, mid).expect("fills the ceiling");
let later = t0 + SUBNET_CHALLENGE_TTL;
let newcomer = live_peer + 1;
assert!(store.issue(newcomer, 1, later).is_some());
let verifier = EntityId::from_bytes([0xAB; 32]);
assert!(
store
.consume(live_peer, 9, &live_nonce, &verifier, later)
.is_some(),
"the live challenge must survive the sweep"
);
assert!(
store.consume(0, 1, &live_nonce, &verifier, later).is_none(),
"swept peers are gone"
);
}
}