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:
ddidderr committed 2026-09-12 12:41:20 +02:00
1 parent 06cd8b93ed
commit 145610c0a1
2 files changed
+128 -2

No files matched your search

+122 -1
View File
@@ -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();