fix(peer): bound authenticated identities per endpoint IP
Allow at most eight committed peer identities at one pinned QUIC endpoint IP after Hello authentication. Reject excess keys before address-owner eviction, while removals release capacity and other LAN hosts remain admissible. Test Plan: - just test - focused port-rotation, ninth-key, address-owner, other-IP, and release tests - git diff --check
This commit is contained in:
@@ -75,7 +75,12 @@ When a peer is discovered:
|
||||
Call-to-Play domains independently. Commit the endpoint and each valid domain
|
||||
only if the candidate lease is still current for both peer ID and address.
|
||||
This domain isolation lets an invalid remote Call-to-Play slice be cleared or
|
||||
preserved according to its session without discarding a valid library.
|
||||
preserved according to its session without discarding a valid library. After
|
||||
the pinned Hello succeeds, at most eight committed identities may reside at
|
||||
one stored endpoint IP, alongside the global 64-peer limit. This
|
||||
authenticated residency bound is separate from the pre-authentication mDNS
|
||||
candidate quota; a ninth identity is rejected without evicting an existing
|
||||
address owner, and removing a peer releases its slot.
|
||||
5. Assign a fresh endpoint generation to every successful authentication.
|
||||
Dropped, failed, or superseded work releases its candidate claims, and a late
|
||||
result cannot mutate a newer generation.
|
||||
|
||||
@@ -20,6 +20,7 @@ use lanspread_proto::{
|
||||
use tokio::time::Instant;
|
||||
|
||||
pub const MAX_AUTHENTICATED_PEERS: usize = 64;
|
||||
const MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP: usize = 8;
|
||||
const MAX_TOTAL_REMOTE_LIBRARY_GAMES: usize = 16_384;
|
||||
|
||||
/// Monotonic identity for one successfully authenticated endpoint observation.
|
||||
@@ -362,6 +363,20 @@ impl PeerGameDB {
|
||||
let existing_at_addr = self.addr_index.get(&endpoint.addr).copied();
|
||||
let is_new = !self.peers.contains_key(&endpoint.peer_id);
|
||||
let makes_room = existing_at_addr.is_some_and(|id| id != endpoint.peer_id);
|
||||
// This endpoint has completed the identity-pinned Hello exchange. Count
|
||||
// the IP that will be stored in `PeerInfo`, independently of the
|
||||
// pre-authentication mDNS candidate quota.
|
||||
let endpoint_ip_occupancy = self
|
||||
.peers
|
||||
.values()
|
||||
.filter(|peer| peer.peer_id != endpoint.peer_id && peer.addr.ip() == endpoint.addr.ip())
|
||||
.count();
|
||||
if endpoint_ip_occupancy >= MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP {
|
||||
eyre::bail!(
|
||||
"authenticated peer limit of {MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP} reached for endpoint IP {}",
|
||||
endpoint.addr.ip()
|
||||
);
|
||||
}
|
||||
if is_new && self.peers.len() >= MAX_AUTHENTICATED_PEERS && !makes_room {
|
||||
eyre::bail!("authenticated peer limit of {MAX_AUTHENTICATED_PEERS} reached");
|
||||
}
|
||||
@@ -735,7 +750,11 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn addr(port: u16) -> SocketAddr {
|
||||
SocketAddr::from(([127, 0, 0, 1], port))
|
||||
addr_at([127, 0, 0, 1], port)
|
||||
}
|
||||
|
||||
fn addr_at(ip: [u8; 4], port: u16) -> SocketAddr {
|
||||
SocketAddr::from((ip, port))
|
||||
}
|
||||
|
||||
fn peer_id(seed: u8) -> PeerId {
|
||||
@@ -746,6 +765,10 @@ mod tests {
|
||||
PeerEndpoint::new(peer_id(seed), addr(port))
|
||||
}
|
||||
|
||||
fn endpoint_at(seed: u8, ip: [u8; 4], port: u16) -> PeerEndpoint {
|
||||
PeerEndpoint::new(peer_id(seed), addr_at(ip, port))
|
||||
}
|
||||
|
||||
fn session(seed: u8) -> RuntimeSessionId {
|
||||
RuntimeSessionId::from_bytes([seed; 16])
|
||||
}
|
||||
@@ -815,6 +838,104 @@ mod tests {
|
||||
assert_eq!(db.peer_endpoint(&endpoint.peer_id), Some(endpoint));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_ip_residency_allows_same_identity_port_rotation_but_rejects_a_ninth_key() {
|
||||
const ORIGIN: [u8; 4] = [192, 0, 2, 10];
|
||||
let mut db = PeerGameDB::new();
|
||||
let mut admitted = Vec::new();
|
||||
for seed in 1..=u8::try_from(MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP)
|
||||
.expect("endpoint IP limit fits in a test seed")
|
||||
{
|
||||
let endpoint = endpoint_at(seed, ORIGIN, 12_000 + u16::from(seed));
|
||||
commit(&mut db, endpoint, session(seed), library(0, None), Some(0));
|
||||
admitted.push(endpoint);
|
||||
}
|
||||
|
||||
let moved = endpoint_at(1, ORIGIN, 13_001);
|
||||
let moved_upsert = commit(&mut db, moved, session(1), library(0, None), Some(0));
|
||||
assert!(moved_upsert.addr_changed);
|
||||
assert_eq!(db.peer_endpoint(&moved.peer_id), Some(moved));
|
||||
|
||||
let rotated_key = endpoint_at(9, ORIGIN, 13_009);
|
||||
let ticket = db
|
||||
.begin_candidate_negotiation(rotated_key)
|
||||
.expect("ninth key should reach authenticated commit admission");
|
||||
let error = db
|
||||
.commit_authenticated_snapshot(rotated_key, ticket, session(9), Some(library(0, None)))
|
||||
.expect_err("a ninth identity at one authenticated endpoint IP must be rejected");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("authenticated peer limit of 8 reached for endpoint IP 192.0.2.10"),
|
||||
"unexpected endpoint IP admission error: {error:#}"
|
||||
);
|
||||
assert!(!db.contains_peer(&rotated_key.peer_id));
|
||||
assert_eq!(
|
||||
db.peer_endpoints().len(),
|
||||
MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP
|
||||
);
|
||||
assert!(
|
||||
admitted
|
||||
.iter()
|
||||
.skip(1)
|
||||
.all(|endpoint| db.peer_endpoint(&endpoint.peer_id) == Some(*endpoint)),
|
||||
"rejecting the rotated key must preserve every existing identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_ip_residency_rejects_before_address_eviction_and_releases_on_removal() {
|
||||
const CROWDED_ORIGIN: [u8; 4] = [192, 0, 2, 20];
|
||||
const OTHER_ORIGIN: [u8; 4] = [192, 0, 2, 21];
|
||||
let mut db = PeerGameDB::new();
|
||||
let crowded = (1..=u8::try_from(MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP)
|
||||
.expect("endpoint IP limit fits in a test seed"))
|
||||
.map(|seed| endpoint_at(seed, CROWDED_ORIGIN, 14_000 + u16::from(seed)))
|
||||
.collect::<Vec<_>>();
|
||||
for endpoint in &crowded {
|
||||
let seed = endpoint.peer_id.as_bytes()[0];
|
||||
commit(&mut db, *endpoint, session(seed), library(0, None), Some(0));
|
||||
}
|
||||
|
||||
let address_owner = crowded[0];
|
||||
let colliding_key = PeerEndpoint::new(peer_id(9), address_owner.addr);
|
||||
let ticket = db
|
||||
.begin_candidate_negotiation(colliding_key)
|
||||
.expect("colliding key should reach authenticated commit admission");
|
||||
assert!(
|
||||
db.commit_authenticated_snapshot(
|
||||
colliding_key,
|
||||
ticket,
|
||||
session(9),
|
||||
Some(library(0, None)),
|
||||
)
|
||||
.is_err(),
|
||||
"the endpoint IP bound must run before same-address eviction"
|
||||
);
|
||||
assert_eq!(
|
||||
db.peer_endpoint(&address_owner.peer_id),
|
||||
Some(address_owner),
|
||||
"the ninth identity must not evict the authenticated address owner"
|
||||
);
|
||||
|
||||
let other_origin = endpoint_at(9, OTHER_ORIGIN, 14_009);
|
||||
commit(&mut db, other_origin, session(9), library(0, None), Some(0));
|
||||
assert_eq!(db.peer_endpoint(&other_origin.peer_id), Some(other_origin));
|
||||
|
||||
let removed = crowded[1];
|
||||
assert!(db.remove_peer(&removed.peer_id).is_some());
|
||||
let replacement = endpoint_at(10, CROWDED_ORIGIN, 14_010);
|
||||
commit(&mut db, replacement, session(10), library(0, None), Some(0));
|
||||
assert_eq!(db.peer_endpoint(&replacement.peer_id), Some(replacement));
|
||||
assert_eq!(
|
||||
db.peer_endpoints()
|
||||
.into_iter()
|
||||
.filter(|endpoint| endpoint.addr.ip() == address_owner.addr.ip())
|
||||
.count(),
|
||||
MAX_AUTHENTICATED_PEERS_PER_ENDPOINT_IP
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_remote_library_budget_drops_only_the_oversized_projection() {
|
||||
let mut db = PeerGameDB::new();
|
||||
|
||||
Reference in New Issue
Block a user