Files
peerspeak/src/avatar.rs
T
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

421 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Avatar helpers: a participant's chosen [`Avatar`] plus the deterministic
//! monogram + colour used as the fallback.
//!
//! Every participant gets a visual avatar (W4). When they haven't chosen a preset
//! or uploaded an image, we fall back to a *monogram*: their initial(s) on a
//! colour deterministically derived from a stable key (their node id, or their
//! name where the id isn't available, e.g. a chat line). Pure + dependency-free
//! so it's unit-testable and needs no image decoding for the common case.
use serde::{Deserialize, Serialize};
/// Number of bundled preset avatars (`preset1.png`..`preset6.png`).
pub const PRESET_COUNT: u8 = 6;
/// The raw PNG bytes of each bundled preset, embedded so they're available
/// offline and identical on every peer. Index 0 = preset 1, etc.
pub const PRESET_PNGS: [&[u8]; PRESET_COUNT as usize] = [
include_bytes!("../assets/avatars/preset1.png"),
include_bytes!("../assets/avatars/preset2.png"),
include_bytes!("../assets/avatars/preset3.png"),
include_bytes!("../assets/avatars/preset4.png"),
include_bytes!("../assets/avatars/preset5.png"),
include_bytes!("../assets/avatars/preset6.png"),
];
/// Longest side a custom avatar is downscaled to on ingest (both our own upload
/// and the limit incoming peer images are validated against). Small on purpose:
/// avatars render tiny, and it keeps the transported bytes well within the gossip
/// frame budget.
pub const CUSTOM_MAX_PX: u32 = 128;
/// Hard cap on the base64 length of a custom avatar. Bounds the presence payload
/// (which rides a 64 KB gossip frame) and the memory a malicious peer can make us
/// hold. A 128px PNG is comfortably under this; anything larger is rejected.
pub const CUSTOM_MAX_B64: usize = 48 * 1024;
/// A participant's chosen avatar. Rides the gossip presence plane (`PeerState`)
/// and is persisted in `AppConfig`. `Monogram` is the default fallback (rendered
/// from name + a colour key, no image). `Preset(i)` selects a bundled preset by
/// zero-based index. `Custom` carries a base64-encoded, hard-capped PNG (W4
/// Phase 3): produced by [`process_upload`] for our own uploads, and validated by
/// [`sanitize_incoming`] for untrusted peer ones.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Avatar {
/// Initials-on-colour fallback.
#[default]
Monogram,
/// A bundled preset by zero-based index; out-of-range falls back to monogram.
Preset(u8),
/// A custom image as a base64-encoded PNG (capped, see [`CUSTOM_MAX_B64`]).
Custom(String),
}
impl Avatar {
/// The embedded PNG bytes for this avatar, or `None` when it isn't a preset
/// (or the preset index is out of range, which renders as a monogram).
pub fn preset_png(&self) -> Option<&'static [u8]> {
match self {
Avatar::Preset(i) => PRESET_PNGS.get(*i as usize).copied(),
_ => None,
}
}
/// The decoded PNG bytes for a custom avatar, or `None` for monogram/preset
/// (or if the stored base64 is malformed). Used to build an image handle.
pub fn custom_png(&self) -> Option<Vec<u8>> {
use base64::Engine;
match self {
Avatar::Custom(b64) => base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.ok(),
_ => None,
}
}
/// Validate an avatar that arrived from an untrusted peer (over presence),
/// returning a safe value. Monogram/Preset pass through (an out-of-range
/// preset just renders as a monogram). A `Custom` is accepted only if its
/// base64 is within [`CUSTOM_MAX_B64`], decodes, and the PNG decodes within
/// [`CUSTOM_MAX_PX`] bounds (guards against decompression bombs / junk);
/// otherwise it's downgraded to a monogram. Re-encode-free: we keep the
/// original (already-validated) base64 to avoid re-deriving it every receipt.
pub fn sanitize_incoming(self) -> Avatar {
match self {
Avatar::Custom(ref b64) => {
if b64.len() <= CUSTOM_MAX_B64 && custom_b64_is_valid_png(b64) {
self
} else {
Avatar::Monogram
}
}
other => other,
}
}
}
/// Decode a base64 PNG and confirm it's a real PNG within [`CUSTOM_MAX_PX`]
/// bounds, using the `image` crate's decode limits so a malicious image can't
/// allocate unbounded memory. Returns false on any error.
fn custom_b64_is_valid_png(b64: &str) -> bool {
use base64::Engine;
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64.as_bytes()) else {
return false;
};
let mut limits = image::Limits::default();
// Cap dimensions a bit above our resize target to allow for slight slack.
limits.max_image_width = Some(CUSTOM_MAX_PX * 2);
limits.max_image_height = Some(CUSTOM_MAX_PX * 2);
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes));
reader.set_format(image::ImageFormat::Png);
reader.limits(limits);
match reader.decode() {
Ok(img) => img.width() <= CUSTOM_MAX_PX * 2 && img.height() <= CUSTOM_MAX_PX * 2,
Err(_) => false,
}
}
/// Process a user-picked image file (arbitrary png/jpeg bytes) into a capped
/// custom [`Avatar`]: decode, downscale so the longest side is [`CUSTOM_MAX_PX`]
/// (aspect preserved), re-encode as PNG, base64. Errors (undecodable, or the
/// result somehow exceeds the cap) are returned as a message for the UI.
pub fn process_upload(raw: &[u8]) -> Result<Avatar, String> {
use base64::Engine;
let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?;
let small = img.thumbnail(CUSTOM_MAX_PX, CUSTOM_MAX_PX);
let mut png = std::io::Cursor::new(Vec::new());
small
.write_to(&mut png, image::ImageFormat::Png)
.map_err(|e| format!("Couldn't encode image: {e}"))?;
let b64 = base64::engine::general_purpose::STANDARD.encode(png.into_inner());
if b64.len() > CUSTOM_MAX_B64 {
return Err("Image is too large even after resizing.".to_string());
}
Ok(Avatar::Custom(b64))
}
/// Curated palette the monogram background is chosen from. Picked to be distinct
/// and legible under the app's (mostly dark) themes; the matching text colour is
/// decided per-background by [`use_dark_text_on`]. Order is part of the stable
/// mapping — don't reorder without accepting that everyone's colour shifts.
pub const PALETTE: [(u8, u8, u8); 12] = [
(0xE5, 0x73, 0x73), // red
(0xE5, 0x9E, 0x57), // orange
(0xE5, 0xC0, 0x7B), // amber
(0x8C, 0xC2, 0x65), // green
(0x5E, 0xC8, 0xA0), // teal
(0x5C, 0xB3, 0xE5), // blue
(0x6E, 0x8C, 0xE5), // azure
(0x9A, 0x8C, 0xE5), // indigo
(0xC0, 0x7B, 0xE5), // violet
(0xE5, 0x7B, 0xC0), // pink
(0xB0, 0x8B, 0x6E), // brown
(0x8A, 0x9B, 0xA8), // slate
];
/// Pick a stable palette colour for `key` (a node id string, or a name). Uses an
/// FNV-1a hash so the same key always maps to the same colour across machines.
pub fn color_for_key(key: &str) -> (u8, u8, u8) {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in key.as_bytes() {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
PALETTE[(hash % PALETTE.len() as u64) as usize]
}
/// Whether dark text is more legible than white on the given background, by
/// relative luminance (sRGB-weighted). Light backgrounds → dark text.
pub fn use_dark_text_on((r, g, b): (u8, u8, u8)) -> bool {
let lum = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
lum > 150.0
}
/// The 12 character monogram for a display name: the first letters of its first
/// two alphanumeric words, uppercased. Falls back to "?" when nothing usable
/// (empty name, or only emoji/punctuation) remains.
pub fn initials(name: &str) -> String {
let mut firsts = name
.split_whitespace()
.filter_map(|w| w.chars().find(|c| c.is_alphanumeric()));
match (firsts.next(), firsts.next()) {
(Some(a), Some(b)) => format!("{}{}", a.to_uppercase(), b.to_uppercase()),
(Some(a), None) => a.to_uppercase().to_string(),
_ => "?".to_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");
assert_eq!(initials("alice bob"), "AB");
assert_eq!(initials(" spaced out name "), "SO");
}
#[test]
fn initials_handles_unicode_and_skips_non_alnum() {
assert_eq!(initials("héllo"), "H");
// Leading emoji word is skipped; the next alphanumeric word is used.
assert_eq!(initials("🎙 Mike"), "M");
}
#[test]
fn initials_falls_back_to_question_mark() {
assert_eq!(initials(""), "?");
assert_eq!(initials(" "), "?");
assert_eq!(initials("🎉🎊"), "?");
}
#[test]
fn color_is_deterministic_and_in_palette() {
let c1 = color_for_key("node-abc");
let c2 = color_for_key("node-abc");
assert_eq!(c1, c2, "same key must map to same colour");
assert!(PALETTE.contains(&c1));
// Different keys generally differ; at minimum the function is total.
let _ = color_for_key("");
}
#[test]
fn avatar_default_is_monogram() {
assert_eq!(Avatar::default(), Avatar::Monogram);
assert!(Avatar::default().preset_png().is_none());
}
#[test]
fn preset_png_in_range_and_out_of_range() {
// Every declared preset index resolves to embedded bytes.
for i in 0..PRESET_COUNT {
assert!(Avatar::Preset(i).preset_png().is_some(), "preset {i} missing");
}
// Out-of-range index gracefully yields None (→ monogram fallback).
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
assert!(Avatar::Preset(250).preset_png().is_none());
}
#[test]
fn avatar_serde_round_trips_and_defaults() {
// Round-trips through JSON (it rides presence + persists in config).
for a in [Avatar::Monogram, Avatar::Preset(0), Avatar::Preset(5)] {
let s = serde_json::to_string(&a).unwrap();
assert_eq!(serde_json::from_str::<Avatar>(&s).unwrap(), a);
}
}
/// A valid PNG of the given size, as raw bytes (test helper).
fn make_png(w: u32, h: u32) -> Vec<u8> {
let img = image::DynamicImage::new_rgba8(w, h);
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
buf.into_inner()
}
#[test]
fn process_upload_resizes_and_round_trips() {
// A 400x200 image downscales so the longest side is CUSTOM_MAX_PX.
let raw = make_png(400, 200);
let avatar = process_upload(&raw).expect("should process");
assert!(matches!(avatar, Avatar::Custom(_)));
// The stored base64 decodes back to a PNG within bounds.
let png = avatar.custom_png().expect("custom png decodes");
let decoded = image::load_from_memory(&png).unwrap();
assert_eq!(decoded.width().max(decoded.height()), CUSTOM_MAX_PX);
assert!(decoded.width() <= CUSTOM_MAX_PX && decoded.height() <= CUSTOM_MAX_PX);
}
#[test]
fn process_upload_rejects_non_image() {
assert!(process_upload(b"definitely not an image").is_err());
}
#[test]
fn sanitize_incoming_passes_monogram_and_presets() {
assert_eq!(Avatar::Monogram.sanitize_incoming(), Avatar::Monogram);
assert_eq!(Avatar::Preset(2).sanitize_incoming(), Avatar::Preset(2));
}
#[test]
fn sanitize_incoming_accepts_valid_custom() {
let avatar = process_upload(&make_png(64, 64)).unwrap();
assert_eq!(avatar.clone().sanitize_incoming(), avatar);
}
#[test]
fn sanitize_incoming_rejects_junk_and_oversize() {
// Not valid base64 / not a PNG → downgraded to monogram.
assert_eq!(Avatar::Custom("not base64!!!".into()).sanitize_incoming(), Avatar::Monogram);
// Over the byte cap → downgraded without even decoding.
let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
assert_eq!(huge.sanitize_incoming(), Avatar::Monogram);
}
#[test]
fn dark_text_chosen_on_light_backgrounds() {
assert!(use_dark_text_on((0xFF, 0xFF, 0xFF))); // white bg → dark text
assert!(!use_dark_text_on((0x10, 0x10, 0x10))); // near-black bg → light text
}
}