Compare commits

..
4 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 1a3c481f4c fix(security): cap recovery-identity state (Tier C F-01 follow-up)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Closes the remaining insider resource-exhaustion vector the Codex Tier C
audit flagged: the active-roster cap bounds the live peer map, but a member
could join (<=32), drop the link without a signed Leave, let the grace timer
expire, and repeat with a fresh identity. Each abandoned identity grew two
unbounded structures and kept doing periodic work forever:

  - known_peers[topic] (the retained rejoin/recovery dial table) was only
    pruned on a signed PeerLeft, so grace-evicted ghosts accumulated.
  - the recovery coordinator's active set + entries map had no identity cap
    and no terminal retry budget — backoff saturated at 60s and re-dialed a
    never-returning peer indefinitely.

Two non-breaking, dependency-free bounds (no wire/protocol change):

  - MAX_RETAINED_PEERS=64 per topic via pure admit_retained() — refreshing a
    tracked peer always succeeds, a brand-new identity is rejected when full.
    Set above MAX_ACTIVE_PEERS=32 so legitimate rooms never hit it.
  - RECOVERY_TERMINAL_ATTEMPTS=12 (~7 min) via pure recovery_is_terminal():
    the coordinator gives up, frees the active slot, and signals a new
    terminal channel; a small drain task forgets the retained address (so the
    table self-drains), scrubs seen-connected state, and emits
    PeerConnectionFailed.

Giving up never blocks a legitimate reconnect: a peer returning after a long
outage still rejoins on its own via a gossip announce — terminal eviction only
stops us from dialing a peer that is not coming back, which was a latent leak
even absent an attacker.

+2 pure-seam unit tests (admit_retained, recovery_is_terminal); 418 lib tests
green, clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:20:45 -04:00
molluskandClaude Opus 4.8 f927567105 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>
2026-06-23 15:51:41 -04:00
molluskandClaude Opus 4.8 5c11947bd7 fix(security): Tier C F-01/F-02/F-03 insider resource-exhaustion caps
A room ticket holder is an authenticated insider; signatures only prove
keypair ownership, not a distinct human. Previously such a member could
exhaust a victim's memory/tasks/dials without bound. Add caps + dedup at
the gossip/core/UI boundaries (no wire/protocol change, no new deps):

F-01 (gossip): cap the roster at MAX_ACTIVE_PEERS (32) — new authors are
rejected when full, existing peers' updates always pass; sanitize each
announced EndpointAddr (<=8 addrs, relay-URL <=256 bytes, drop Custom);
replace (set_endpoint_info) instead of unioning attacker address history.

F-02 (core): gate chat image auto-fetch — only roster authors qualify,
(author, attachment_id) is deduped, and a 4-permit pool bounds concurrent
detached fetch tasks (RAII AutoFetchGuard releases permit + dedup marker).
Chat text is still shown (already sanitized); the user-initiated "Save"
fetch is unchanged. Non-roster sock-puppet chat can no longer spawn tasks.

F-03 (app): replace the unbounded AVATAR_HANDLE_CACHE map with a bounded,
byte-equality-keyed LRU (avatar::ByteLru, cap 64) — fixes both unbounded
growth from an endless stream of distinct valid avatars and the prior
64-bit-hash-collision-shows-wrong-avatar bug.

Pure seams (sanitize_endpoint_addr, admit_into_roster, should_auto_fetch,
ByteLru) + 6 adversarial/unit tests. 413 lib tests, clippy --all-targets
clean, release build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:10:08 -04:00
molluskandClaude Opus 4.8 7349744d16 chore(packaging): bump Windows installer to 0.4.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The 0.4.0 Windows binary was cross-built (x86_64-pc-windows-gnu,
build-std static) and verified in the libvirt Windows 11 VM: the raw
exe launches/renders the full v0.4.0 UI and runs stably, and the
compiled installer was test-installed end-to-end (Program Files exe
sha256 1a211eb6…, Start-menu shortcut, firewall rule, launch from
the installed location) before publishing to the v0.4.0 release.

- peerspeak.iss: MyAppVersion 0.3.0 -> 0.4.0 (installer output is
  peerspeak-0.4.0-setup.exe)
- INSTALL.md / README.md: update the 0.3.0 filename/version references

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:45:49 -04:00
8 changed files with 642 additions and 63 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
## 1. Install it
1. Double-click **`peerspeak-0.3.0-setup.exe`** (the file I sent you).
1. Double-click **`peerspeak-0.4.0-setup.exe`** (the file I sent you).
2. **Windows will probably show a blue "Windows protected your PC" warning.**
This is normal — it shows up for any app that isn't from a big company with a
+1 -1
View File
@@ -12,7 +12,7 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
## Version compatibility
The installer version tracks the crate version in `Cargo.toml` (currently
**0.3.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
**0.4.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
peers on different MINOR versions can't connect (they fail fast at the
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.3.0"
#define MyAppVersion "0.4.0"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+19 -14
View File
@@ -5526,30 +5526,35 @@ fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage>
.into()
}
/// Maximum distinct avatar images we keep handles for. Each avatar is bounded to
/// 48 KiB / 256×256 at ingest, so a 64-entry LRU caps this cache at a few MB
/// regardless of how many distinct avatars peers publish over time (Tier C F-03).
const AVATAR_CACHE_CAP: usize = 64;
thread_local! {
/// Cache of avatar image handles, keyed by a hash of the PNG bytes, so the
/// SAME `image::Handle` (and thus the same GPU texture id) is reused across
/// Bounded cache of avatar image handles, keyed by PNG content, so the SAME
/// `image::Handle` (and thus the same GPU texture id) is reused across
/// redraws. `image::Handle::from_bytes` mints a fresh *unique* id on every
/// call, so building handles inline in `view()` made iced re-upload the
/// texture on every repaint — including the redraws fired on each mouse move —
/// which showed up as constant flicker. Lives on the (single) UI thread.
static AVATAR_HANDLE_CACHE: std::cell::RefCell<HashMap<u64, iced::widget::image::Handle>> =
std::cell::RefCell::new(HashMap::new());
/// which showed up as constant flicker. A peer can publish an unbounded stream
/// of distinct valid avatars over a session, so the cache is an LRU (bounded +
/// byte-equality keyed) rather than a plain map (Tier C F-03). Lives on the
/// (single) UI thread.
static AVATAR_HANDLE_CACHE:
std::cell::RefCell<crate::avatar::ByteLru<iced::widget::image::Handle>> =
std::cell::RefCell::new(crate::avatar::ByteLru::new(AVATAR_CACHE_CAP));
}
/// A stable image handle for these exact PNG bytes (cached by content hash), so
/// it keeps the same id across redraws — see [`AVATAR_HANDLE_CACHE`].
/// A stable image handle for these exact PNG bytes (cached by content), so it
/// keeps the same id across redraws — see [`AVATAR_HANDLE_CACHE`].
fn cached_image_handle(bytes: bytes::Bytes) -> iced::widget::image::Handle {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.as_ref().hash(&mut hasher);
let key = hasher.finish();
AVATAR_HANDLE_CACHE.with(|cache| {
cache
.borrow_mut()
.entry(key)
.or_insert_with(|| iced::widget::image::Handle::from_bytes(bytes))
.clone()
.get_or_insert(bytes.as_ref(), || {
iced::widget::image::Handle::from_bytes(bytes.clone())
})
})
}
+119
View File
@@ -185,10 +185,129 @@ pub fn initials(name: &str) -> String {
}
}
/// A small content-addressed LRU cache mapping image bytes to a built value
/// (e.g. an `iced` image handle), so the SAME value is reused across redraws
/// instead of rebuilt every frame. Two Tier C F-03 properties beyond a plain
/// hash map:
///
/// 1. **Bounded** — at most `cap` entries, evicting the least-recently-used on
/// overflow, so a peer can't grow the cache without limit by publishing an
/// endless stream of distinct valid avatars.
/// 2. **Collision-safe** — a hit requires full byte equality, not just a matching
/// 64-bit hash, so a hash collision can never return a different image's value.
///
/// Linear scan; intended for small `cap` (tens of entries).
pub struct ByteLru<V> {
cap: usize,
/// `(content hash, content bytes, value)`; back = most recently used.
entries: Vec<(u64, Vec<u8>, V)>,
}
impl<V: Clone> ByteLru<V> {
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
pub fn new(cap: usize) -> Self {
Self { cap: cap.max(1), entries: Vec::new() }
}
/// Return the cached value for these exact `bytes`, building and inserting it
/// on a miss (evicting the least-recently-used entry once over `cap`). A hit
/// verifies full byte equality, so a 64-bit hash collision never returns the
/// wrong value. A hit also refreshes the entry's recency.
pub fn get_or_insert(&mut self, bytes: &[u8], build: impl FnOnce() -> V) -> V {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
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
.entries
.iter()
.position(|(h, b, _)| *h == hash && b.as_slice() == bytes)
{
// LRU touch: move the hit entry to the back (most recent).
let entry = self.entries.remove(idx);
let val = entry.2.clone();
self.entries.push(entry);
return val;
}
let val = build();
if self.entries.len() >= self.cap {
self.entries.remove(0); // evict least-recently-used
}
self.entries.push((hash, bytes.to_vec(), val.clone()));
val
}
#[cfg(test)]
fn len(&self) -> usize {
self.entries.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byte_lru_reuses_value_for_identical_bytes() {
let mut lru: ByteLru<u32> = ByteLru::new(4);
let mut next = 0u32;
let mut build = |lru: &mut ByteLru<u32>, b: &[u8]| {
lru.get_or_insert(b, || {
next += 1;
next
})
};
// Same bytes → same value, built only once.
assert_eq!(build(&mut lru, b"alice"), 1);
assert_eq!(build(&mut lru, b"alice"), 1);
// Different bytes → a freshly built value.
assert_eq!(build(&mut lru, b"bob"), 2);
assert_eq!(lru.len(), 2);
}
#[test]
fn byte_lru_evicts_least_recently_used() {
let mut lru: ByteLru<u32> = ByteLru::new(2);
let mut n = 0u32;
let mut ins = |lru: &mut ByteLru<u32>, b: &[u8]| {
lru.get_or_insert(b, || {
n += 1;
n
})
};
ins(&mut lru, b"a"); // -> 1
ins(&mut lru, b"b"); // -> 2, cache = [a, b]
ins(&mut lru, b"a"); // touch a, cache = [b, a]
ins(&mut lru, b"c"); // evicts LRU (b), cache = [a, c]
assert_eq!(lru.len(), 2);
// `a` survived (recently touched) → still value 1, not rebuilt.
assert_eq!(ins(&mut lru, b"a"), 1);
// `b` was evicted → rebuilt with a new value.
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]
fn initials_takes_first_two_words() {
assert_eq!(initials("Alice"), "A");
+208 -29
View File
@@ -134,6 +134,25 @@ type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
type KnownPeers =
Arc<std::sync::Mutex<HashMap<[u8; 32], HashMap<EndpointId, EndpointAddr>>>>;
/// Per-topic cap on the retained rejoin-bootstrap / recovery target table
/// (Tier C recovery-identity cap). Set comfortably above the live-roster cap
/// (`gossip::MAX_ACTIVE_PEERS`, 32) so a legitimate room — even one where every
/// member drops at once during a relay outage — never hits it, while an insider
/// who grace-cycles distinct identities (join, drop without a signed Leave,
/// repeat) cannot grow the table without bound. Combined with the recovery
/// terminal budget (which forgets a retained address when it gives up), abandoned
/// identities drain on their own, so this cap is a deterministic ceiling rather
/// than a pinnable slot pool.
const MAX_RETAINED_PEERS: usize = 64;
/// Whether a peer may be inserted into a retained-target table at `len` entries.
/// An update to an id already present is always allowed (it only refreshes an
/// address); a brand-new id is admitted only while below the cap. Mirrors the
/// gossip roster's `admit_into_roster` reject-when-full admission.
fn admit_retained(len: usize, is_new_id: bool, cap: usize) -> bool {
!is_new_id || len < cap
}
#[derive(Clone)]
struct RecoveryContext {
coordinator: RecoveryCoordinator,
@@ -522,6 +541,7 @@ struct ActiveSession {
event_task: tokio::task::JoinHandle<()>,
conn_event_task: tokio::task::JoinHandle<()>,
recovery_task: tokio::task::JoinHandle<()>,
recovery_terminal_task: tokio::task::JoinHandle<()>,
grace_timers: GraceTimers,
transport: Arc<IrohTransport>,
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
@@ -557,6 +577,7 @@ impl ActiveSession {
handle.abort();
}
self.recovery_task.abort();
self.recovery_terminal_task.abort();
crate::log_msg("Aborted tasks");
let audio_backend_clone = audio_backend.clone();
@@ -744,20 +765,63 @@ async fn build_net_stack(
})
}
/// Maximum number of *automatic* chat-attachment fetches in flight at once.
///
/// Auto-fetch (inline image preview) is triggered by an untrusted peer's chat
/// message, and each fetch is a detached task that can spend up to ~60s dialing
/// and reading. Without a bound, a room insider could spam attachment-carrying
/// chat to accumulate arbitrary pending tasks/dials (Tier C F-02). When the bound
/// is reached we simply skip the auto-fetch; the descriptor still renders and the
/// user can fetch it on demand (which is not rate-limited here).
const MAX_INFLIGHT_ATTACHMENT_FETCHES: usize = 4;
/// In-flight `(author, attachment_id)` markers for bounded, deduplicated auto-
/// fetches (Tier C F-02). Bounded by [`MAX_INFLIGHT_ATTACHMENT_FETCHES`].
type InflightAttachments =
Arc<std::sync::Mutex<HashSet<(EndpointId, crate::files::AttachmentId)>>>;
/// RAII bookkeeping for one bounded auto-fetch: holds the concurrency permit for
/// the task's lifetime and clears the in-flight `(author, id)` marker when the
/// fetch finishes (success OR failure), so the same image can be retried later.
struct AutoFetchGuard {
_permit: tokio::sync::OwnedSemaphorePermit,
inflight: InflightAttachments,
key: (EndpointId, crate::files::AttachmentId),
}
impl Drop for AutoFetchGuard {
fn drop(&mut self) {
self.inflight.lock().unwrap().remove(&self.key);
}
}
/// Whether to AUTO-fetch a chat image attachment. Only authenticated roster
/// authors qualify (closing the non-roster injection vector), and a `(author,
/// id)` already being fetched is skipped (dedup). The concurrency bound itself is
/// enforced separately by the permit. Pure → unit-testable (Tier C F-02).
fn should_auto_fetch(is_image: bool, author_in_roster: bool, already_inflight: bool) -> bool {
is_image && author_in_roster && !already_inflight
}
/// Fetch a chat attachment's bytes from `from` over the file plane in a detached
/// task, then report the result to the UI via [`UiEvent::AttachmentReady`] /
/// [`UiEvent::AttachmentFailed`] keyed by the attachment id. For images
/// (`is_image`) the bytes are defensively re-validated (decodable + within pixel
/// limits) before being handed to the renderer; an "Image" that doesn't decode is
/// reported as a failure rather than rendered.
/// reported as a failure rather than rendered. `guard` is `Some` for bounded
/// auto-fetches and `None` for user-initiated fetches; it is dropped when the
/// task ends, releasing the concurrency permit and the dedup marker.
fn spawn_attachment_fetch(
transport: Arc<IrohTransport>,
ui_tx: mpsc::Sender<UiEvent>,
from: EndpointId,
att: crate::files::ChatAttachment,
is_image: bool,
guard: Option<AutoFetchGuard>,
) {
tokio::spawn(async move {
// Held for the whole fetch; dropped here on completion (Tier C F-02).
let _guard = guard;
match transport.fetch_attachment(from, &att).await {
Ok(data) => {
if is_image && crate::files::validate_image_bytes(&data).is_none() {
@@ -1799,7 +1863,7 @@ async fn run_core_loop(
// The topic of the room this event loop serves, so peer add/remove
// updates the right per-topic bucket in `known_peers` (A8 archive).
let room_topic = topic_id;
let (recovery_coordinator, recovery_task) =
let (recovery_coordinator, recovery_task, recovery_terminal_rx) =
RecoveryCoordinator::spawn(room_state.clone());
let recovery_context = RecoveryContext {
coordinator: recovery_coordinator,
@@ -1808,17 +1872,51 @@ async fn run_core_loop(
topic_id,
};
let recovery_events = recovery_context.clone();
// Drain the recovery coordinator's terminal-eviction signals (Tier C
// recovery-identity cap). When background recovery exhausts its budget
// for a peer, forget its retained dial target so the per-topic retain
// table drains, scrub residual seen-connected state, and surface the
// failure. A peer that later returns can still rejoin via a gossip
// announce, so giving up never blocks a legitimate reconnect.
let recovery_terminal_ctx = recovery_context.clone();
let seen_connected_terminal = seen_connected.clone();
let ui_tx_terminal = ui_tx.clone();
let recovery_terminal_task = tokio::spawn(async move {
let mut terminal_rx = recovery_terminal_rx;
while let Some(peer_id) = terminal_rx.recv().await {
crate::log_msg(&format!(
"Background recovery gave up on peer {peer_id:?}; forgetting retained target"
));
recovery_terminal_ctx.forget(peer_id);
seen_connected_terminal.lock().unwrap().remove(&peer_id);
let _ = ui_tx_terminal
.send(UiEvent::PeerConnectionFailed { id: peer_id })
.await;
}
});
// Friends store + ui sender, so a connected peer who is a friend has
// their saved address auto-healed (W7) — populates `last_addr` so the
// presence scheduler can reach them later.
let friends_events = friends.clone();
let friends_read_only_events = friends_read_only;
// Bounded, deduplicated auto-fetch of chat image attachments (Tier C
// F-02): the permit pool caps concurrent fetch tasks; the in-flight
// set dedups identical (author, id) pairs.
let attachment_limiter =
Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_ATTACHMENT_FETCHES));
let inflight_attachments: InflightAttachments =
Arc::new(std::sync::Mutex::new(HashSet::new()));
let event_task = tokio::spawn(async move {
// The authenticated roster for this room, maintained from the
// same sequential event stream. Only its members may trigger an
// automatic attachment fetch (Tier C F-02).
let mut roster: HashSet<EndpointId> = HashSet::new();
while let Some(event) = room_events.recv().await {
match event {
RoomEvent::PeerJoined(peer_id, state) => {
// A (re)join means the peer is back — cancel any
// pending reconnect grace timer before re-adding it.
roster.insert(peer_id);
cancel_grace_timer(&grace_timers_events, &peer_id);
recovery_events.cancel(peer_id);
transport_events.admit_audio_sender(peer_id);
@@ -1843,13 +1941,22 @@ async fn run_core_loop(
.await;
}
// Retain this peer under this room's topic as a
// future rejoin bootstrap target (A8).
known_peers_events
.lock()
.unwrap()
.entry(room_topic)
.or_default()
.insert(peer_id, state.addr.clone());
// future rejoin bootstrap target (A8), bounded by the
// per-topic retain cap (Tier C recovery-identity cap):
// refreshing a peer we already track is always allowed,
// a brand-new identity only while below the cap.
{
let mut kp = known_peers_events.lock().unwrap();
let bucket = kp.entry(room_topic).or_default();
let is_new_id = !bucket.contains_key(&peer_id);
if admit_retained(bucket.len(), is_new_id, MAX_RETAINED_PEERS) {
bucket.insert(peer_id, state.addr.clone());
} else {
crate::log_msg(&format!(
"Retain table full ({MAX_RETAINED_PEERS}); not retaining new peer {peer_id:?} for rejoin"
));
}
}
// If a multitrack recording is live, give this peer
// its own stem track (silence-padded back to t=0).
if is_multitrack_events.load(Ordering::Relaxed)
@@ -1862,6 +1969,7 @@ async fn run_core_loop(
}
RoomEvent::PeerLeft(peer_id) => {
// Graceful leave — evict immediately.
roster.remove(&peer_id);
cancel_grace_timer(&grace_timers_events, &peer_id);
seen_connected_events.lock().unwrap().remove(&peer_id);
// A signed Leave cancels background recovery and
@@ -1899,13 +2007,21 @@ async fn run_core_loop(
.await;
}
// Refresh this room's retained rejoin target with the
// fresh addr (A8).
known_peers_events
.lock()
.unwrap()
.entry(room_topic)
.or_default()
.insert(peer_id, state.addr.clone());
// fresh addr (A8), under the per-topic retain cap. A
// re-announce from a peer we already track always
// refreshes; a new identity is bounded by the cap.
{
let mut kp = known_peers_events.lock().unwrap();
let bucket = kp.entry(room_topic).or_default();
let is_new_id = !bucket.contains_key(&peer_id);
if admit_retained(bucket.len(), is_new_id, MAX_RETAINED_PEERS) {
bucket.insert(peer_id, state.addr.clone());
} else {
crate::log_msg(&format!(
"Retain table full ({MAX_RETAINED_PEERS}); not retaining new peer {peer_id:?} for rejoin"
));
}
}
let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await;
}
RoomEvent::ChatMessage { from, name, text, ts: _, attachment } => {
@@ -1913,16 +2029,47 @@ async fn run_core_loop(
// without a click; non-image files wait for an explicit
// FetchAttachment (the "Save" chip). The descriptor was
// already filename-sanitized + size-capped on ingest.
if let Some(att) = attachment.clone()
&& att.kind == crate::files::AttachmentKind::Image
{
spawn_attachment_fetch(
transport_events.clone(),
ui_tx_events.clone(),
from,
att,
true,
);
//
// The auto path is an untrusted-peer-triggered detached
// task, so it is gated (Tier C F-02): only roster authors
// qualify, identical (author,id) pairs are deduped, and a
// permit pool caps concurrent fetch tasks. The chat TEXT
// is always forwarded (it's sanitized at the UI edge);
// only the fetch is bounded.
if let Some(att) = attachment.clone() {
let is_image = att.kind == crate::files::AttachmentKind::Image;
let key = (from, att.id);
let already_inflight =
inflight_attachments.lock().unwrap().contains(&key);
if should_auto_fetch(is_image, roster.contains(&from), already_inflight) {
// Reserve the dedup slot, then a permit. If the
// pool is exhausted, drop the auto-fetch (and the
// dedup marker) — the descriptor still shows and
// the user can fetch on demand.
inflight_attachments.lock().unwrap().insert(key);
match attachment_limiter.clone().try_acquire_owned() {
Ok(permit) => {
spawn_attachment_fetch(
transport_events.clone(),
ui_tx_events.clone(),
from,
att,
true,
Some(AutoFetchGuard {
_permit: permit,
inflight: inflight_attachments.clone(),
key,
}),
);
}
Err(_) => {
inflight_attachments.lock().unwrap().remove(&key);
crate::log_msg(
"Chat attachment auto-fetch limit reached; skipping (fetch on demand)",
);
}
}
}
}
let _ = ui_tx_events.send(UiEvent::ChatMessage {
from: from.to_string(),
@@ -1992,6 +2139,7 @@ async fn run_core_loop(
event_task,
conn_event_task,
recovery_task,
recovery_terminal_task,
grace_timers,
transport: transport.clone(),
#[cfg(target_os = "linux")]
@@ -2467,12 +2615,15 @@ async fn run_core_loop(
CoreCommand::FetchAttachment { from, attachment } => {
if let Some(session) = &active_session {
let is_image = attachment.kind == crate::files::AttachmentKind::Image;
// User-initiated (the "Save" chip): not bounded here — a human
// click rate-limits it. The auto path (F-02) passes a guard.
spawn_attachment_fetch(
session.transport.clone(),
ui_tx.clone(),
from,
attachment,
is_image,
None,
);
}
}
@@ -2575,11 +2726,39 @@ async fn run_core_loop(
#[cfg(test)]
mod tests {
use super::{
apply_volume, audio_datagram_len_ok, frame_level, mix_frames, mix_stereo_frames,
next_game_change, stereo_to_mono, KnownPeers, MicLevelMeter, PeerSpeakTicket,
MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
admit_retained, apply_volume, audio_datagram_len_ok, frame_level, mix_frames,
mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono, KnownPeers,
MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS,
MIC_LEVEL_REPORT_SAMPLES,
};
#[test]
fn admit_retained_rejects_only_new_ids_at_the_cap() {
// Below the cap, a brand-new identity is retained.
assert!(admit_retained(0, true, MAX_RETAINED_PEERS));
assert!(admit_retained(MAX_RETAINED_PEERS - 1, true, MAX_RETAINED_PEERS));
// At the cap, a brand-new identity is refused — this is the bound that stops
// an insider grace-cycling distinct identities from growing the retain table.
assert!(!admit_retained(MAX_RETAINED_PEERS, true, MAX_RETAINED_PEERS));
// A peer already tracked always refreshes, even at (or past) the cap: it only
// updates an existing address and never adds a slot.
assert!(admit_retained(MAX_RETAINED_PEERS, false, MAX_RETAINED_PEERS));
assert!(admit_retained(MAX_RETAINED_PEERS + 5, false, MAX_RETAINED_PEERS));
}
#[test]
fn auto_fetch_only_for_roster_images_not_already_inflight() {
// The happy path: a roster author's brand-new image attachment.
assert!(should_auto_fetch(true, true, false));
// A non-image (generic file) never auto-fetches — it waits for "Save".
assert!(!should_auto_fetch(false, true, false));
// A non-roster author (e.g. a sock puppet that never announced) is rejected,
// closing the F-02 unbounded-task vector.
assert!(!should_auto_fetch(true, false, false));
// An identical (author,id) already being fetched is deduped.
assert!(!should_auto_fetch(true, true, true));
}
#[test]
fn retained_peers_are_shared_by_topic_across_restamped_tickets() {
let topic_id = [23u8; 32];
+68 -8
View File
@@ -22,6 +22,27 @@ fn recovery_delay(attempt: usize) -> Duration {
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
}
/// Terminal retry budget for background recovery. After this many failed attempts
/// the coordinator gives up: it drops the entry, frees the active slot, and signals
/// the event task to forget the retained address (Tier C recovery-identity cap).
///
/// With the [`RECOVERY_DELAYS`] backoff this is roughly seven minutes of dialing
/// (1+2+4+8+15+30+60s, then 60s steps), far beyond any normal transient outage. A
/// genuine peer returning after a longer outage still rejoins on its own via a
/// gossip announce, so giving up only stops us from dialing a peer that is not
/// coming back — it does not break legitimate reconnect-after-outage.
const RECOVERY_TERMINAL_ATTEMPTS: usize = 12;
/// Capacity of the terminal-eviction notification channel. Bounded; on the rare
/// event of saturation the entry is still removed (the dial work stops) and only
/// the retained-address forget is skipped, which the per-topic retain cap bounds.
const RECOVERY_TERMINAL_CAPACITY: usize = 64;
/// Whether `attempt` completed recoveries have exhausted the terminal budget.
fn recovery_is_terminal(attempt: usize, max_attempts: usize) -> bool {
attempt >= max_attempts
}
enum RecoveryCommand {
Start {
peer_id: EndpointId,
@@ -60,19 +81,24 @@ pub(super) struct RecoveryCoordinator {
}
impl RecoveryCoordinator {
pub(super) fn spawn(room_state: Arc<IrohGossipState>) -> (Self, JoinHandle<()>) {
pub(super) fn spawn(
room_state: Arc<IrohGossipState>,
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
Self::spawn_inner(room_state)
}
fn spawn_inner(room_state: Arc<dyn RecoveryRoom>) -> (Self, JoinHandle<()>) {
fn spawn_inner(
room_state: Arc<dyn RecoveryRoom>,
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
let (terminal_tx, terminal_rx) = mpsc::channel(RECOVERY_TERMINAL_CAPACITY);
let active = Arc::new(Mutex::new(HashSet::new()));
let handle = Self {
tx,
active: active.clone(),
};
let task = tokio::spawn(run_coordinator(room_state, active, rx));
(handle, task)
let task = tokio::spawn(run_coordinator(room_state, active, rx, terminal_tx));
(handle, task, terminal_rx)
}
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
@@ -116,6 +142,7 @@ async fn run_coordinator(
room_state: Arc<dyn RecoveryRoom>,
active: Arc<Mutex<HashSet<EndpointId>>>,
mut rx: mpsc::Receiver<RecoveryCommand>,
terminal_tx: mpsc::Sender<EndpointId>,
) {
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
@@ -155,9 +182,24 @@ async fn run_coordinator(
entries.remove(&peer_id);
continue;
}
if let Some(entry) = entries.get_mut(&peer_id) {
// Advance the backoff, then check the terminal budget.
// `attempt` counts completed attempts, so the delay
// uses the current value before it is incremented.
let terminal = if let Some(entry) = entries.get_mut(&peer_id) {
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
entry.attempt = entry.attempt.saturating_add(1);
recovery_is_terminal(entry.attempt, RECOVERY_TERMINAL_ATTEMPTS)
} else {
false
};
if terminal {
// Give up on a peer that has not returned within the
// budget: drop its entry, free the active slot, and
// signal the event task to forget its retained
// address so the per-topic retain table drains.
entries.remove(&peer_id);
active.lock().unwrap().remove(&peer_id);
let _ = terminal_tx.try_send(peer_id);
}
}
}
@@ -210,6 +252,23 @@ mod tests {
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
}
#[test]
fn recovery_budget_is_terminal_only_at_or_past_the_cap() {
assert!(!recovery_is_terminal(0, RECOVERY_TERMINAL_ATTEMPTS));
assert!(!recovery_is_terminal(
RECOVERY_TERMINAL_ATTEMPTS - 1,
RECOVERY_TERMINAL_ATTEMPTS
));
assert!(recovery_is_terminal(
RECOVERY_TERMINAL_ATTEMPTS,
RECOVERY_TERMINAL_ATTEMPTS
));
assert!(recovery_is_terminal(
RECOVERY_TERMINAL_ATTEMPTS + 5,
RECOVERY_TERMINAL_ATTEMPTS
));
}
#[test]
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
let (tx, mut rx) = mpsc::channel(4);
@@ -244,9 +303,10 @@ mod tests {
#[tokio::test]
async fn coordinator_attempts_rebootstrap_immediately() {
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
let (coordinator, task) = RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
attempts: attempts_tx,
}));
let (coordinator, task, _terminal_rx) =
RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
attempts: attempts_tx,
}));
let peer_id = SecretKey::generate().public();
let addr = EndpointAddr::from(peer_id);
+225 -9
View File
@@ -1,11 +1,11 @@
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket};
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature};
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr};
use iroh_gossip::net::Gossip;
use iroh_gossip::proto::TopicId;
use tokio::sync::mpsc;
use tokio::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use async_trait::async_trait;
use tokio_stream::StreamExt;
use serde::{Serialize, Deserialize};
@@ -131,6 +131,89 @@ fn admit_state_mutation(
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.
///
/// Everyone with the room ticket is an authenticated *insider*: a signature only
/// proves ownership of the generated keypair it was made with, not that the
/// author is a distinct human. A malicious member can therefore mint many valid
/// signed identities. Voice is full-mesh (each peer dials every other), so a real
/// room is realistically well under this bound; the cap exists purely so a flood
/// of sock-puppet `Announce`s can't grow our peer map / audio supervisors / dials
/// without limit (Tier C F-01).
const MAX_ACTIVE_PEERS: usize = 32;
/// Maximum transport addresses we retain from a single peer announce. iroh
/// normally advertises a handful (a few LAN/WAN IP candidates plus one home
/// relay); the cap stops an insider stuffing a large unique address set into each
/// announce to inflate the address lookup and the dialer's candidate list.
const MAX_PEER_ADDRS: usize = 8;
/// Maximum byte length of a relay URL we accept inside a peer address. A relay
/// URL is normal-length; anything longer is dropped rather than retained.
const MAX_RELAY_URL_LEN: usize = 256;
/// Bound an untrusted peer's advertised address set before we retain it / hand it
/// to the address lookup and dialer (Tier C F-01). Drops transport kinds we never
/// use (`Custom`) and over-long relay URLs, then truncates to at most
/// [`MAX_PEER_ADDRS`] addresses. `BTreeSet` iteration is deterministic, so the
/// kept subset is stable. Pure → unit-testable.
fn sanitize_endpoint_addr(addr: &EndpointAddr) -> EndpointAddr {
let addrs: BTreeSet<TransportAddr> = addr
.addrs
.iter()
.filter(|a| match a {
TransportAddr::Relay(url) => url.as_str().len() <= MAX_RELAY_URL_LEN,
TransportAddr::Ip(_) => true,
// `TransportAddr` is #[non_exhaustive]; we only speak IP + relay, so
// anything else (Custom / future kinds) is dropped, not retained.
_ => false,
})
.take(MAX_PEER_ADDRS)
.cloned()
.collect();
EndpointAddr { id: addr.id, addrs }
}
/// Whether an `Announce` may enter the roster. Only a brand-new author
/// (`subject_to_cap`) is gated by [`MAX_ACTIVE_PEERS`]; updates to an
/// 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.
fn announce_subject_to_cap(is_new: bool, is_reconnecting: bool) -> bool {
is_new && !is_reconnecting
}
fn peer_state_for_log(state: &PeerState) -> String {
format!(
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}, game={:?}",
@@ -390,6 +473,18 @@ impl RoomState for IrohGossipState {
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(
&mut state_mutations_seen,
payload.author,
@@ -436,16 +531,49 @@ impl RoomState for IrohGossipState {
let cleaned = crate::sanitize::sanitize_game_label(&g);
(!cleaned.is_empty()).then_some(cleaned)
});
disconnected_peers.lock().unwrap().remove(&payload.author);
let (is_new, state_changed) = {
// Bound an insider's advertised address set
// before we retain it / hand it to the dialer
// (Tier C F-01).
state.addr = sanitize_endpoint_addr(&state.addr);
// 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 mut peer_map = peers.lock().unwrap();
let is_new = !peer_map.contains_key(&payload.author);
let state_changed = peer_map.get(&payload.author) != Some(&state);
if is_new || state_changed {
peer_map.insert(payload.author, state.clone());
// Cap the roster so a flood of signed
// sock-puppet identities can't grow our
// memory/tasks/dials without bound (Tier C
// F-01). Existing-peer updates and reconnects
// 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
} else {
let state_changed = peer_map.get(&payload.author) != Some(&state);
if is_new || state_changed {
peer_map.insert(payload.author, state.clone());
}
Some((is_new, state_changed))
}
(is_new, state_changed)
};
let Some((is_new, state_changed)) = admitted else {
crate::log_msg(&format!(
"Gossip roster full ({MAX_ACTIVE_PEERS}); rejecting new peer {}",
crate::short_id(&payload.author.to_string())
));
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 {
crate::log_msg(&format!(
@@ -453,7 +581,12 @@ impl RoomState for IrohGossipState {
crate::short_id(&payload.author.to_string()),
peer_state_for_log(&state)
));
address_lookup.add_endpoint_info(state.addr.clone());
// Replace (not union) the lookup's record for
// this id with the authenticated, sanitized
// address set, so leave/re-announce cycles
// can't accumulate attacker-supplied history
// (Tier C F-01).
let _ = address_lookup.set_endpoint_info(state.addr.clone());
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
} else if state_changed {
crate::log_msg(&format!(
@@ -466,6 +599,11 @@ impl RoomState for IrohGossipState {
}
GossipMessage::Leave => {
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 was_disconnected = disconnected_peers
.lock()
@@ -768,6 +906,84 @@ mod tests {
assert!(!bootstrap.contains(&me));
}
#[test]
fn admit_into_roster_caps_new_authors_but_not_updates() {
// New authors are admitted while there's room...
assert!(admit_into_roster(0, true, 3));
assert!(admit_into_roster(2, true, 3));
// ...rejected once the roster is full...
assert!(!admit_into_roster(3, true, 3));
assert!(!admit_into_roster(10, true, 3));
// ...but an existing peer's update always passes, even at/over the cap.
assert!(admit_into_roster(3, 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]
fn sanitize_endpoint_addr_caps_address_count() {
use std::net::SocketAddr;
let id = fresh_id();
// An insider stuffs far more addresses than MAX_PEER_ADDRS into one announce.
let many: Vec<TransportAddr> = (0..(MAX_PEER_ADDRS as u16 + 50))
.map(|i| TransportAddr::Ip(SocketAddr::from(([127, 0, 0, 1], 1000 + i))))
.collect();
let addr = EndpointAddr::from_parts(id, many);
let out = sanitize_endpoint_addr(&addr);
assert_eq!(out.id, id);
assert_eq!(out.addrs.len(), MAX_PEER_ADDRS);
}
#[test]
fn sanitize_endpoint_addr_drops_overlong_relay_url() {
use std::str::FromStr;
let id = fresh_id();
let short = iroh::RelayUrl::from_str("https://relay.example/").unwrap();
let long = iroh::RelayUrl::from_str(&format!(
"https://relay.example/{}",
"a".repeat(MAX_RELAY_URL_LEN)
))
.unwrap();
assert!(long.as_str().len() > MAX_RELAY_URL_LEN);
let addr = EndpointAddr::from_parts(
id,
[TransportAddr::Relay(short.clone()), TransportAddr::Relay(long)],
);
let out = sanitize_endpoint_addr(&addr);
let relays: Vec<_> = out.relay_urls().cloned().collect();
assert_eq!(relays, vec![short], "over-long relay URL must be dropped");
}
#[test]
fn test_gossip_message_leave_round_trip() {
let original = GossipMessage::Leave;