fix(security): address Codex Tier C audit — F-01 regression + cheap closes
Follow-up to 5c11947 after Codex's adversarial audit
(tier-c-audit-2026-06-23.md). Fixes a regression the roster cap introduced
and closes F-01's two cheap unbounded-growth vectors. No wire/protocol
change, no new deps.
- Regression (cap × reconnect): a peer reconnecting from a transient drop
sits in `disconnected_peers`, not the live roster, so the new cap could
reject it as "new" at a full 32-peer roster — and the eager
`disconnected_peers.remove()` (before the cap check) then orphaned its
recovery state so a later signed Leave skipped cleanup. Now reconnecting
(and existing) peers are exempt from the cap via the pure
`announce_subject_to_cap`, and the disconnect marker is cleared only after
admission. PeerJoined semantics for reconnects are preserved.
- F-01 replay map: `state_mutations_seen` was uncapped, so signed Leaves
from unlimited generated keys grew it for the room's lifetime. Prune
entries older than the freshness window once past a soft cap
(`prune_stale_mutations`) — stale entries can't gate an in-window message
(verify_gossip rejects the replay first), so replay protection is intact;
the map is now bounded to ~authors-seen-per-window.
- F-01 address lookup: a signed Leave now calls `remove_endpoint_info`, so
cycling identities through Announce→Leave can't grow the iroh lookup
without bound. Re-announce re-populates it.
- F-03 test: added a forced same-hash/different-bytes ByteLru test (via a
hash-injectable inner seam) so collision-safety is regression-tested, not
just code-reviewed.
Deferred follow-ups from the audit (logged): recovery/known_peers identity
cap (needs a design pass, touches reconnect-resilience), F-02 result-cache
LRU, and the (author,id)-vs-id attachment aliasing integrity bug.
416 lib tests (+3), clippy --all-targets clean, release build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+21
-1
@@ -217,8 +217,13 @@ impl<V: Clone> ByteLru<V> {
|
|||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
bytes.hash(&mut hasher);
|
bytes.hash(&mut hasher);
|
||||||
let hash = hasher.finish();
|
self.get_or_insert_hashed(hasher.finish(), bytes, build)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner seam with the content `hash` supplied explicitly. Production callers
|
||||||
|
/// use [`get_or_insert`]; tests use this to force a hash collision (different
|
||||||
|
/// bytes, same hash) and exercise the byte-equality guard.
|
||||||
|
fn get_or_insert_hashed(&mut self, hash: u64, bytes: &[u8], build: impl FnOnce() -> V) -> V {
|
||||||
if let Some(idx) = self
|
if let Some(idx) = self
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -288,6 +293,21 @@ mod tests {
|
|||||||
assert_eq!(ins(&mut lru, b"b"), 4);
|
assert_eq!(ins(&mut lru, b"b"), 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn byte_lru_byte_equality_survives_a_hash_collision() {
|
||||||
|
// Force the SAME 64-bit hash for two DIFFERENT byte strings (the case a
|
||||||
|
// bare-hash cache would alias — Tier C F-03 collision bug).
|
||||||
|
let mut lru: ByteLru<u32> = ByteLru::new(4);
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 1), 1);
|
||||||
|
// `bob` collides on the hash but differs in bytes → a MISS, built fresh,
|
||||||
|
// NOT aliased to alice's value.
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 2), 2);
|
||||||
|
// Both coexist; each re-lookup returns its own value (build closure unused).
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 99), 1);
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 99), 2);
|
||||||
|
assert_eq!(lru.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn initials_takes_first_two_words() {
|
fn initials_takes_first_two_words() {
|
||||||
assert_eq!(initials("Alice"), "A");
|
assert_eq!(initials("Alice"), "A");
|
||||||
|
|||||||
+102
-8
@@ -131,6 +131,27 @@ fn admit_state_mutation(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Size at which we prune stale entries from the replay-tracking map (Tier C
|
||||||
|
/// F-01 audit). `admit_state_mutation` records `(author, kind)` for every signed
|
||||||
|
/// mutation, so an insider sending validly signed `Leave`s from unlimited
|
||||||
|
/// generated keys would otherwise grow it for the room's lifetime. A mutation
|
||||||
|
/// older than the freshness window can never be the deciding `last_ts` for an
|
||||||
|
/// in-window message — `verify_gossip`'s timestamp check rejects such a replay
|
||||||
|
/// first — so dropping those entries cannot weaken replay protection; it bounds
|
||||||
|
/// the map to roughly the authors seen within one freshness window.
|
||||||
|
const STATE_MUTATIONS_SOFT_CAP: usize = 256;
|
||||||
|
|
||||||
|
/// Drop replay-tracking entries whose timestamp is older than `window_ms` before
|
||||||
|
/// `now_ms` (see [`STATE_MUTATIONS_SOFT_CAP`]). Pure → unit-testable.
|
||||||
|
fn prune_stale_mutations(
|
||||||
|
seen: &mut HashMap<(EndpointId, StateMutationKind), u64>,
|
||||||
|
now_ms: u64,
|
||||||
|
window_ms: u64,
|
||||||
|
) {
|
||||||
|
let floor = now_ms.saturating_sub(window_ms);
|
||||||
|
seen.retain(|_, last_ts| *last_ts >= floor);
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximum number of distinct peers we hold in a room roster at once.
|
/// Maximum number of distinct peers we hold in a room roster at once.
|
||||||
///
|
///
|
||||||
/// Everyone with the room ticket is an authenticated *insider*: a signature only
|
/// Everyone with the room ticket is an authenticated *insider*: a signature only
|
||||||
@@ -174,12 +195,23 @@ fn sanitize_endpoint_addr(addr: &EndpointAddr) -> EndpointAddr {
|
|||||||
EndpointAddr { id: addr.id, addrs }
|
EndpointAddr { id: addr.id, addrs }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an `Announce` may enter the roster. A NEW author is admitted only
|
/// Whether an `Announce` may enter the roster. Only a brand-new author
|
||||||
/// while the roster is below [`MAX_ACTIVE_PEERS`]; updates to an already-present
|
/// (`subject_to_cap`) is gated by [`MAX_ACTIVE_PEERS`]; updates to an
|
||||||
/// peer always pass (so a full room's members can keep changing mute/avatar/etc).
|
/// already-present peer AND re-announces from a peer mid-reconnect (which
|
||||||
|
/// already held a slot) always pass — exempting reconnects keeps a full room
|
||||||
|
/// from rejecting a legitimately reconnecting member and orphaning its recovery
|
||||||
|
/// state (Tier C F-01 audit). Pure → unit-testable.
|
||||||
|
fn admit_into_roster(roster_len: usize, subject_to_cap: bool, max_peers: usize) -> bool {
|
||||||
|
!subject_to_cap || roster_len < max_peers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a received `Announce`'s author is gated by the roster cap. A peer
|
||||||
|
/// already in the roster (`is_new == false`, an ordinary update) or one
|
||||||
|
/// mid-reconnect (`is_reconnecting`, it already held a slot) is exempt; only a
|
||||||
|
/// brand-new author counts against [`MAX_ACTIVE_PEERS`] (Tier C F-01 audit).
|
||||||
/// Pure → unit-testable.
|
/// Pure → unit-testable.
|
||||||
fn admit_into_roster(roster_len: usize, is_new: bool, max_peers: usize) -> bool {
|
fn announce_subject_to_cap(is_new: bool, is_reconnecting: bool) -> bool {
|
||||||
!is_new || roster_len < max_peers
|
is_new && !is_reconnecting
|
||||||
}
|
}
|
||||||
|
|
||||||
fn peer_state_for_log(state: &PeerState) -> String {
|
fn peer_state_for_log(state: &PeerState) -> String {
|
||||||
@@ -441,6 +473,18 @@ impl RoomState for IrohGossipState {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the replay-tracking map bounded: prune entries
|
||||||
|
// older than the freshness window once it grows past the
|
||||||
|
// soft cap (Tier C F-01 audit). Stale entries can't gate
|
||||||
|
// an in-window message, so this never weakens replay
|
||||||
|
// protection.
|
||||||
|
if state_mutations_seen.len() > STATE_MUTATIONS_SOFT_CAP {
|
||||||
|
prune_stale_mutations(
|
||||||
|
&mut state_mutations_seen,
|
||||||
|
now_millis(),
|
||||||
|
GOSSIP_FRESHNESS_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
if !admit_state_mutation(
|
if !admit_state_mutation(
|
||||||
&mut state_mutations_seen,
|
&mut state_mutations_seen,
|
||||||
payload.author,
|
payload.author,
|
||||||
@@ -491,15 +535,25 @@ impl RoomState for IrohGossipState {
|
|||||||
// before we retain it / hand it to the dialer
|
// before we retain it / hand it to the dialer
|
||||||
// (Tier C F-01).
|
// (Tier C F-01).
|
||||||
state.addr = sanitize_endpoint_addr(&state.addr);
|
state.addr = sanitize_endpoint_addr(&state.addr);
|
||||||
disconnected_peers.lock().unwrap().remove(&payload.author);
|
// A peer reconnecting from a transient drop sits
|
||||||
|
// in `disconnected_peers` (not the live roster);
|
||||||
|
// it already held a slot, so it must be re-admitted
|
||||||
|
// regardless of the cap, and its disconnect marker
|
||||||
|
// cleared ONLY once re-admitted — clearing it before
|
||||||
|
// a possible reject would orphan its recovery state
|
||||||
|
// (Tier C F-01 audit).
|
||||||
|
let is_reconnecting =
|
||||||
|
disconnected_peers.lock().unwrap().contains(&payload.author);
|
||||||
let admitted = {
|
let admitted = {
|
||||||
let mut peer_map = peers.lock().unwrap();
|
let mut peer_map = peers.lock().unwrap();
|
||||||
let is_new = !peer_map.contains_key(&payload.author);
|
let is_new = !peer_map.contains_key(&payload.author);
|
||||||
// Cap the roster so a flood of signed
|
// Cap the roster so a flood of signed
|
||||||
// sock-puppet identities can't grow our
|
// sock-puppet identities can't grow our
|
||||||
// memory/tasks/dials without bound (Tier C
|
// memory/tasks/dials without bound (Tier C
|
||||||
// F-01). Existing peers' updates always pass.
|
// F-01). Existing-peer updates and reconnects
|
||||||
if !admit_into_roster(peer_map.len(), is_new, MAX_ACTIVE_PEERS) {
|
// are exempt; only brand-new authors are gated.
|
||||||
|
let subject_to_cap = announce_subject_to_cap(is_new, is_reconnecting);
|
||||||
|
if !admit_into_roster(peer_map.len(), subject_to_cap, MAX_ACTIVE_PEERS) {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let state_changed = peer_map.get(&payload.author) != Some(&state);
|
let state_changed = peer_map.get(&payload.author) != Some(&state);
|
||||||
@@ -516,6 +570,10 @@ impl RoomState for IrohGossipState {
|
|||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// Admitted — now it is safe to clear any reconnect
|
||||||
|
// marker (a rejected announce above leaves it intact
|
||||||
|
// so a later signed Leave still cleans up).
|
||||||
|
disconnected_peers.lock().unwrap().remove(&payload.author);
|
||||||
|
|
||||||
if is_new {
|
if is_new {
|
||||||
crate::log_msg(&format!(
|
crate::log_msg(&format!(
|
||||||
@@ -541,6 +599,11 @@ impl RoomState for IrohGossipState {
|
|||||||
}
|
}
|
||||||
GossipMessage::Leave => {
|
GossipMessage::Leave => {
|
||||||
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
||||||
|
// Drop this id's address-lookup entry so cycling
|
||||||
|
// distinct identities through Announce→Leave can't
|
||||||
|
// grow the lookup for the room's lifetime (Tier C
|
||||||
|
// F-01 audit). Re-announce re-populates it.
|
||||||
|
let _ = address_lookup.remove_endpoint_info(payload.author);
|
||||||
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
||||||
let was_disconnected = disconnected_peers
|
let was_disconnected = disconnected_peers
|
||||||
.lock()
|
.lock()
|
||||||
@@ -856,6 +919,37 @@ mod tests {
|
|||||||
assert!(admit_into_roster(99, false, 3));
|
assert!(admit_into_roster(99, false, 3));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconnecting_and_existing_peers_are_exempt_from_the_cap() {
|
||||||
|
// A brand-new author counts against the cap...
|
||||||
|
assert!(announce_subject_to_cap(/* is_new */ true, /* is_reconnecting */ false));
|
||||||
|
// ...but an ordinary update from an in-roster peer does not...
|
||||||
|
assert!(!announce_subject_to_cap(false, false));
|
||||||
|
// ...and neither does a re-announce from a peer mid-reconnect, even
|
||||||
|
// though it was removed from the live roster (the F-01-audit fix: a full
|
||||||
|
// room must not reject a legitimately reconnecting member).
|
||||||
|
assert!(!announce_subject_to_cap(true, true));
|
||||||
|
// Combined with admit_into_roster: a reconnecting author passes at a full
|
||||||
|
// roster, a brand-new one does not.
|
||||||
|
assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3));
|
||||||
|
assert!(!admit_into_roster(3, announce_subject_to_cap(true, false), 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_stale_mutations_drops_only_out_of_window_entries() {
|
||||||
|
let a = fresh_id();
|
||||||
|
let b = fresh_id();
|
||||||
|
let mut seen = HashMap::new();
|
||||||
|
seen.insert((a, StateMutationKind::Announce), 10_000u64);
|
||||||
|
seen.insert((b, StateMutationKind::Leave), 250_000u64);
|
||||||
|
// now = 300_000, window = 120_000 → floor 180_000. The 10_000 entry is
|
||||||
|
// stale (and could never gate an in-window message), the 250_000 is live.
|
||||||
|
prune_stale_mutations(&mut seen, 300_000, GOSSIP_FRESHNESS_MS);
|
||||||
|
assert_eq!(seen.len(), 1);
|
||||||
|
assert!(seen.contains_key(&(b, StateMutationKind::Leave)));
|
||||||
|
assert!(!seen.contains_key(&(a, StateMutationKind::Announce)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_endpoint_addr_caps_address_count() {
|
fn sanitize_endpoint_addr_caps_address_count() {
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
|||||||
Reference in New Issue
Block a user