Skip to main content

river_core/room_state/
direct_messages.rs

1//! In-room direct messages (#230 Phase 1).
2//!
3//! End-to-end-encrypted DMs between two members of the same room,
4//! carried inside `ChatRoomStateV1`. Replaces the reverted inbox-contract
5//! approach (PR #234 → reverted in #238) - instead of a separate per-pair
6//! contract, DMs live in the room contract and are scoped to the room
7//! they're sent in by design.
8//!
9//! # State shape
10//!
11//! - [`DirectMessagesV1::messages`]: a flat list of
12//!   [`AuthorizedDirectMessage`]s. Each is signed by its sender,
13//!   addressed to a specific recipient, and carries opaque ECIES
14//!   ciphertext encrypted to the recipient's `member_vk`.
15//!
16//! - [`DirectMessagesV1::purges`]: a sorted list of
17//!   [`AuthorizedRecipientPurges`] tombstone envelopes, one per
18//!   recipient. Each recipient signs a single, monotonically-versioned
19//!   list of [`PurgeToken`] entries identifying messages they've purged.
20//!   The recipient is the sole signer of their own purge envelope;
21//!   concurrent updates are resolved by strict-monotonic `version`. A
22//!   `Vec` (rather than `HashMap<MemberId, _>`) is used so the state
23//!   round-trips through `serde_json` - `MemberId` is a struct and is
24//!   rejected as a JSON object key (see bug-prevention-patterns
25//!   "Non-string map keys", #3987 incident).
26//!
27//! # Authorisation model
28//!
29//! Every piece of state is cryptographically authorised at insertion:
30//!
31//! 1. Each [`AuthorizedDirectMessage`] carries a sender signature over
32//!    canonical bytes (see [`build_direct_message_signed_bytes`]) that
33//!    bind `sender`, `recipient`, `room_owner_vk`, `timestamp`, and
34//!    `ciphertext`, prefixed by the 1-byte domain tag
35//!    [`DOMAIN_TAG_MESSAGE`]. The signature is verified against the
36//!    sender's resolved `member_vk` (looked up in
37//!    `parent_state.members`).
38//!
39//! 2. Each [`AuthorizedRecipientPurges`] carries a recipient signature
40//!    over canonical bytes (see [`build_recipient_purges_signed_bytes`])
41//!    that bind `recipient`, `room_owner_vk`, `version`, and the purge
42//!    list, prefixed by the 1-byte domain tag [`DOMAIN_TAG_PURGES`].
43//!    Verified against the recipient's resolved `member_vk`.
44//!
45//! 3. Both sender and recipient MUST be current members of the room.
46//!    The owner is treated as an implicit member (their key is in
47//!    `parameters.owner`). Bans are NOT enforced here - see "Interaction
48//!    with bans" below.
49//!
50//! # Tombstone-as-block semantics
51//!
52//! Once a recipient signs a purge envelope listing the BLAKE3-derived
53//! [`PurgeToken`] of a sender's signature, ANY incoming message whose
54//! signature hashes to the same token is dropped on merge. Versioning of
55//! the purge envelope follows the `Configuration` monotonic-version
56//! pattern (one signed envelope per recipient, strictly-greater version
57//! replaces older); the drop-on-merge filtering effect matches `BansV1`'s
58//! treatment of banned members. Stale peers re-merging a purged message
59//! are blocked by the current `purges` state. Each new envelope MUST
60//! contain a superset of the previous version's tombstones (no
61//! un-purging) - enforced in [`ComposableState::apply_delta`].
62//!
63//! # Interaction with bans
64//!
65//! `verify` deliberately does NOT reject DMs whose sender or recipient
66//! is currently in `parent_state.bans` - same precedent as
67//! [`crate::room_state::message::MessagesV1`], which only checks
68//! signatures + author-is-a-member in `verify`. Bans are enforced as a
69//! *sweep* in [`crate::ChatRoomStateV1::post_apply_cleanup`]: banned DMs
70//! are dropped after each merge so the state stays verifiable. Without
71//! this split, adding a ban for a participant of an existing DM would
72//! make every peer's verify fail until the next purge - a self-DoS.
73//!
74//! # Threat model
75//!
76//! - The contract validates only the OUTER envelope (sender authorised,
77//!   recipient is a member of the same room, caps respected, tombstones
78//!   honoured). The inner ECIES ciphertext is OPAQUE - the contract
79//!   cannot read it, has no view into per-message replay, and provides
80//!   no in-contract de-duplication of identical re-sent ciphertexts.
81//!
82//! - A malicious member can grief storage by saturating their own
83//!   per-pair cap (up to [`MAX_DM_MESSAGES_PER_PAIR`] ×
84//!   [`MAX_DM_CIPHERTEXT_BYTES`] per recipient they target). The
85//!   recipient mitigates by signing a purge envelope listing the
86//!   offending tokens.
87//!
88//! - Re-spam after purge is NOT prevented - a banned-then-unbanned (or
89//!   simply persistent) member produces a fresh signature on each DM,
90//!   yielding a fresh purge token. Tombstones prevent state-replay
91//!   ("stale peer re-merges the same signed message") but not new spam;
92//!   that's a ban concern.
93//!
94//! # Bounds
95//!
96//! - `Configuration::effective_max_direct_messages`: owner-tunable GLOBAL cap
97//!   on `messages`, defaulting to
98//!   [`crate::room_state::configuration::DEFAULT_MAX_DIRECT_MESSAGES`] (300).
99//!   An INTERIM bound — the intended fix is moving DMs into per-member
100//!   contracts — so it is deliberately the simplest correct thing and is kept
101//!   wholly inside this module. Added
102//!   for freenet/river#519: the per-pair cap below bounds any one
103//!   conversation but nothing bounded the set as a whole, and because
104//!   `ChatRoomStateV1::post_apply_cleanup` exempts every DM participant from
105//!   inactivity-prune (via [`DirectMessagesV1::active_participants`]), an
106//!   unbounded DM set pinned an unbounded member set. Enforced in
107//!   `apply_delta` only, NEVER in `verify` — see the note on
108//!   [`DmRetentionHorizon`] and `trim_to_global_cap`.
109//! - [`MAX_DM_MESSAGES_PER_PAIR`]: per (sender, recipient) ordered pair.
110//! - [`MAX_DM_CIPHERTEXT_BYTES`]: per-message ciphertext size cap.
111//! - [`MAX_PURGED_TOMBSTONES_PER_RECIPIENT`]: cap on per-recipient
112//!   purge-list length.
113//! - [`MAX_DM_FUTURE_SKEW_SECS`]: maximum permitted future-skew when
114//!   accepting a fresh message (verifiable via
115//!   [`check_dm_future_skew`]). Not enforced inside `verify` (would be
116//!   self-DoS for already-stored state).
117
118use crate::room_state::member::{AuthorizedMember, MemberId};
119use crate::room_state::ChatRoomParametersV1;
120use crate::ChatRoomStateV1;
121use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
122use freenet_scaffold::ComposableState;
123use serde::{Deserialize, Serialize};
124use std::collections::{BTreeSet, HashMap, HashSet};
125
126// ---------------------------------------------------------------------------
127// Domain separation tags (prepended to signed byte buffers)
128// ---------------------------------------------------------------------------
129
130/// Domain-separation tag for [`build_direct_message_signed_bytes`]. The
131/// signed buffer always begins with this byte so a sender's DM signature
132/// can never be reused as a recipient purge signature (or vice versa)
133/// regardless of crafted field lengths.
134pub const DOMAIN_TAG_MESSAGE: u8 = b'M';
135
136/// Domain-separation tag for [`build_recipient_purges_signed_bytes`].
137pub const DOMAIN_TAG_PURGES: u8 = b'P';
138
139// ---------------------------------------------------------------------------
140// Bounds
141// ---------------------------------------------------------------------------
142
143/// Maximum direct messages held per ordered `(sender, recipient)` pair.
144pub const MAX_DM_MESSAGES_PER_PAIR: usize = 100;
145
146/// Maximum permitted ciphertext size per direct message, in bytes.
147pub const MAX_DM_CIPHERTEXT_BYTES: usize = 32_768;
148
149/// Maximum tombstone entries any single recipient may keep.
150pub const MAX_PURGED_TOMBSTONES_PER_RECIPIENT: usize = 1000;
151
152/// Maximum permitted future-skew when ingesting a fresh direct message
153/// (seconds). Use [`check_dm_future_skew`] at message-construction time;
154/// `verify` deliberately does NOT enforce this on already-stored state
155/// to avoid self-DoS.
156pub const MAX_DM_FUTURE_SKEW_SECS: u64 = 5 * 60;
157
158// ---------------------------------------------------------------------------
159// PurgeToken - BLAKE3-derived signature tombstone
160// ---------------------------------------------------------------------------
161
162/// 16-byte BLAKE3-derived identifier for a specific signed direct
163/// message, used as the per-recipient tombstone key. 128 bits gives a
164/// ~2^64 birthday bound - adequate against worst-case attacker-chosen
165/// signature grinding (an attacker who can sign as themselves cannot
166/// influence which token any *other* member's purge list contains, and
167/// the recipient is the sole signer of their own purge list).
168#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
169pub struct PurgeToken(pub [u8; 16]);
170
171impl PurgeToken {
172    /// Derive the tombstone for a sender signature.
173    pub fn from_signature(signature: &Signature) -> Self {
174        let digest = blake3::hash(signature.to_bytes().as_ref());
175        let mut out = [0u8; 16];
176        out.copy_from_slice(&digest.as_bytes()[..16]);
177        PurgeToken(out)
178    }
179}
180
181impl Serialize for PurgeToken {
182    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
183        serializer.serialize_bytes(&self.0)
184    }
185}
186
187impl<'de> Deserialize<'de> for PurgeToken {
188    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
189        let bytes = <Vec<u8>>::deserialize(deserializer)?;
190        let arr: [u8; 16] = bytes.as_slice().try_into().map_err(|_| {
191            serde::de::Error::custom(format!(
192                "expected 16-byte PurgeToken, got {} bytes",
193                bytes.len()
194            ))
195        })?;
196        Ok(PurgeToken(arr))
197    }
198}
199
200// ---------------------------------------------------------------------------
201// Signature byte wrapper (serde can't derive for `[u8; 64]` directly)
202// ---------------------------------------------------------------------------
203
204/// Newtype around a 64-byte Ed25519 signature, present only because
205/// serde doesn't derive `Serialize`/`Deserialize` for `[u8; 64]`.
206/// Used as a set key in [`DirectMessagesSummary`] for fast
207/// "do we already have this signature?" lookups during delta
208/// computation.
209///
210/// `Ord`/`PartialOrd` (over the raw 64 bytes) are required so the summary can
211/// store these in a `BTreeSet` — a deterministic order is what keeps the
212/// ciborium-serialized summary bytes identical across peers (see
213/// [`DirectMessagesSummary`] and freenet/freenet-core#4857).
214#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
215pub struct SignatureBytes(pub [u8; 64]);
216
217impl Serialize for SignatureBytes {
218    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
219        serializer.serialize_bytes(&self.0)
220    }
221}
222
223impl<'de> Deserialize<'de> for SignatureBytes {
224    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
225        let bytes = <Vec<u8>>::deserialize(deserializer)?;
226        let arr: [u8; 64] = bytes.as_slice().try_into().map_err(|_| {
227            serde::de::Error::custom(format!(
228                "expected 64-byte Ed25519 signature, got {} bytes",
229                bytes.len()
230            ))
231        })?;
232        Ok(SignatureBytes(arr))
233    }
234}
235
236// ---------------------------------------------------------------------------
237// State shape
238// ---------------------------------------------------------------------------
239
240/// In-room direct-message sub-state. Wired into [`ChatRoomStateV1`] as
241/// `direct_messages` with `#[serde(default)]` for back-compat with
242/// pre-#230 encoded states.
243#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
244pub struct DirectMessagesV1 {
245    /// All sender-signed direct messages currently held.
246    #[serde(default)]
247    pub messages: Vec<AuthorizedDirectMessage>,
248
249    /// Per-recipient purge envelopes (at most one per recipient).
250    /// Stored as a sorted `Vec` (sorted by `recipient_id`) rather than
251    /// `HashMap<MemberId, _>` because `MemberId` is a struct and
252    /// `serde_json` rejects non-string map keys; see the bug-prevention
253    /// pattern. `verify` enforces no-duplicate recipient_id.
254    #[serde(default)]
255    pub purges: Vec<AuthorizedRecipientPurges>,
256}
257
258/// A sender-signed direct message.
259#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
260pub struct AuthorizedDirectMessage {
261    pub message: DirectMessage,
262    /// Sender's Ed25519 signature over the bytes produced by
263    /// [`build_direct_message_signed_bytes`].
264    pub sender_signature: Signature,
265}
266
267/// The signed payload of a direct message.
268#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
269pub struct DirectMessage {
270    /// Sender's [`MemberId`]. For owner-sent DMs, this is
271    /// `MemberId::from(&parameters.owner)`.
272    pub sender: MemberId,
273
274    /// Recipient's [`MemberId`].
275    pub recipient: MemberId,
276
277    /// Unix timestamp (seconds since epoch). See [`check_dm_future_skew`].
278    pub timestamp: u64,
279
280    /// Opaque ciphertext, ECIES-encrypted to recipient's `member_vk`.
281    pub ciphertext: Vec<u8>,
282}
283
284/// A recipient-signed purge envelope.
285#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
286pub struct AuthorizedRecipientPurges {
287    /// The recipient this envelope authorises purges for. MUST equal
288    /// the `MemberId` derived from the signing key's `VerifyingKey`.
289    pub recipient_id: MemberId,
290    pub state: RecipientPurges,
291    /// Recipient's Ed25519 signature over the bytes produced by
292    /// [`build_recipient_purges_signed_bytes`].
293    pub recipient_signature: Signature,
294}
295
296/// Recipient-controlled purge list.
297#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
298pub struct RecipientPurges {
299    /// Monotonically increasing per-recipient. `0` is reserved as the
300    /// "no purge envelope yet" sentinel: the first envelope MUST use
301    /// `version >= 1`, and each subsequent envelope MUST use a strictly
302    /// greater `version`. A version-bump MUST also be a superset of the
303    /// previous list - un-purging is not allowed (`apply_delta` rejects
304    /// any shrinking purge list).
305    #[serde(default)]
306    pub version: u64,
307
308    /// BLAKE3-derived purge tokens of messages the recipient has
309    /// purged. Once present, ANY incoming message whose token matches
310    /// is dropped. Order within the list is canonical-sorted for
311    /// signature determinism (see
312    /// [`build_recipient_purges_signed_bytes`]).
313    #[serde(default)]
314    pub purged: Vec<PurgeToken>,
315}
316
317// ---------------------------------------------------------------------------
318// Canonical signed-byte layouts
319// ---------------------------------------------------------------------------
320
321/// Build the bytes the sender signs for an [`AuthorizedDirectMessage`].
322///
323/// ```text
324///     domain_tag                  ( 1 byte, = DOMAIN_TAG_MESSAGE)
325///     sender_member_id_le_i64     ( 8 bytes)
326///     recipient_member_id_le_i64  ( 8 bytes)
327///     room_owner_vk               (32 bytes)
328///     timestamp_le_u64            ( 8 bytes)
329///     ciphertext_len_le_u32       ( 4 bytes)
330///     ciphertext                  (variable)
331/// ```
332///
333/// Canonical by construction: all fields fixed-length except the
334/// trailing ciphertext, which is preceded by its u32 little-endian
335/// length. The leading domain-separation tag prevents this signed
336/// buffer from ever being byte-equal to a [`build_recipient_purges_signed_bytes`]
337/// buffer regardless of crafted field lengths.
338pub fn build_direct_message_signed_bytes(
339    sender: MemberId,
340    recipient: MemberId,
341    room_owner_vk: &VerifyingKey,
342    timestamp: u64,
343    ciphertext: &[u8],
344) -> Result<Vec<u8>, String> {
345    let ct_len: u32 = ciphertext.len().try_into().map_err(|_| {
346        format!(
347            "DM ciphertext length {} does not fit in u32",
348            ciphertext.len()
349        )
350    })?;
351    let mut out = Vec::with_capacity(1 + 8 + 8 + 32 + 8 + 4 + ciphertext.len());
352    out.push(DOMAIN_TAG_MESSAGE);
353    out.extend_from_slice(&sender.0 .0.to_le_bytes());
354    out.extend_from_slice(&recipient.0 .0.to_le_bytes());
355    out.extend_from_slice(room_owner_vk.as_bytes());
356    out.extend_from_slice(&timestamp.to_le_bytes());
357    out.extend_from_slice(&ct_len.to_le_bytes());
358    out.extend_from_slice(ciphertext);
359    Ok(out)
360}
361
362/// Build the bytes the recipient signs for an
363/// [`AuthorizedRecipientPurges`].
364///
365/// ```text
366///     domain_tag                  ( 1 byte, = DOMAIN_TAG_PURGES)
367///     recipient_member_id_le_i64  ( 8 bytes)
368///     room_owner_vk               (32 bytes)
369///     version_le_u64              ( 8 bytes)
370///     purged_count_le_u32         ( 4 bytes)
371///     purged                      (16 bytes per entry, in declared order)
372/// ```
373///
374/// Each `purged` entry is encoded as 16 raw bytes (the [`PurgeToken`])
375/// in the order they appear in [`RecipientPurges::purged`]. The list
376/// should be sorted ascending for canonical comparison; signers SHOULD
377/// sort before signing.
378pub fn build_recipient_purges_signed_bytes(
379    recipient: MemberId,
380    room_owner_vk: &VerifyingKey,
381    state: &RecipientPurges,
382) -> Result<Vec<u8>, String> {
383    let purged_count: u32 = state.purged.len().try_into().map_err(|_| {
384        format!(
385            "DM purge list length {} does not fit in u32",
386            state.purged.len()
387        )
388    })?;
389    let mut out = Vec::with_capacity(1 + 8 + 32 + 8 + 4 + state.purged.len() * 16);
390    out.push(DOMAIN_TAG_PURGES);
391    out.extend_from_slice(&recipient.0 .0.to_le_bytes());
392    out.extend_from_slice(room_owner_vk.as_bytes());
393    out.extend_from_slice(&state.version.to_le_bytes());
394    out.extend_from_slice(&purged_count.to_le_bytes());
395    for entry in &state.purged {
396        out.extend_from_slice(&entry.0);
397    }
398    Ok(out)
399}
400
401// ---------------------------------------------------------------------------
402// Helpers - sender / recipient signing
403// ---------------------------------------------------------------------------
404
405/// Sign a direct message. Sender's `MemberId` MUST match
406/// `sender_sk.verifying_key()`.
407pub fn sign_direct_message(
408    sender_sk: &SigningKey,
409    sender: MemberId,
410    recipient: MemberId,
411    room_owner_vk: &VerifyingKey,
412    timestamp: u64,
413    ciphertext: Vec<u8>,
414) -> Result<AuthorizedDirectMessage, String> {
415    debug_assert_eq!(
416        sender,
417        MemberId::from(&sender_sk.verifying_key()),
418        "sender MemberId must derive from sender_sk"
419    );
420    if sender == recipient {
421        return Err("DM sender and recipient must differ".to_string());
422    }
423    let bytes = build_direct_message_signed_bytes(
424        sender,
425        recipient,
426        room_owner_vk,
427        timestamp,
428        &ciphertext,
429    )?;
430    let signature = sender_sk.sign(&bytes);
431    Ok(AuthorizedDirectMessage {
432        message: DirectMessage {
433            sender,
434            recipient,
435            timestamp,
436            ciphertext,
437        },
438        sender_signature: signature,
439    })
440}
441
442/// Sign a recipient purge envelope. Recipient's `MemberId` MUST match
443/// `recipient_sk.verifying_key()`. The purge list is canonicalised
444/// (sorted + deduplicated) before signing.
445pub fn sign_recipient_purges(
446    recipient_sk: &SigningKey,
447    recipient: MemberId,
448    room_owner_vk: &VerifyingKey,
449    mut state: RecipientPurges,
450) -> Result<AuthorizedRecipientPurges, String> {
451    debug_assert_eq!(
452        recipient,
453        MemberId::from(&recipient_sk.verifying_key()),
454        "recipient MemberId must derive from recipient_sk"
455    );
456    state.purged.sort();
457    state.purged.dedup();
458    let bytes = build_recipient_purges_signed_bytes(recipient, room_owner_vk, &state)?;
459    let signature = recipient_sk.sign(&bytes);
460    Ok(AuthorizedRecipientPurges {
461        recipient_id: recipient,
462        state,
463        recipient_signature: signature,
464    })
465}
466
467/// Count messages currently stored from `sender` to `recipient`. Clients
468/// call this before [`compose_direct_message`] so they can surface a
469/// user-visible error instead of silently losing the message — the contract
470/// `apply_delta` drops overflow without erroring (one over-eager sender
471/// should not poison the merge for every peer; see
472/// `direct_messages.rs::apply_delta` comments).
473pub fn pair_message_count(
474    state: &DirectMessagesV1,
475    sender: MemberId,
476    recipient: MemberId,
477) -> usize {
478    state
479        .messages
480        .iter()
481        .filter(|m| m.message.sender == sender && m.message.recipient == recipient)
482        .count()
483}
484
485/// Reject timestamps too far ahead of `now_secs`. Used at
486/// message-construction / ingestion time; deliberately NOT called from
487/// [`ComposableState::verify`] to avoid self-DoS on stored state.
488pub fn check_dm_future_skew(timestamp: u64, now_secs: u64) -> Result<(), String> {
489    if timestamp > now_secs.saturating_add(MAX_DM_FUTURE_SKEW_SECS) {
490        Err(format!(
491            "DM timestamp {} is more than {}s ahead of now ({})",
492            timestamp, MAX_DM_FUTURE_SKEW_SECS, now_secs
493        ))
494    } else {
495        Ok(())
496    }
497}
498
499/// End-to-end helper: encrypt `body` to the recipient, sign as the sender,
500/// and return a wire-ready [`AuthorizedDirectMessage`]. Both the UI and
501/// `riverctl` call this so the bytes that hit `DirectMessagesV1::messages`
502/// are byte-identical across clients.
503///
504/// Requires the `ecies-randomized` feature (delegate WASM never sends DMs,
505/// only inspects them).
506///
507/// Caps enforced here so a client never tries to push state the contract
508/// will silently drop:
509/// * `body` is rejected when the resulting envelope exceeds
510///   [`MAX_DM_CIPHERTEXT_BYTES`].
511/// * `timestamp` is rejected if more than [`MAX_DM_FUTURE_SKEW_SECS`] ahead
512///   of `now_secs` (the caller's view of wall-clock).
513#[cfg(feature = "ecies-randomized")]
514pub fn compose_direct_message(
515    sender_sk: &SigningKey,
516    recipient_vk: &VerifyingKey,
517    room_owner_vk: &VerifyingKey,
518    timestamp: u64,
519    now_secs: u64,
520    body: &[u8],
521) -> Result<AuthorizedDirectMessage, String> {
522    check_dm_future_skew(timestamp, now_secs)?;
523
524    let sender = MemberId::from(&sender_sk.verifying_key());
525    let recipient = MemberId::from(recipient_vk);
526    if sender == recipient {
527        return Err("DM sender and recipient must differ".to_string());
528    }
529
530    let envelope = crate::ecies::seal_dm_for_recipient(recipient_vk, body);
531    if envelope.len() > MAX_DM_CIPHERTEXT_BYTES {
532        return Err(format!(
533            "DM body too large: envelope {} bytes exceeds cap {} (body {} bytes; {} bytes of crypto overhead)",
534            envelope.len(),
535            MAX_DM_CIPHERTEXT_BYTES,
536            body.len(),
537            envelope.len() - body.len()
538        ));
539    }
540
541    sign_direct_message(
542        sender_sk,
543        sender,
544        recipient,
545        room_owner_vk,
546        timestamp,
547        envelope,
548    )
549}
550
551/// Inverse of [`compose_direct_message`]: decrypt a DM's ciphertext back to
552/// plaintext bytes using the recipient's signing key. Does NOT verify the
553/// sender signature — call [`AuthorizedDirectMessage::verify_signature`]
554/// separately when freshness matters.
555///
556/// Feature-gated on `ecies` because the wire-format decryption lives in
557/// [`crate::ecies`], which is itself `#[cfg(feature = "ecies")]`. The
558/// room-contract WASM does not enable `ecies` (it only validates signed
559/// envelopes, never reads plaintext); making this unconditional would break
560/// that build.
561#[cfg(feature = "ecies")]
562pub fn open_direct_message(
563    recipient_sk: &SigningKey,
564    msg: &AuthorizedDirectMessage,
565) -> Result<Vec<u8>, String> {
566    crate::ecies::unseal_dm_from_sender(recipient_sk, &msg.message.ciphertext)
567}
568
569/// Construct a fresh [`AuthorizedRecipientPurges`] that bumps the recipient's
570/// purge envelope to `previous.version + 1` (or `1` if `previous` is `None`)
571/// and unions in `new_tokens`. The combined list is canonicalised
572/// (sorted + deduplicated) and rejected when it exceeds
573/// [`MAX_PURGED_TOMBSTONES_PER_RECIPIENT`].
574pub fn advance_recipient_purges(
575    recipient_sk: &SigningKey,
576    room_owner_vk: &VerifyingKey,
577    previous: Option<&AuthorizedRecipientPurges>,
578    new_tokens: impl IntoIterator<Item = PurgeToken>,
579) -> Result<AuthorizedRecipientPurges, String> {
580    let recipient = MemberId::from(&recipient_sk.verifying_key());
581    if let Some(prev) = previous {
582        if prev.recipient_id != recipient {
583            return Err(format!(
584                "advance_recipient_purges: previous envelope is for recipient {:?}, but signing key is for {:?}",
585                prev.recipient_id, recipient
586            ));
587        }
588    }
589
590    let prev_version = previous.map(|p| p.state.version).unwrap_or(0);
591    let next_version = prev_version
592        .checked_add(1)
593        .ok_or_else(|| "recipient purges version overflow".to_string())?;
594
595    let mut combined: Vec<PurgeToken> =
596        previous.map(|p| p.state.purged.clone()).unwrap_or_default();
597    combined.extend(new_tokens);
598    combined.sort();
599    combined.dedup();
600
601    if combined.len() > MAX_PURGED_TOMBSTONES_PER_RECIPIENT {
602        return Err(format!(
603            "recipient purge list would exceed cap: {} > {}",
604            combined.len(),
605            MAX_PURGED_TOMBSTONES_PER_RECIPIENT
606        ));
607    }
608
609    sign_recipient_purges(
610        recipient_sk,
611        recipient,
612        room_owner_vk,
613        RecipientPurges {
614            version: next_version,
615            purged: combined,
616        },
617    )
618}
619
620// ---------------------------------------------------------------------------
621// Verification helpers
622// ---------------------------------------------------------------------------
623
624impl AuthorizedDirectMessage {
625    /// Verify the sender signature against the resolved sender
626    /// verifying key.
627    pub fn verify_signature(
628        &self,
629        sender_vk: &VerifyingKey,
630        room_owner_vk: &VerifyingKey,
631    ) -> Result<(), String> {
632        let bytes = build_direct_message_signed_bytes(
633            self.message.sender,
634            self.message.recipient,
635            room_owner_vk,
636            self.message.timestamp,
637            &self.message.ciphertext,
638        )?;
639        sender_vk
640            .verify(&bytes, &self.sender_signature)
641            .map_err(|e| format!("Invalid DM sender signature: {}", e))
642    }
643
644    /// BLAKE3-derived tombstone token for this signature; what the
645    /// recipient records in [`RecipientPurges::purged`].
646    pub fn purge_token(&self) -> PurgeToken {
647        PurgeToken::from_signature(&self.sender_signature)
648    }
649
650    /// This message's position in the per-pair retention order — the key
651    /// [`trim_pairs_to_cap`] prunes by, and the same `(timestamp, signature)`
652    /// order [`sort_state`] uses within a pair. Must stay in step with both;
653    /// see [`DmPairHorizon`].
654    pub fn order_key(&self) -> DmOrderKey {
655        DmOrderKey {
656            timestamp: self.message.timestamp,
657            signature: SignatureBytes(self.sender_signature.to_bytes()),
658        }
659    }
660}
661
662impl AuthorizedRecipientPurges {
663    /// Verify the recipient signature against the resolved recipient
664    /// verifying key.
665    pub fn verify_signature(
666        &self,
667        recipient_vk: &VerifyingKey,
668        room_owner_vk: &VerifyingKey,
669    ) -> Result<(), String> {
670        let bytes =
671            build_recipient_purges_signed_bytes(self.recipient_id, room_owner_vk, &self.state)?;
672        recipient_vk
673            .verify(&bytes, &self.recipient_signature)
674            .map_err(|e| format!("Invalid recipient purges signature: {}", e))
675    }
676}
677
678// ---------------------------------------------------------------------------
679// Banned-DM sweep (called from ChatRoomStateV1::post_apply_cleanup)
680// ---------------------------------------------------------------------------
681
682impl DirectMessagesV1 {
683    /// Set of member IDs that appear as a sender or recipient of any
684    /// currently-held DM, OR as the recipient of any currently-held
685    /// purge envelope. Used by `ChatRoomStateV1::post_apply_cleanup` to
686    /// keep DM participants AND purge-envelope holders in the active
687    /// members list. The latter is required so a recipient's purge
688    /// envelope is not swept along with the recipient as soon as they
689    /// have purged their last DM (and have no recent room messages):
690    /// dropping the envelope would re-enable a stale peer to re-merge
691    /// the original signed DM, undermining the tombstone-as-block
692    /// guarantee.
693    pub fn active_participants(&self) -> HashSet<MemberId> {
694        let mut out = HashSet::with_capacity(self.messages.len() * 2 + self.purges.len());
695        for m in &self.messages {
696            out.insert(m.message.sender);
697            out.insert(m.message.recipient);
698        }
699        for p in &self.purges {
700            out.insert(p.recipient_id);
701        }
702        out
703    }
704
705    /// The [`DmPairHorizon`] entries this peer publishes: one per ordered
706    /// `(sender, recipient)` pair that has reached [`MAX_DM_MESSAGES_PER_PAIR`],
707    /// carrying the oldest key that pair still holds.
708    ///
709    /// Computed as the MINIMUM held key rather than the exact post-trim cutoff,
710    /// and only once the pair is at or over the cap, so it never over-states:
711    /// a sender is never told to withhold a DM the pair would in fact have
712    /// kept. Under-stating merely costs an extra round, and the horizon rises
713    /// strictly each round, so the exchange still terminates.
714    ///
715    /// Returned sorted, because these bytes are part of what freenet-core
716    /// byte-compares to decide staleness.
717    pub fn pair_horizons(&self) -> Vec<DmPairHorizon> {
718        let mut by_pair: HashMap<(MemberId, MemberId), (usize, DmOrderKey)> = HashMap::new();
719        for m in &self.messages {
720            let key = m.order_key();
721            match by_pair.entry((m.message.sender, m.message.recipient)) {
722                std::collections::hash_map::Entry::Occupied(mut e) => {
723                    let (count, oldest) = e.get_mut();
724                    *count += 1;
725                    if key < *oldest {
726                        *oldest = key;
727                    }
728                }
729                std::collections::hash_map::Entry::Vacant(e) => {
730                    e.insert((1, key));
731                }
732            }
733        }
734
735        let mut horizons: Vec<DmPairHorizon> = by_pair
736            .into_iter()
737            .filter(|(_, (count, _))| *count >= MAX_DM_MESSAGES_PER_PAIR)
738            .map(
739                |((sender, recipient), (_, oldest_retained))| DmPairHorizon {
740                    sender,
741                    recipient,
742                    oldest_retained,
743                },
744            )
745            .collect();
746        horizons.sort();
747        horizons
748    }
749
750    /// The [`DmRetentionHorizon`] this peer publishes for the given global cap.
751    ///
752    /// Computed as the MINIMUM held key rather than the exact post-trim cutoff,
753    /// and only once the peer is at or over the cap, so it never over-states —
754    /// see [`DmRetentionHorizon`] for why that direction is the safe one.
755    ///
756    /// Does not assume `self.messages` is sorted, and could not rely on it if
757    /// it were: `sort_state` orders by `(sender, recipient, timestamp,
758    /// signature)`, so the room-wide oldest DM is NOT at index 0. `verify` does
759    /// not enforce any ordering either, so a hand-built or hostile full-state
760    /// PUT can arrive in any order. Taking the min is correct regardless.
761    pub fn global_retention_horizon(&self, max_direct_messages: usize) -> DmRetentionHorizon {
762        if max_direct_messages == 0 {
763            return DmRetentionHorizon::Closed;
764        }
765        if self.messages.len() < max_direct_messages {
766            return DmRetentionHorizon::Open;
767        }
768        match self.messages.iter().map(|m| m.order_key()).min() {
769            Some(oldest) => DmRetentionHorizon::OldestRetained(oldest),
770            // Unreachable: len >= max_direct_messages >= 1 means non-empty.
771            // `Open` is the safe fallback (offers more, never withholds).
772            None => DmRetentionHorizon::Open,
773        }
774    }
775
776    /// Drop any DM whose sender or recipient is banned (`banned_ids`),
777    /// or is not a current member of the room (`active_member_ids`,
778    /// owner-implicit). Called by `ChatRoomStateV1::post_apply_cleanup`
779    /// to keep `verify` stable after bans / member churn - see the
780    /// module-level "Interaction with bans" section. Also drops purge
781    /// envelopes belonging to non-members so the state doesn't carry
782    /// signatures from former-members forever.
783    pub fn sweep_after_membership_change(
784        &mut self,
785        owner_id: MemberId,
786        active_member_ids: &HashSet<MemberId>,
787        banned_ids: &HashSet<MemberId>,
788    ) {
789        let alive = |id: MemberId| -> bool {
790            id == owner_id || (active_member_ids.contains(&id) && !banned_ids.contains(&id))
791        };
792        self.messages
793            .retain(|m| alive(m.message.sender) && alive(m.message.recipient));
794        self.purges.retain(|p| alive(p.recipient_id));
795    }
796}
797
798// ---------------------------------------------------------------------------
799// ComposableState impl
800// ---------------------------------------------------------------------------
801
802impl ComposableState for DirectMessagesV1 {
803    type ParentState = ChatRoomStateV1;
804    type Summary = DirectMessagesSummary;
805    type Delta = DirectMessagesDelta;
806    type Parameters = ChatRoomParametersV1;
807
808    fn verify(
809        &self,
810        parent_state: &Self::ParentState,
811        parameters: &Self::Parameters,
812    ) -> Result<(), String> {
813        let owner_id = parameters.owner_id();
814        let members_by_id = parent_state.members.members_by_member_id();
815
816        // ---- purges: signature + cap + duplicate-recipient + version ----
817        let mut seen_recipients: HashSet<MemberId> = HashSet::new();
818        for purges in &self.purges {
819            if !seen_recipients.insert(purges.recipient_id) {
820                return Err(format!(
821                    "DM purges: duplicate envelope for recipient {:?}",
822                    purges.recipient_id
823                ));
824            }
825            if purges.state.version == 0 {
826                return Err(format!(
827                    "DM purges for {:?}: version 0 is reserved as the absent sentinel",
828                    purges.recipient_id
829                ));
830            }
831            if purges.state.purged.len() > MAX_PURGED_TOMBSTONES_PER_RECIPIENT {
832                return Err(format!(
833                    "DM purges for {:?} exceed cap: {} > {}",
834                    purges.recipient_id,
835                    purges.state.purged.len(),
836                    MAX_PURGED_TOMBSTONES_PER_RECIPIENT
837                ));
838            }
839            let recipient_vk =
840                resolve_member_vk(purges.recipient_id, owner_id, parameters, &members_by_id)
841                    .ok_or_else(|| {
842                        format!(
843                            "DM purges: recipient {:?} is not a current member",
844                            purges.recipient_id
845                        )
846                    })?;
847            purges.verify_signature(&recipient_vk, &parameters.owner)?;
848        }
849
850        // Build per-recipient tombstone sets for O(1) lookup during the
851        // message loop.
852        let purges_by_recipient: HashMap<MemberId, HashSet<PurgeToken>> = self
853            .purges
854            .iter()
855            .map(|p| (p.recipient_id, p.state.purged.iter().copied().collect()))
856            .collect();
857
858        // ---- messages: signature + cap + membership + tombstone ----
859        //
860        // Bans are NOT enforced here - see module-level "Interaction
861        // with bans". Banned-participant DMs are removed by
862        // `ChatRoomStateV1::post_apply_cleanup`, so `verify` stays
863        // stable across ban-state changes.
864        let mut per_pair: HashMap<(MemberId, MemberId), usize> = HashMap::new();
865        for msg in &self.messages {
866            if msg.message.ciphertext.len() > MAX_DM_CIPHERTEXT_BYTES {
867                return Err(format!(
868                    "DM ciphertext too large: {} > {}",
869                    msg.message.ciphertext.len(),
870                    MAX_DM_CIPHERTEXT_BYTES
871                ));
872            }
873
874            if msg.message.sender == msg.message.recipient {
875                return Err(format!(
876                    "DM sender and recipient must differ ({:?})",
877                    msg.message.sender
878                ));
879            }
880
881            let sender_vk =
882                resolve_member_vk(msg.message.sender, owner_id, parameters, &members_by_id)
883                    .ok_or_else(|| {
884                        format!("DM sender {:?} is not a current member", msg.message.sender)
885                    })?;
886
887            if resolve_member_vk(msg.message.recipient, owner_id, parameters, &members_by_id)
888                .is_none()
889            {
890                return Err(format!(
891                    "DM recipient {:?} is not a current member",
892                    msg.message.recipient
893                ));
894            }
895
896            msg.verify_signature(&sender_vk, &parameters.owner)?;
897
898            // Tombstone check: if the recipient has purged this signature,
899            // the message must not be present.
900            if let Some(tombstones) = purges_by_recipient.get(&msg.message.recipient) {
901                if tombstones.contains(&msg.purge_token()) {
902                    return Err(format!(
903                        "DM from {:?} to {:?} is present despite being purged",
904                        msg.message.sender, msg.message.recipient
905                    ));
906                }
907            }
908
909            let count = per_pair
910                .entry((msg.message.sender, msg.message.recipient))
911                .or_insert(0);
912            *count += 1;
913            if *count > MAX_DM_MESSAGES_PER_PAIR {
914                return Err(format!(
915                    "DM pair ({:?} -> {:?}) exceeds cap: {} > {}",
916                    msg.message.sender, msg.message.recipient, count, MAX_DM_MESSAGES_PER_PAIR
917                ));
918            }
919        }
920
921        Ok(())
922    }
923
924    /// NOTE: this `summarize` READS `parent_state`, for the global DM cap that
925    /// sizes [`DirectMessagesSummary::global_horizon`] — the same dependency
926    /// [`crate::room_state::message::MessagesV1::summarize`] has on
927    /// `max_recent_messages`, and with the same requirement: callers MUST pass
928    /// the SUMMARIZING peer's own state. Passing a cheap
929    /// `ChatRoomStateV1::default()` sentinel reads the DEFAULT cap instead of
930    /// the room's, understating the horizon and re-opening the resend loop.
931    /// The DM-side pin is
932    /// `dm_global_cap_test::whole_state_gossip_under_the_cap_converges_and_stays_verifiable`
933    /// (the messages-side `merge_uses_room_state_as_parent_so_horizon_is_correct`
934    /// would not catch a DM-specific regression).
935    fn summarize(
936        &self,
937        parent_state: &Self::ParentState,
938        _parameters: &Self::Parameters,
939    ) -> Self::Summary {
940        let message_signatures: BTreeSet<SignatureBytes> = self
941            .messages
942            .iter()
943            .map(|m| SignatureBytes(m.sender_signature.to_bytes()))
944            .collect();
945
946        let purge_versions: Vec<(MemberId, u64)> = {
947            let mut v: Vec<(MemberId, u64)> = self
948                .purges
949                .iter()
950                .map(|p| (p.recipient_id, p.state.version))
951                .collect();
952            v.sort_by_key(|(k, _)| *k);
953            v
954        };
955
956        DirectMessagesSummary {
957            message_signatures,
958            purge_versions,
959            pair_horizons: self.pair_horizons(),
960            global_horizon: self.global_retention_horizon(
961                parent_state
962                    .configuration
963                    .configuration
964                    .effective_max_direct_messages(),
965            ),
966        }
967    }
968
969    fn delta(
970        &self,
971        _parent_state: &Self::ParentState,
972        _parameters: &Self::Parameters,
973        old_state_summary: &Self::Summary,
974    ) -> Option<Self::Delta> {
975        let prior_versions: HashMap<MemberId, u64> =
976            old_state_summary.purge_versions.iter().copied().collect();
977
978        // A DM the receiver's per-pair cap would discard the instant it applied
979        // it must never be offered, or the pair loops forever re-sending it.
980        // See [`DmPairHorizon`].
981        let horizons: HashMap<(MemberId, MemberId), &DmOrderKey> = old_state_summary
982            .pair_horizons
983            .iter()
984            .map(|h| ((h.sender, h.recipient), &h.oldest_retained))
985            .collect();
986
987        let new_messages: Vec<AuthorizedDirectMessage> = self
988            .messages
989            .iter()
990            .filter(|m| {
991                !old_state_summary
992                    .message_signatures
993                    .contains(&SignatureBytes(m.sender_signature.to_bytes()))
994            })
995            .filter(
996                |m| match horizons.get(&(m.message.sender, m.message.recipient)) {
997                    // The receiver's pair is below the cap: it keeps anything.
998                    None => true,
999                    Some(oldest) => m.order_key() > **oldest,
1000                },
1001            )
1002            // Same rule again on the GLOBAL axis: a DM can sit comfortably
1003            // inside its own pair's window and still be the oldest DM in the
1004            // room, so clearing `pair_horizons` is not enough to know the
1005            // receiver will keep it. See [`DmRetentionHorizon`].
1006            .filter(|m| match &old_state_summary.global_horizon {
1007                DmRetentionHorizon::Open => true,
1008                DmRetentionHorizon::OldestRetained(oldest) => m.order_key() > *oldest,
1009                DmRetentionHorizon::Closed => false,
1010            })
1011            .cloned()
1012            .collect();
1013
1014        let advanced_purges: Vec<AuthorizedRecipientPurges> = self
1015            .purges
1016            .iter()
1017            .filter_map(|p| {
1018                let prior = prior_versions.get(&p.recipient_id).copied().unwrap_or(0);
1019                if p.state.version > prior {
1020                    Some(p.clone())
1021                } else {
1022                    None
1023                }
1024            })
1025            .collect();
1026
1027        if new_messages.is_empty() && advanced_purges.is_empty() {
1028            None
1029        } else {
1030            Some(DirectMessagesDelta {
1031                new_messages,
1032                advanced_purges,
1033            })
1034        }
1035    }
1036
1037    fn apply_delta(
1038        &mut self,
1039        parent_state: &Self::ParentState,
1040        parameters: &Self::Parameters,
1041        delta: &Option<Self::Delta>,
1042    ) -> Result<(), String> {
1043        let max_direct_messages = parent_state
1044            .configuration
1045            .configuration
1046            .effective_max_direct_messages();
1047
1048        let Some(delta) = delta else {
1049            // Even when no delta arrived, enforce the caps and re-sort. The
1050            // caps run unconditionally (as `MessagesV1::apply_delta` runs
1051            // `max_recent_messages`) so a state that arrived over-cap by a
1052            // path that skips this function — a full-state PUT, or the #292
1053            // migration PUT carrying a legacy set larger than the room's
1054            // current cap — converges down instead of sitting over-cap until
1055            // the next DM happens to arrive.
1056            enforce_caps_and_sort(self, max_direct_messages);
1057            return Ok(());
1058        };
1059
1060        let owner_id = parameters.owner_id();
1061        let members_by_id = parent_state.members.members_by_member_id();
1062
1063        // ---- 1. Apply purge advances first ----
1064        //
1065        // The recipient is the sole signer of their own envelope, so
1066        // strict-monotonic `version` is the entire ordering rule. A
1067        // duplicate-version with different content is a protocol error
1068        // (the same signer wouldn't sign two different envelopes at
1069        // the same version). Each new version's purge list MUST be a
1070        // superset of the previous version's list (no un-purging).
1071        for advance in &delta.advanced_purges {
1072            if advance.state.version == 0 {
1073                return Err(format!(
1074                    "DM purges for {:?}: version 0 is reserved as the absent sentinel",
1075                    advance.recipient_id
1076                ));
1077            }
1078            if advance.state.purged.len() > MAX_PURGED_TOMBSTONES_PER_RECIPIENT {
1079                return Err(format!(
1080                    "DM purges for {:?} exceed cap: {} > {}",
1081                    advance.recipient_id,
1082                    advance.state.purged.len(),
1083                    MAX_PURGED_TOMBSTONES_PER_RECIPIENT
1084                ));
1085            }
1086            let recipient_vk =
1087                match resolve_member_vk(advance.recipient_id, owner_id, parameters, &members_by_id)
1088                {
1089                    Some(vk) => vk,
1090                    // Recipient is either not yet a member on this peer
1091                    // (member-add and purge envelope arriving in
1092                    // separate deltas in the wrong order) or no longer
1093                    // a member at all. Silent-drop; a subsequent
1094                    // summary-driven sync will deliver the envelope
1095                    // once the member entry is present.
1096                    None => continue,
1097                };
1098            advance.verify_signature(&recipient_vk, &parameters.owner)?;
1099
1100            let pos = self
1101                .purges
1102                .iter()
1103                .position(|p| p.recipient_id == advance.recipient_id);
1104            match pos {
1105                Some(idx) => {
1106                    let current = &self.purges[idx];
1107                    if current.state.version > advance.state.version {
1108                        continue; // already up to date
1109                    }
1110                    if current.state.version == advance.state.version {
1111                        // Same-version-different-content is a recipient
1112                        // signing bug (a multi-device user who didn't
1113                        // coordinate version numbers, or a malicious
1114                        // client). Drop the incoming envelope silently
1115                        // - first-seen wins. Returning Err here would
1116                        // poison the whole delta merge, taking
1117                        // unrelated `new_messages` and other recipients'
1118                        // `advanced_purges` with it. The recipient is
1119                        // expected to bump the version to converge.
1120                        continue;
1121                    }
1122                    // Monotonic-content: new must be a superset of old.
1123                    let current_set: HashSet<PurgeToken> =
1124                        current.state.purged.iter().copied().collect();
1125                    let advance_set: HashSet<PurgeToken> =
1126                        advance.state.purged.iter().copied().collect();
1127                    if !current_set.is_subset(&advance_set) {
1128                        // Recipient is trying to un-purge tokens by
1129                        // shrinking the list across a version bump.
1130                        // Silent-drop the malformed envelope rather
1131                        // than failing the whole delta.
1132                        continue;
1133                    }
1134                    self.purges[idx] = advance.clone();
1135                }
1136                None => {
1137                    self.purges.push(advance.clone());
1138                }
1139            }
1140        }
1141
1142        // ---- 2. Apply new messages, gated by the up-to-date purges ----
1143        let mut existing_sigs: HashSet<SignatureBytes> = self
1144            .messages
1145            .iter()
1146            .map(|m| SignatureBytes(m.sender_signature.to_bytes()))
1147            .collect();
1148
1149        let purges_index: HashMap<MemberId, HashSet<PurgeToken>> = self
1150            .purges
1151            .iter()
1152            .map(|p| (p.recipient_id, p.state.purged.iter().copied().collect()))
1153            .collect();
1154
1155        for msg in &delta.new_messages {
1156            if msg.message.ciphertext.len() > MAX_DM_CIPHERTEXT_BYTES {
1157                continue; // silently drop oversized messages
1158            }
1159
1160            if msg.message.sender == msg.message.recipient {
1161                continue; // silently drop self-DMs
1162            }
1163
1164            // Dedup against current state - and against earlier
1165            // messages already accepted in this same delta.
1166            let sig = SignatureBytes(msg.sender_signature.to_bytes());
1167            if existing_sigs.contains(&sig) {
1168                continue;
1169            }
1170
1171            let sender_vk =
1172                match resolve_member_vk(msg.message.sender, owner_id, parameters, &members_by_id) {
1173                    Some(vk) => vk,
1174                    None => continue, // sender no longer a member - silently drop
1175                };
1176
1177            if resolve_member_vk(msg.message.recipient, owner_id, parameters, &members_by_id)
1178                .is_none()
1179            {
1180                continue; // recipient no longer a member - silently drop
1181            }
1182
1183            if msg.verify_signature(&sender_vk, &parameters.owner).is_err() {
1184                continue; // bad signature - silently drop
1185            }
1186
1187            // Tombstone gate.
1188            if let Some(tombstones) = purges_index.get(&msg.message.recipient) {
1189                if tombstones.contains(&msg.purge_token()) {
1190                    continue;
1191                }
1192            }
1193
1194            // The per-pair cap is NOT applied here. It used to be, as
1195            // first-come-wins ("already at the cap? drop the arrival"), and
1196            // that was wrong twice over:
1197            //
1198            //  * Convergence: which messages a peer ends up holding depended on
1199            //    ARRIVAL ORDER, so two peers could sit at the cap with
1200            //    different sets, each re-offering what the other discards, and
1201            //    `delta` never emptied. See [`DmPairHorizon`].
1202            //  * Behaviour: once a pair filled up, every later DM from that
1203            //    sender was silently dropped forever.
1204            //
1205            // Accept everything that passes authorisation and let
1206            // `trim_pairs_to_cap` below keep the NEWEST `MAX_DM_MESSAGES_PER_PAIR`
1207            // — a deterministic function of the union, so peers converge.
1208            existing_sigs.insert(sig);
1209            self.messages.push(msg.clone());
1210        }
1211
1212        // ---- 3. Drop any existing messages that are now tombstoned ----
1213        // This handles the case where a purge envelope arrives in the
1214        // same delta as (or after) a message-bearing delta that already
1215        // installed the message.
1216        let purges_after: HashMap<MemberId, HashSet<PurgeToken>> = self
1217            .purges
1218            .iter()
1219            .map(|p| (p.recipient_id, p.state.purged.iter().copied().collect()))
1220            .collect();
1221        self.messages.retain(|m| {
1222            !purges_after
1223                .get(&m.message.recipient)
1224                .is_some_and(|set| set.contains(&m.purge_token()))
1225        });
1226
1227        // ---- 4. Enforce the caps, newest-first, then sort ----
1228        enforce_caps_and_sort(self, max_direct_messages);
1229
1230        Ok(())
1231    }
1232}
1233
1234/// Apply both retention caps and restore the canonical stored order.
1235///
1236/// The per-pair trim runs FIRST, and the order is load-bearing for RETENTION,
1237/// not for legality: either order leaves every pair legal, because whichever
1238/// trim runs last only shrinks the set further. What the swapped order loses is
1239/// messages. Running the global trim first spends the global budget on DMs that
1240/// the per-pair trim is about to discard anyway, so the surviving set can end up
1241/// well BELOW the global cap while DMs that would have fitted were dropped —
1242/// e.g. one busy pair holding the room's newest 150 DMs under a global cap of
1243/// 200 yields 200 retained pair-first but only 150 global-first. Pinned by
1244/// `pair_trim_runs_before_the_global_trim_so_the_budget_is_not_wasted`.
1245///
1246/// Every step is a pure function of the held set, so the composition is too:
1247/// two peers reaching the same union converge to the same state, and running
1248/// this twice changes nothing after the first pass (idempotent).
1249fn enforce_caps_and_sort(s: &mut DirectMessagesV1, max_direct_messages: usize) {
1250    dedup_by_signature(s);
1251    trim_pairs_to_cap(s);
1252    trim_to_global_cap(s, max_direct_messages);
1253    sort_state(s);
1254}
1255
1256/// Drop duplicate entries, keeping the first occurrence of each signature.
1257///
1258/// `apply_delta` already dedupes on insert, so a peer that only ever merges
1259/// deltas never holds duplicates. `verify` does NOT reject them, though — it
1260/// counts duplicates toward the per-pair cap but has no distinct-signature
1261/// check — so a hand-built or hostile full-state PUT can carry them, and
1262/// `validate_state` is `verify`.
1263///
1264/// That matters because both trims key off [`DmOrderKey`], and duplicate
1265/// entries carry IDENTICAL keys (same timestamp, same signature). Without this,
1266/// [`trim_to_global_cap`]'s cutoff can land on a repeated key, in which case
1267/// `retain(>= cutoff)` drops nothing and the peer sits permanently over the cap
1268/// — the exact failure the cap exists to prevent. Deduping first makes the
1269/// "keys are unique" premise TRUE rather than assumed.
1270///
1271/// Removal-only, so it cannot make a verifying state fail `verify`, and it is a
1272/// pure function of the held set, so it preserves convergence.
1273fn dedup_by_signature(s: &mut DirectMessagesV1) {
1274    let mut seen: HashSet<SignatureBytes> = HashSet::with_capacity(s.messages.len());
1275    s.messages
1276        .retain(|m| seen.insert(SignatureBytes(m.sender_signature.to_bytes())));
1277}
1278
1279/// Keep only the newest [`MAX_DM_MESSAGES_PER_PAIR`] messages in each ordered
1280/// `(sender, recipient)` pair, by [`AuthorizedDirectMessage::order_key`].
1281///
1282/// A pure function of the held set, so every peer that ends up with the same
1283/// union trims to the same result regardless of the order the messages arrived
1284/// in. That determinism is what makes the pair converge; the paired
1285/// [`DmPairHorizon`] in the summary is what stops a sender re-offering the
1286/// entries this drops.
1287fn trim_pairs_to_cap(s: &mut DirectMessagesV1) {
1288    // No single pair can exceed the cap while the whole set is within it.
1289    if s.messages.len() <= MAX_DM_MESSAGES_PER_PAIR {
1290        return;
1291    }
1292
1293    let mut by_pair: HashMap<(MemberId, MemberId), Vec<(DmOrderKey, SignatureBytes)>> =
1294        HashMap::new();
1295    for m in &s.messages {
1296        by_pair
1297            .entry((m.message.sender, m.message.recipient))
1298            .or_default()
1299            .push((m.order_key(), SignatureBytes(m.sender_signature.to_bytes())));
1300    }
1301
1302    let mut dropped: HashSet<SignatureBytes> = HashSet::new();
1303    for entries in by_pair.values_mut() {
1304        if entries.len() <= MAX_DM_MESSAGES_PER_PAIR {
1305            continue;
1306        }
1307        // Ascending, so the oldest — the ones that go — are at the front.
1308        entries.sort_by(|a, b| a.0.cmp(&b.0));
1309        let excess = entries.len() - MAX_DM_MESSAGES_PER_PAIR;
1310        dropped.extend(entries.iter().take(excess).map(|(_, sig)| *sig));
1311    }
1312
1313    if !dropped.is_empty() {
1314        s.messages
1315            .retain(|m| !dropped.contains(&SignatureBytes(m.sender_signature.to_bytes())));
1316    }
1317}
1318
1319/// Keep only the newest `max_direct_messages` messages across the WHOLE set, by
1320/// [`AuthorizedDirectMessage::order_key`] — the global counterpart of
1321/// [`trim_pairs_to_cap`], added for freenet/river#519.
1322///
1323/// Ordering by `(timestamp, signature)` room-wide is a strict total order
1324/// (signatures are unique per message), so "newest N" is unambiguous. Like the
1325/// per-pair trim this is a pure function of the held set, so every peer that
1326/// ends up with the same union trims to the same result regardless of arrival
1327/// order; the paired [`DmRetentionHorizon`] in the summary is what stops a
1328/// sender re-offering the entries this drops.
1329///
1330/// Runs AFTER [`trim_pairs_to_cap`], so the pair cap can never be violated by
1331/// the global trim keeping a message the pair cap had already discarded.
1332fn trim_to_global_cap(s: &mut DirectMessagesV1, max_direct_messages: usize) {
1333    if s.messages.len() <= max_direct_messages {
1334        return;
1335    }
1336    if max_direct_messages == 0 {
1337        s.messages.clear();
1338        return;
1339    }
1340
1341    // Select the cutoff by ranking keys rather than sorting `messages` itself:
1342    // `sort_state` owns the stored order, and reordering here would silently
1343    // couple the two.
1344    let mut keys: Vec<DmOrderKey> = s.messages.iter().map(|m| m.order_key()).collect();
1345    // `select_nth_unstable` puts the element that WOULD be at this index in
1346    // sorted order there, with everything smaller before it — exactly the
1347    // "drop the oldest `excess`" boundary, in O(n).
1348    let excess = keys.len() - max_direct_messages;
1349    keys.select_nth_unstable(excess);
1350    let cutoff = keys[excess].clone();
1351
1352    // Strictly-below-cutoff goes; the cutoff key itself and everything above it
1353    // stays. Keys are unique — `enforce_caps_and_sort` runs `dedup_by_signature`
1354    // first, and a signature is unique per message — so this keeps exactly
1355    // `max_direct_messages`. Without that dedup a repeated cutoff key would
1356    // make this drop nothing, leaving the peer permanently over cap.
1357    s.messages.retain(|m| m.order_key() >= cutoff);
1358}
1359
1360fn sort_state(s: &mut DirectMessagesV1) {
1361    s.messages.sort_by(|a, b| {
1362        a.message
1363            .sender
1364            .cmp(&b.message.sender)
1365            .then(a.message.recipient.cmp(&b.message.recipient))
1366            .then(a.message.timestamp.cmp(&b.message.timestamp))
1367            .then(
1368                a.sender_signature
1369                    .to_bytes()
1370                    .cmp(&b.sender_signature.to_bytes()),
1371            )
1372    });
1373    s.purges.sort_by_key(|p| p.recipient_id);
1374}
1375
1376// ---------------------------------------------------------------------------
1377// Summary / Delta
1378// ---------------------------------------------------------------------------
1379
1380#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
1381pub struct DirectMessagesSummary {
1382    /// Raw Ed25519 signatures of messages already held locally.
1383    ///
1384    /// `BTreeSet` (not `HashSet`) so the ciborium-serialized summary bytes are
1385    /// deterministic: freenet-core byte-compares `summarize_state` output for
1386    /// staleness, and a `HashSet` iterates in a per-process-random order,
1387    /// making two identical DM sets summarize to different bytes → spurious
1388    /// anti-entropy heals. See `.claude/rules/contract-summary-determinism.md`
1389    /// and freenet/freenet-core#4857.
1390    #[serde(default)]
1391    pub message_signatures: BTreeSet<SignatureBytes>,
1392
1393    /// Per-recipient purge-envelope version known locally. Stored as a
1394    /// sorted `Vec` (not `HashMap`) so the type round-trips through
1395    /// `serde_json` - `MemberId` is a struct and `serde_json` rejects
1396    /// it as a map key.
1397    #[serde(default)]
1398    pub purge_versions: Vec<(MemberId, u64)>,
1399
1400    /// One entry per ordered `(sender, recipient)` pair that has reached
1401    /// [`MAX_DM_MESSAGES_PER_PAIR`], carrying the oldest message that pair
1402    /// still holds. See [`DmPairHorizon`] for why this is here.
1403    ///
1404    /// Sorted (by the whole tuple) and stored as a `Vec` for the same two
1405    /// reasons as `purge_versions`: canonical bytes for freenet-core's
1406    /// summary comparison, and `serde_json` compatibility.
1407    #[serde(default)]
1408    pub pair_horizons: Vec<DmPairHorizon>,
1409
1410    /// The whole-set counterpart of `pair_horizons`, for the GLOBAL cap
1411    /// (`Configuration::effective_max_direct_messages`). See
1412    /// [`DmRetentionHorizon`].
1413    ///
1414    /// `#[serde(default)]` yields [`DmRetentionHorizon::Open`], which is the
1415    /// safe direction: a peer whose summary predates this field is treated as
1416    /// accepting everything, so nothing is silently withheld from it.
1417    #[serde(default)]
1418    pub global_horizon: DmRetentionHorizon,
1419}
1420
1421/// How much appetite a peer has for older direct messages GLOBALLY, published
1422/// in [`DirectMessagesSummary`] so a sender never offers a DM the receiver
1423/// would discard the instant it applied it.
1424///
1425/// # Why this exists, separately from [`DmPairHorizon`]
1426///
1427/// [`DmPairHorizon`] solves the identical problem one ordered pair at a time,
1428/// for the per-pair cap [`MAX_DM_MESSAGES_PER_PAIR`]. The global cap
1429/// introduced for freenet/river#519 prunes across ALL pairs, so it makes the
1430/// merge non-monotonic along an axis no per-pair horizon can describe: a DM
1431/// can be well inside its own pair's window and still be the oldest DM in the
1432/// room. Without this second horizon, `delta` re-offers exactly those DMs on
1433/// every fan-out, the receiver re-prunes them, neither summary changes, and
1434/// the pair loops forever — the same failure that drove the 2026-07-25
1435/// bandwidth incident, at up to [`MAX_DM_CIPHERTEXT_BYTES`] per message.
1436///
1437/// # Why it terminates
1438///
1439/// [`DmRetentionHorizon::OldestRetained`] is the smallest [`DmOrderKey`] the
1440/// peer currently holds, published only once it is AT the global cap. A sender
1441/// offers only strictly-greater keys. A peer BELOW the cap publishes `Open` and
1442/// discards nothing globally, so its signature set only grows.
1443///
1444/// Note the global horizon does NOT necessarily move on every accepted DM, and
1445/// the argument must not claim it does: when the arrival's own pair is at
1446/// [`MAX_DM_MESSAGES_PER_PAIR`], `trim_pairs_to_cap` runs first, drops that
1447/// pair's oldest, and returns the set to exactly the cap — so
1448/// [`trim_to_global_cap`] early-returns and this horizon is unchanged.
1449///
1450/// Termination comes from the two horizons TOGETHER, via the invariant that
1451/// every message either trim drops is strictly below at least one horizon the
1452/// peer publishes immediately afterwards. So an accepted DM always advances
1453/// something: either the set grew, or the pair trim dropped that pair's oldest
1454/// (that pair's horizon rose), or the global trim dropped the room's oldest
1455/// (this horizon rose). A dropped message is never re-offered, because it now
1456/// sits below a published horizon. Every exchange therefore grows a bounded set
1457/// or strictly advances a bounded key, so there are no cycles.
1458///
1459/// Deliberately conservative in the same direction as
1460/// [`crate::room_state::message::RetentionHorizon`]: publishing the oldest
1461/// HELD key rather than the exact post-merge cutoff can cost one extra round,
1462/// whereas over-stating would silently withhold DMs the peer would have kept.
1463#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
1464pub enum DmRetentionHorizon {
1465    /// The peer holds fewer than the global cap; it retains anything.
1466    #[default]
1467    Open,
1468    /// The peer is at (or over) the global cap and holds nothing ordering
1469    /// before this key. Anything at or below it is discarded on arrival.
1470    OldestRetained(DmOrderKey),
1471    /// The cap is `0`: the peer retains no direct messages at all.
1472    ///
1473    /// `AuthorizedConfigurationV1::apply_delta` rejects a zero cap, but
1474    /// `verify` does not, so an owner-signed zero can still arrive on the
1475    /// full-state path. Represented explicitly rather than folded into
1476    /// `OldestRetained` so the sender suppresses the delta instead of looping
1477    /// against a peer that keeps nothing.
1478    Closed,
1479}
1480
1481/// The retention horizon for one ordered `(sender, recipient)` pair.
1482///
1483/// # Why this exists
1484///
1485/// [`DirectMessagesV1::apply_delta`] caps each ordered pair at
1486/// [`MAX_DM_MESSAGES_PER_PAIR`], which makes the merge non-monotonic in exactly
1487/// the way [`crate::room_state::message::RetentionHorizon`] documents for room
1488/// messages. Without a horizon, `delta` is a pure signature-set difference: a
1489/// peer whose window for the pair differs from its neighbour's re-offers DMs
1490/// the neighbour discards, on every fan-out, forever. At up to 32 KiB of
1491/// ciphertext each that is a heavy loop.
1492///
1493/// `oldest_retained` is the smallest key the pair currently holds, published
1494/// only once the pair is at capacity. A sender offers only strictly-greater
1495/// keys; applying one pushes the pair over the cap, so the trim drops at least
1496/// the horizon message itself and the horizon strictly increases. A pair below
1497/// capacity publishes no entry at all and accepts anything.
1498#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
1499pub struct DmPairHorizon {
1500    pub sender: MemberId,
1501    pub recipient: MemberId,
1502    pub oldest_retained: DmOrderKey,
1503}
1504
1505/// Retention order for direct messages within one pair: `(timestamp,
1506/// signature)`, matching [`sort_state`]'s within-pair ordering. The signature
1507/// breaks timestamp ties deterministically.
1508#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
1509pub struct DmOrderKey {
1510    pub timestamp: u64,
1511    pub signature: SignatureBytes,
1512}
1513
1514#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
1515pub struct DirectMessagesDelta {
1516    #[serde(default)]
1517    pub new_messages: Vec<AuthorizedDirectMessage>,
1518
1519    #[serde(default)]
1520    pub advanced_purges: Vec<AuthorizedRecipientPurges>,
1521}
1522
1523// ---------------------------------------------------------------------------
1524// Internal helpers
1525// ---------------------------------------------------------------------------
1526
1527/// Resolve a [`MemberId`] to its `VerifyingKey`. The owner is treated
1528/// as an implicit member: their key lives in `parameters.owner`, not
1529/// in `parent_state.members`.
1530fn resolve_member_vk(
1531    id: MemberId,
1532    owner_id: MemberId,
1533    parameters: &ChatRoomParametersV1,
1534    members_by_id: &HashMap<MemberId, &AuthorizedMember>,
1535) -> Option<VerifyingKey> {
1536    if id == owner_id {
1537        Some(parameters.owner)
1538    } else {
1539        members_by_id.get(&id).map(|m| m.member.member_vk)
1540    }
1541}
1542
1543#[cfg(test)]
1544mod tests {
1545    // Unit tests for this module live in
1546    // `common/tests/direct_messages_test.rs` so they exercise the
1547    // public API the same way downstream consumers will.
1548}