381e00bc0efe2e6f5ed6e472e128b41dd44d9e24
The chat-attachment result cache (`attachment_data` + `image_handle_cache`) was keyed by attachment id alone and only cleared on room-leave, so an authenticated insider could (F-02) stream distinct attachments to grow it without bound, and (F-12) reuse a victim's attachment id to alias displayed/ saved bytes — the id is attacker-chosen, so a signature only proves keypair ownership, not a distinct human. F-12: thread the author (`from: EndpointId`) back through the `AttachmentReady`/`AttachmentFailed` core→UI events (the fetch task already holds it) and key all attachment result state on `(author, id)`: - new `AttachmentKey = (EndpointId, AttachmentId)`; - `attachment_data` + `image_handle_cache` fold into one `AttachmentCache`; - `pending_saves` and the `SaveAttachment`/`PlayAudio` messages re-keyed, so the save/fetch dispatch can't be redirected to the wrong sender's line; - `find_attachment_source` now matches author AND id; - the render path resolves each line's key from `ChatEntry.from`. F-02: `AttachmentCache` is bounded (`ATTACHMENT_CACHE_CAP = 64`) with insertion-order eviction. True LRU is impossible because iced's `view` borrows `&self` and so can't reorder on a render read; the generous cap means a normal session never evicts and the newest (on-screen) entries are always retained — only an abusive stream hits the bound. Deliberately id-keyed (cosmetic only, documented): the clip player's `playing_id`, `pending_plays`, `invalid_audio` — they're coupled to the id-keyed clip player, and the bytes actually played come from the author-keyed cache, so content is always correct. No gossip/wire/protocol change (UiEvent is in-process), no new deps. +6 unit tests (cache eviction, replace-keeps-position, same-id/distinct-author non-aliasing, is_ready/handle/clear, cap-zero clamp). 423 lib tests, clippy --all-targets clean, release build green. TESTS-GREEN-ONLY. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>