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(¶meters.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(×tamp.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, ¶meters.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, ¶meters.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, ¶meters.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, ¶meters.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}