Skip to main content

dig_message/
stream.rs

1//! WU4: the streaming state machine (SPEC §3) — the ordered, backpressured, cancelable, bidirectionally
2//! half-closable channel every long-lived directed exchange rides.
3//!
4//! ## What this module owns
5//! - [`StreamSession`] — the pure, crypto-free per-stream state machine: the OPEN → OPEN_ACK → DATA →
6//!   CLOSE lifecycle, strictly-monotonic per-direction `seq` (gap / reorder / replay → REJECT), credit
7//!   backpressure, and independent per-direction half-close + RESET (SPEC §3). Pure so every transition
8//!   is unit-testable without touching crypto.
9//! - [`StreamEndpoint`] — the per-peer registry that wraps the seal around the state machine: it seals
10//!   EVERY frame with a FRESH ephemeral via [`seal_message`] (SPEC §5.1 forward secrecy — never a fixed
11//!   or session-wide ephemeral, so ChaCha20Poly1305 nonce-reuse is impossible), opens + fully verifies
12//!   every inbound frame via [`open_message`], enforces the [`MAX_CONCURRENT_STREAMS`] per-peer cap
13//!   (SPEC §3 DoS gate), and RESETs a stream on any failed verify / protocol violation (defense-in-depth
14//!   gate item — a garbage or forged frame never poisons a stream silently).
15//!
16//! ## Per-frame sealing (CUSTODY-critical)
17//! Each frame is an independent WU2 sealed envelope: BLS-G2 signed over the transcript (which binds the
18//! `stream_frame` + `stream_seq`, SPEC §5.1) and G1-DHKEM auth-sealed under its OWN fresh ephemeral.
19//! Cross-session frame replay is rejected two ways: the persistent [`ReplayGuard`] (per-sender monotonic
20//! counter + freshness window) drops a captured frame re-injected later, and a frame's `correlation_id`
21//! must match a live session — a frame from another stream/session addresses a different `correlation_id`
22//! and finds no session.
23//!
24//! ## Transport ordering (the layer BELOW)
25//! The reliable mTLS-WS transport (dig-gossip) delivers frames in order and its bounded
26//! `StreamReassembler` (256 chunks / 4 MiB per stream) restores order across any out-of-order transport
27//! delivery. That reassembler is a SINGLE-stream transport primitive that sits UNDER dig-message and is
28//! wired at the dig-node integration layer (WU6); dig-message does not depend on the heavy dig-gossip
29//! crate (it must stay wasm-compilable + crates.io-publishable). This state machine is the layer ABOVE:
30//! it enforces the `seq` contract end-to-end and bounds the number of CONCURRENT streams — exactly the
31//! responsibility the reassembler's own docs assign to "the streaming state machine (WU4)".
32
33use std::collections::HashMap;
34
35use chia_bls::SecretKey;
36use chia_protocol::Bytes32;
37
38use crate::constants::MAX_CONCURRENT_STREAMS;
39use crate::envelope::{DigMessageEnvelope, InteractionShape, StreamFrame, StreamHeader};
40use crate::error::{MessageError, Result};
41use crate::replay::ReplayGuard;
42use crate::seal::{open_message, seal_message, SealParams};
43
44/// Which side opened the stream — the initiator sends OPEN and awaits OPEN_ACK; the responder receives
45/// OPEN and replies OPEN_ACK (SPEC §3 handshake).
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum Role {
48    Initiator,
49    Responder,
50}
51
52/// The OPEN/OPEN_ACK handshake position (SPEC §3): `Opening` until the ACK completes, then `Established`.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum Handshake {
55    Opening,
56    Established,
57}
58
59/// The observable lifecycle position of a stream (SPEC §3), derived from the half-close + reset flags.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum StreamState {
62    /// OPEN sent/received, awaiting the OPEN_ACK that completes the handshake.
63    Opening,
64    /// Both directions open — DATA / CREDIT may flow.
65    Open,
66    /// We sent CLOSE; the peer may still send until it also CLOSEs (SPEC §3 half-close).
67    HalfClosedLocal,
68    /// The peer sent CLOSE; we may still send until we also CLOSE (SPEC §3 half-close).
69    HalfClosedRemote,
70    /// Both directions closed, or the stream was RESET (SPEC §3).
71    Closed,
72}
73
74/// The result of a pure state-machine transition on an inbound frame — the crypto-free classification
75/// [`StreamEndpoint::accept`] pairs with the opened payload to build the public [`StreamEvent`].
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum Accepted {
78    /// OPEN_ACK completed the handshake (initiator side).
79    Established,
80    /// An in-order DATA chunk (its bytes travel separately, from the opened seal).
81    Data,
82    /// A CREDIT grant of `n` additional DATA frames for our sending direction.
83    Credit(u64),
84    /// The peer half-closed its sending direction.
85    RemoteClosed,
86    /// The peer acknowledged our CLOSE.
87    CloseAcked,
88    /// The peer RESET the stream (immediate abort).
89    Reset,
90}
91
92/// A verified, in-order stream event delivered to the caller by [`StreamEndpoint::accept`] (SPEC §3).
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum StreamEvent {
95    /// A new inbound stream OPENed by the peer — the caller should reply with
96    /// [`StreamEndpoint::open_ack`] to complete the handshake.
97    Opened,
98    /// Our OPEN was acknowledged; the stream is now fully open (initiator side).
99    Established,
100    /// An in-order, verified, decompressed data chunk.
101    Data(Vec<u8>),
102    /// The peer granted `n` more DATA credits for our sending direction (backpressure relief).
103    CreditGranted(u64),
104    /// The peer half-closed its sending direction; it will send no more DATA (SPEC §3 half-close).
105    RemoteClosed,
106    /// The peer acknowledged our CLOSE.
107    CloseAcked,
108    /// The peer RESET the stream — it is aborted (SPEC §3 cancel).
109    PeerReset,
110}
111
112/// The outcome of [`StreamEndpoint::accept`] (SPEC §3, RESET-on-failed-verify gate item #1162).
113///
114/// The security purpose of "RESET-on-failed-verify" is to NEVER deliver corrupt data and NEVER let a bad
115/// frame poison a stream — **dropping the frame fully satisfies that**. Emitting a *signed* RESET is a
116/// separate, RESTRICTED action, because a RESET is itself a real non-replayable frame: broadcasting one
117/// in response to unauthenticated or duplicate input lets the untrusted relay (§5.4) weaponize it —
118/// a self-sustaining RESET reflection storm (inject-only), or tearing down a healthy stream by replaying
119/// one frame. So a RESET must NEVER beget a RESET. The three outcomes:
120#[derive(Debug, PartialEq, Eq)]
121pub enum StreamAccept {
122    /// A verified, in-order event.
123    Event(StreamEvent),
124    /// The frame was rejected and SILENTLY DROPPED — nothing is transmitted. This covers every
125    /// unauthenticated or non-actionable input: a failed open/verify (bad seal / bad signature /
126    /// replay / expiry / unresolvable sender), a non-stream or unknown-kind frame, an inbound RESET,
127    /// and any frame addressing an unknown stream. A live session, if any, is left UNTOUCHED (a garbage
128    /// or replayed frame never tears down a healthy stream). `cause` records why.
129    Dropped {
130        /// Why the frame was dropped.
131        cause: MessageError,
132    },
133    /// The state machine rejected an AUTHENTICATED frame on a KNOWN stream (an ordering/credit/half-close
134    /// violation by the verified peer, or the concurrent-stream cap) — send this RESET and consider the
135    /// stream aborted. This is the ONLY transmitting rejection, and it is safe: the frame was
136    /// cryptographically authenticated, so it cannot be forged or replayed by the relay, and the peer
137    /// receiving the RESET for its own live/opening stream tears it down without re-RESETting (no storm).
138    Reset {
139        /// The sealed RESET frame to transmit to the peer (boxed — it dwarfs the `Event` variant).
140        frame: Box<DigMessageEnvelope>,
141        /// Why the frame was rejected.
142        cause: MessageError,
143    },
144}
145
146/// The pure, crypto-free per-stream state machine (SPEC §3). One instance tracks BOTH directions of one
147/// stream: our sending half (`send_*`) and the peer's sending half we receive (`recv_*`), with
148/// independent half-close so either side can finish sending while the other continues.
149#[derive(Debug)]
150pub struct StreamSession {
151    role: Role,
152    handshake: Handshake,
153    /// The next `seq` we will stamp on an outbound DATA frame (strictly monotonic from 0).
154    send_seq: u64,
155    /// The next inbound DATA `seq` we require (strictly monotonic from 0; any other value is a
156    /// gap / reorder / replay → REJECT, SPEC §3).
157    recv_seq: u64,
158    /// DATA frames we may still send before needing more credit (granted by the peer, SPEC §3).
159    send_credit: u64,
160    /// DATA frames we still permit the peer to send before it needs more credit (the window WE granted).
161    recv_window_remaining: u64,
162    /// We sent CLOSE — our sending direction is done.
163    local_closed: bool,
164    /// The peer sent CLOSE — its sending direction is done.
165    remote_closed: bool,
166    /// The stream was RESET (either side) — a hard, immediate abort.
167    reset: bool,
168}
169
170impl StreamSession {
171    /// A fresh initiator session: we sent OPEN advertising `recv_window` credits for the peer→us
172    /// direction, and await OPEN_ACK before sending DATA (SPEC §3).
173    fn initiator(recv_window: u32) -> Self {
174        Self {
175            role: Role::Initiator,
176            handshake: Handshake::Opening,
177            send_seq: 0,
178            recv_seq: 0,
179            send_credit: 0,
180            recv_window_remaining: u64::from(recv_window),
181            local_closed: false,
182            remote_closed: false,
183            reset: false,
184        }
185    }
186
187    /// A fresh responder session created from a received OPEN carrying `granted_credit` DATA credits for
188    /// our sending direction; the handshake completes when we send OPEN_ACK (SPEC §3).
189    fn responder(granted_credit: u32) -> Self {
190        Self {
191            role: Role::Responder,
192            handshake: Handshake::Opening,
193            send_seq: 0,
194            recv_seq: 0,
195            send_credit: u64::from(granted_credit),
196            recv_window_remaining: 0,
197            local_closed: false,
198            remote_closed: false,
199            reset: false,
200        }
201    }
202
203    /// The observable [`StreamState`] (SPEC §3), derived from the handshake + half-close + reset flags.
204    #[must_use]
205    pub fn state(&self) -> StreamState {
206        if self.reset || (self.local_closed && self.remote_closed) {
207            return StreamState::Closed;
208        }
209        if self.handshake == Handshake::Opening {
210            return StreamState::Opening;
211        }
212        match (self.local_closed, self.remote_closed) {
213            (true, false) => StreamState::HalfClosedLocal,
214            (false, true) => StreamState::HalfClosedRemote,
215            _ => StreamState::Open,
216        }
217    }
218
219    /// Whether the stream is fully closed (both halves closed, or RESET) and may be dropped.
220    #[must_use]
221    pub fn is_closed(&self) -> bool {
222        self.state() == StreamState::Closed
223    }
224
225    /// DATA credits remaining for our sending direction (SPEC §3 backpressure).
226    #[must_use]
227    pub fn send_credit(&self) -> u64 {
228        self.send_credit
229    }
230
231    /// Complete the responder handshake by sending OPEN_ACK, advertising `recv_window` credits for the
232    /// peer→us direction (SPEC §3). Returns the header to seal.
233    ///
234    /// # Errors
235    /// [`MessageError::StreamProtocol`] if called by an initiator or when not in the opening handshake.
236    fn build_open_ack(&mut self, recv_window: u32) -> Result<StreamHeader> {
237        if self.role != Role::Responder || self.handshake != Handshake::Opening {
238            return Err(MessageError::StreamProtocol(
239                "OPEN_ACK only from a responder mid-handshake",
240            ));
241        }
242        self.handshake = Handshake::Established;
243        self.recv_window_remaining = u64::from(recv_window);
244        Ok(header(StreamFrame::OpenAck, 0, recv_window))
245    }
246
247    /// Stamp + reserve credit for one outbound DATA frame (SPEC §3). Returns the header to seal.
248    ///
249    /// # Errors
250    /// [`MessageError::StreamProtocol`] if the stream is not established, our sending direction is
251    /// already closed, or we have no send credit (backpressure — wait for a CREDIT grant).
252    fn build_data(&mut self) -> Result<StreamHeader> {
253        if self.handshake != Handshake::Established {
254            return Err(MessageError::StreamProtocol(
255                "DATA before the stream is established",
256            ));
257        }
258        if self.local_closed {
259            return Err(MessageError::StreamProtocol("DATA after our own CLOSE"));
260        }
261        if self.send_credit == 0 {
262            return Err(MessageError::StreamProtocol(
263                "DATA exceeds the granted credit window",
264            ));
265        }
266        self.send_credit -= 1;
267        let seq = self.send_seq;
268        self.send_seq += 1;
269        Ok(header(StreamFrame::Data, seq, 0))
270    }
271
272    /// Grant the peer `n` more DATA credits for its sending direction (SPEC §3 backpressure relief).
273    /// Returns the CREDIT header to seal.
274    ///
275    /// # Errors
276    /// [`MessageError::StreamProtocol`] if the stream is not established.
277    fn build_credit(&mut self, n: u32) -> Result<StreamHeader> {
278        if self.handshake != Handshake::Established {
279            return Err(MessageError::StreamProtocol(
280                "CREDIT before the stream is established",
281            ));
282        }
283        self.recv_window_remaining = self.recv_window_remaining.saturating_add(u64::from(n));
284        Ok(header(StreamFrame::Credit, 0, n))
285    }
286
287    /// Half-close our sending direction (SPEC §3). Returns the CLOSE header to seal.
288    ///
289    /// # Errors
290    /// [`MessageError::StreamProtocol`] if our direction is already closed.
291    fn build_close(&mut self) -> Result<StreamHeader> {
292        if self.local_closed {
293            return Err(MessageError::StreamProtocol("CLOSE after our own CLOSE"));
294        }
295        self.local_closed = true;
296        Ok(header(StreamFrame::Close, 0, 0))
297    }
298
299    /// Abort the stream immediately from our side (SPEC §3 cancel). Returns the RESET header to seal.
300    fn build_reset(&mut self) -> StreamHeader {
301        self.reset = true;
302        header(StreamFrame::Reset, 0, 0)
303    }
304
305    /// Validate + apply an inbound frame against the current state (SPEC §3). Pure: it mutates only the
306    /// state machine and classifies the transition; the caller pairs [`Accepted::Data`] with the opened
307    /// payload. OPEN is handled by the registry (it creates the session), never here.
308    ///
309    /// # Errors
310    /// [`MessageError::StreamProtocol`] for any illegal transition — an out-of-order/gap/replayed seq, a
311    /// DATA beyond the granted credit window, a frame after a half-close or reset, or an out-of-sequence
312    /// handshake frame. The caller RESETs the stream on any such rejection.
313    fn on_recv(&mut self, frame: StreamFrame, hdr: StreamHeader) -> Result<Accepted> {
314        if self.reset {
315            return Err(MessageError::StreamProtocol("frame after RESET"));
316        }
317        match frame {
318            StreamFrame::Open => Err(MessageError::StreamProtocol(
319                "duplicate OPEN for a live stream",
320            )),
321            StreamFrame::OpenAck => {
322                if self.role != Role::Initiator || self.handshake != Handshake::Opening {
323                    return Err(MessageError::StreamProtocol("unexpected OPEN_ACK"));
324                }
325                self.handshake = Handshake::Established;
326                self.send_credit = u64::from(hdr.window);
327                Ok(Accepted::Established)
328            }
329            StreamFrame::Data => {
330                if self.handshake != Handshake::Established {
331                    return Err(MessageError::StreamProtocol(
332                        "DATA before the stream is established",
333                    ));
334                }
335                if self.remote_closed {
336                    return Err(MessageError::StreamProtocol("DATA after the peer's CLOSE"));
337                }
338                if hdr.seq != self.recv_seq {
339                    return Err(MessageError::StreamProtocol(
340                        "out-of-order / gap / replayed DATA seq",
341                    ));
342                }
343                if self.recv_window_remaining == 0 {
344                    return Err(MessageError::StreamProtocol(
345                        "DATA exceeds the credit window we granted",
346                    ));
347                }
348                self.recv_window_remaining -= 1;
349                self.recv_seq += 1;
350                Ok(Accepted::Data)
351            }
352            StreamFrame::Credit => {
353                if self.handshake != Handshake::Established {
354                    return Err(MessageError::StreamProtocol(
355                        "CREDIT before the stream is established",
356                    ));
357                }
358                self.send_credit = self.send_credit.saturating_add(u64::from(hdr.window));
359                Ok(Accepted::Credit(u64::from(hdr.window)))
360            }
361            StreamFrame::Close => {
362                if self.handshake != Handshake::Established {
363                    return Err(MessageError::StreamProtocol(
364                        "CLOSE before the stream is established",
365                    ));
366                }
367                if self.remote_closed {
368                    return Err(MessageError::StreamProtocol(
369                        "duplicate CLOSE from the peer",
370                    ));
371                }
372                self.remote_closed = true;
373                Ok(Accepted::RemoteClosed)
374            }
375            StreamFrame::CloseAck => {
376                if !self.local_closed {
377                    return Err(MessageError::StreamProtocol("CLOSE_ACK without our CLOSE"));
378                }
379                Ok(Accepted::CloseAcked)
380            }
381            StreamFrame::Reset => {
382                self.reset = true;
383                Ok(Accepted::Reset)
384            }
385        }
386    }
387}
388
389/// A [`StreamHeader`] for a control/data frame (SPEC §3).
390fn header(frame: StreamFrame, seq: u64, window: u32) -> StreamHeader {
391    StreamHeader {
392        frame: frame.as_u8(),
393        seq,
394        window,
395    }
396}
397
398/// The per-peer streaming registry (SPEC §3): it multiplexes many concurrent streams to ONE peer,
399/// bounds their count ([`MAX_CONCURRENT_STREAMS`]), seals every outbound frame with a fresh ephemeral,
400/// and opens + fully verifies every inbound frame — RESETting a stream on any failed verify (gate items
401/// DIG-Network/dig_ecosystem#1162).
402///
403/// One endpoint's identity key both SEALS our outbound frames (as sender) and OPENS the peer's inbound
404/// frames (as recipient) — the ONE BLS12-381 identity key (SPEC §5.1).
405pub struct StreamEndpoint<'a> {
406    /// Our identity secret key — seals outbound (sender) + opens inbound (recipient) (SPEC §5.1).
407    identity_sk: &'a SecretKey,
408    /// Our DID launcher id (the `sender` of our outbound frames).
409    local_did: Bytes32,
410    /// Our key epoch for rotation disambiguation (SPEC §2 field 7).
411    local_epoch: u32,
412    /// The peer DID launcher id (the `recipient` of our outbound frames).
413    peer_did: Bytes32,
414    /// The peer's BLS G1 identity public key (48-byte compressed) — the seal target for our frames.
415    peer_pub: &'a [u8; 48],
416    /// The message-type id every frame of these streams carries (SPEC §4).
417    message_type: u32,
418    /// Our monotonic per-peer anti-replay counter for outbound frames (SPEC §5.6).
419    send_counter: u64,
420    /// Live streams keyed by `correlation_id` (SPEC §2 field 4 / §3).
421    sessions: HashMap<Bytes32, StreamSession>,
422    /// The per-peer concurrent-stream cap (SPEC §3 DoS gate).
423    max_concurrent: usize,
424    /// The inbound anti-replay guard shared across all of this peer's streams (SPEC §5.6).
425    guard: ReplayGuard,
426}
427
428impl<'a> StreamEndpoint<'a> {
429    /// A new endpoint for streams to one peer, using [`MAX_CONCURRENT_STREAMS`] as the cap.
430    ///
431    /// `identity_sk` is our ONE BLS12-381 identity key; `peer_pub` is the peer's 48-byte G1 identity
432    /// key. `message_type` is the SPEC §4 type every frame carries.
433    #[must_use]
434    pub fn new(
435        identity_sk: &'a SecretKey,
436        local_did: Bytes32,
437        local_epoch: u32,
438        peer_did: Bytes32,
439        peer_pub: &'a [u8; 48],
440        message_type: u32,
441    ) -> Self {
442        Self {
443            identity_sk,
444            local_did,
445            local_epoch,
446            peer_did,
447            peer_pub,
448            message_type,
449            send_counter: 0,
450            sessions: HashMap::new(),
451            max_concurrent: MAX_CONCURRENT_STREAMS,
452            guard: ReplayGuard::new(),
453        }
454    }
455
456    /// Override the per-peer concurrent-stream cap (SPEC §3), e.g. for a higher-capacity relay endpoint
457    /// or a tighter test boundary. Defaults to [`MAX_CONCURRENT_STREAMS`].
458    #[must_use]
459    pub fn with_max_concurrent(mut self, max: usize) -> Self {
460        self.max_concurrent = max;
461        self
462    }
463
464    /// The number of live streams to this peer.
465    #[must_use]
466    pub fn stream_count(&self) -> usize {
467        self.sessions.len()
468    }
469
470    /// Read-only access to a live session (for inspection/testing).
471    #[must_use]
472    pub fn session(&self, correlation_id: Bytes32) -> Option<&StreamSession> {
473        self.sessions.get(&correlation_id)
474    }
475
476    /// Open a new outbound stream, advertising `recv_window` credits for the peer→us direction (SPEC §3).
477    /// Returns the sealed OPEN envelope to send.
478    ///
479    /// # Errors
480    /// [`MessageError::StreamLimit`] if we already hold [`MAX_CONCURRENT_STREAMS`] streams;
481    /// [`MessageError::StreamProtocol`] if `correlation_id` is already in use; plus any seal error.
482    pub fn open(
483        &mut self,
484        correlation_id: Bytes32,
485        recv_window: u32,
486        now_ms: u64,
487        expires_at: u64,
488    ) -> Result<DigMessageEnvelope> {
489        if self.sessions.contains_key(&correlation_id) {
490            return Err(MessageError::StreamProtocol(
491                "correlation_id already in use",
492            ));
493        }
494        if self.sessions.len() >= self.max_concurrent {
495            return Err(MessageError::StreamLimit {
496                cap: self.max_concurrent,
497            });
498        }
499        self.sessions
500            .insert(correlation_id, StreamSession::initiator(recv_window));
501        let hdr = header(StreamFrame::Open, 0, recv_window);
502        self.seal_frame(correlation_id, hdr, &[], now_ms, expires_at)
503    }
504
505    /// Complete a responder handshake, advertising `recv_window` credits for the peer→us direction.
506    /// Returns the sealed OPEN_ACK envelope.
507    ///
508    /// # Errors
509    /// [`MessageError::StreamProtocol`] for an unknown stream or an illegal handshake position; plus any
510    /// seal error.
511    pub fn open_ack(
512        &mut self,
513        correlation_id: Bytes32,
514        recv_window: u32,
515        now_ms: u64,
516        expires_at: u64,
517    ) -> Result<DigMessageEnvelope> {
518        let hdr = self
519            .session_mut(correlation_id)?
520            .build_open_ack(recv_window)?;
521        self.seal_frame(correlation_id, hdr, &[], now_ms, expires_at)
522    }
523
524    /// Send one DATA chunk on an established stream, consuming one credit (SPEC §3). Returns the sealed
525    /// DATA envelope.
526    ///
527    /// # Errors
528    /// [`MessageError::StreamProtocol`] for an unknown stream, a closed direction, or exhausted credit
529    /// (backpressure); plus any seal/compression error.
530    pub fn send_data(
531        &mut self,
532        correlation_id: Bytes32,
533        payload: &[u8],
534        now_ms: u64,
535        expires_at: u64,
536    ) -> Result<DigMessageEnvelope> {
537        let hdr = self.session_mut(correlation_id)?.build_data()?;
538        self.seal_frame(correlation_id, hdr, payload, now_ms, expires_at)
539    }
540
541    /// Grant the peer `n` more DATA credits (SPEC §3 backpressure). Returns the sealed CREDIT envelope.
542    ///
543    /// # Errors
544    /// [`MessageError::StreamProtocol`] for an unknown or not-yet-established stream; plus any seal error.
545    pub fn grant_credit(
546        &mut self,
547        correlation_id: Bytes32,
548        n: u32,
549        now_ms: u64,
550        expires_at: u64,
551    ) -> Result<DigMessageEnvelope> {
552        let hdr = self.session_mut(correlation_id)?.build_credit(n)?;
553        self.seal_frame(correlation_id, hdr, &[], now_ms, expires_at)
554    }
555
556    /// Half-close our sending direction (SPEC §3). Returns the sealed CLOSE envelope; the stream is
557    /// dropped once BOTH directions are closed.
558    ///
559    /// # Errors
560    /// [`MessageError::StreamProtocol`] for an unknown or already-closed direction; plus any seal error.
561    pub fn close(
562        &mut self,
563        correlation_id: Bytes32,
564        now_ms: u64,
565        expires_at: u64,
566    ) -> Result<DigMessageEnvelope> {
567        let hdr = self.session_mut(correlation_id)?.build_close()?;
568        let env = self.seal_frame(correlation_id, hdr, &[], now_ms, expires_at)?;
569        self.drop_if_closed(correlation_id);
570        Ok(env)
571    }
572
573    /// Abort a stream immediately (SPEC §3 cancel). Returns the sealed RESET envelope and drops the
574    /// stream.
575    ///
576    /// # Errors
577    /// [`MessageError::StreamProtocol`] for an unknown stream; plus any seal error.
578    pub fn reset(
579        &mut self,
580        correlation_id: Bytes32,
581        now_ms: u64,
582        expires_at: u64,
583    ) -> Result<DigMessageEnvelope> {
584        let hdr = self.session_mut(correlation_id)?.build_reset();
585        let env = self.seal_frame(correlation_id, hdr, &[], now_ms, expires_at)?;
586        self.sessions.remove(&correlation_id);
587        Ok(env)
588    }
589
590    /// Open + fully verify an inbound frame and drive the state machine (SPEC §3). On success returns a
591    /// verified [`StreamEvent`]; on ANY failed verify or protocol violation returns
592    /// [`StreamAccept::Dropped`] — a bad/unauthenticated/non-actionable frame silently discarded (a live
593    /// session is left untouched); or, ONLY for a state-machine violation by the AUTHENTICATED peer on a
594    /// KNOWN stream (or the concurrent-stream cap), [`StreamAccept::Reset`] — a sealed RESET the caller
595    /// sends. A RESET is NEVER emitted for unauthenticated or duplicate input, so the untrusted relay
596    /// (§5.4) cannot provoke a RESET reflection storm or replay-teardown (SPEC §3, gate item #1162).
597    ///
598    /// `resolve_sender_pub` maps `(peer DID, epoch)` to the peer's 48-byte G1 key (usually the endpoint's
599    /// own `peer_pub`); `now_ms` is our wall clock for the freshness + expiry checks.
600    ///
601    /// # Errors
602    /// Only a failure to SEAL the RESET response (on the authenticated-violation path) propagates as
603    /// `Err`; a rejected inbound frame is the non-error [`StreamAccept::Dropped`]/[`StreamAccept::Reset`]
604    /// outcome.
605    pub fn accept(
606        &mut self,
607        envelope: &DigMessageEnvelope,
608        resolve_sender_pub: impl Fn(Bytes32, u32) -> Option<[u8; 48]>,
609        now_ms: u64,
610    ) -> Result<StreamAccept> {
611        let correlation_id = envelope.correlation_id;
612
613        // 1. Open + fully verify the seal (subgroup, AEAD, BLS sig, header-match, expiry, anti-replay).
614        //    ANY failure → DROP (never a RESET): the frame is unauthenticated (garbage/forged) or a
615        //    replay/stale/expired frame the relay re-injected — it cannot be trusted to name a stream, so
616        //    responding with a signed RESET would let the relay weaponize it (reflection storm /
617        //    replay-teardown, §5.4). Dropping fully satisfies "never deliver corrupt data"; a live
618        //    session for this correlation_id (if any) is left UNTOUCHED (a replayed frame never tears
619        //    down a healthy stream).
620        let opened = match open_message(
621            self.identity_sk,
622            envelope,
623            &resolve_sender_pub,
624            &mut self.guard,
625            now_ms,
626        ) {
627            Ok(opened) => opened,
628            Err(cause) => return Ok(StreamAccept::Dropped { cause }),
629        };
630
631        // 2. It MUST be a stream frame with a known kind (SPEC §3). A malformed-but-authenticated frame
632        //    is a no-op → DROP (no RESET: still not an actionable state-machine event).
633        let Some(hdr) = envelope.stream else {
634            return Ok(StreamAccept::Dropped {
635                cause: MessageError::StreamProtocol("stream event on a non-stream envelope"),
636            });
637        };
638        let Some(frame) = StreamFrame::from_u8(hdr.frame) else {
639            return Ok(StreamAccept::Dropped {
640                cause: MessageError::StreamProtocol("unknown stream frame kind"),
641            });
642        };
643        if opened.shape != InteractionShape::StreamFrame {
644            return Ok(StreamAccept::Dropped {
645                cause: MessageError::StreamProtocol("stream frame with a non-stream shape"),
646            });
647        }
648
649        // 3. An inbound RESET is terminal: a RESET must NEVER beget a RESET (anti-storm). If it names a
650        //    live session, tear that session down (PeerReset); otherwise DROP it silently.
651        if frame == StreamFrame::Reset {
652            return Ok(if self.sessions.remove(&correlation_id).is_some() {
653                StreamAccept::Event(StreamEvent::PeerReset)
654            } else {
655                StreamAccept::Dropped {
656                    cause: MessageError::StreamProtocol("RESET for an unknown stream"),
657                }
658            });
659        }
660
661        // 4. OPEN starts a NEW session (registry-owned): enforce the concurrent-stream cap here.
662        if frame == StreamFrame::Open {
663            return self.accept_open(correlation_id, hdr, now_ms);
664        }
665
666        // 5. Every other frame drives an existing session. A frame for an UNKNOWN stream is DROPPED (no
667        //    RESET): it may be a late/stale frame after we dropped the session, and RESETting it would
668        //    feed a ping-pong. Only a state-machine violation by the authenticated peer on a KNOWN
669        //    session warrants a RESET (the frame is provably genuine, so no forge/replay storm).
670        if !self.sessions.contains_key(&correlation_id) {
671            return Ok(StreamAccept::Dropped {
672                cause: MessageError::StreamProtocol("frame for an unknown stream"),
673            });
674        }
675        let transition = self
676            .sessions
677            .get_mut(&correlation_id)
678            .expect("presence checked above")
679            .on_recv(frame, hdr);
680        match transition {
681            Ok(accepted) => {
682                let event = to_event(accepted, opened.payload);
683                self.drop_if_closed(correlation_id);
684                Ok(StreamAccept::Event(event))
685            }
686            Err(cause) => {
687                self.sessions.remove(&correlation_id);
688                self.reset_response(correlation_id, now_ms, cause)
689            }
690        }
691    }
692
693    /// Handle a verified inbound OPEN: enforce the per-peer concurrent-stream cap, create the responder
694    /// session, and report [`StreamEvent::Opened`] (SPEC §3). The OPEN is already authenticated (it
695    /// passed `open_message`), so a RESET here is safe — it cannot be forged/replayed by the relay.
696    fn accept_open(
697        &mut self,
698        correlation_id: Bytes32,
699        hdr: StreamHeader,
700        now_ms: u64,
701    ) -> Result<StreamAccept> {
702        if self.sessions.contains_key(&correlation_id) {
703            return self.reset_response(
704                correlation_id,
705                now_ms,
706                MessageError::StreamProtocol("duplicate OPEN for a live stream"),
707            );
708        }
709        if self.sessions.len() >= self.max_concurrent {
710            return self.reset_response(
711                correlation_id,
712                now_ms,
713                MessageError::StreamLimit {
714                    cap: self.max_concurrent,
715                },
716            );
717        }
718        self.sessions
719            .insert(correlation_id, StreamSession::responder(hdr.window));
720        Ok(StreamAccept::Event(StreamEvent::Opened))
721    }
722
723    /// Build the [`StreamAccept::Reset`] outcome: seal a RESET to the peer for `correlation_id` and
724    /// report `cause`. Only ever called for an AUTHENTICATED state-machine violation on a known session
725    /// or the concurrent-stream cap — never for unauthenticated/duplicate input (a RESET must never beget
726    /// a RESET; SPEC §3 gate item #1162).
727    fn reset_response(
728        &mut self,
729        correlation_id: Bytes32,
730        now_ms: u64,
731        cause: MessageError,
732    ) -> Result<StreamAccept> {
733        let hdr = header(StreamFrame::Reset, 0, 0);
734        let frame = self.seal_frame(correlation_id, hdr, &[], now_ms, 0)?;
735        Ok(StreamAccept::Reset {
736            frame: Box::new(frame),
737            cause,
738        })
739    }
740
741    /// Seal one frame with a FRESH ephemeral (SPEC §5.1) — never a fixed/session ephemeral, so every
742    /// frame has a unique `(key, nonce)` and ChaCha20Poly1305 nonce-reuse is impossible (gate item
743    /// DIG-Network/dig_ecosystem#1183). Consumes one outbound anti-replay counter (SPEC §5.6).
744    fn seal_frame(
745        &mut self,
746        correlation_id: Bytes32,
747        hdr: StreamHeader,
748        payload: &[u8],
749        now_ms: u64,
750        expires_at: u64,
751    ) -> Result<DigMessageEnvelope> {
752        let counter = self.send_counter;
753        self.send_counter += 1;
754        let params = SealParams {
755            sender_sk: self.identity_sk,
756            sender: self.local_did,
757            sender_epoch: self.local_epoch,
758            recipient: self.peer_did,
759            recipient_pub: self.peer_pub,
760            message_type: self.message_type,
761            shape: InteractionShape::StreamFrame,
762            correlation_id,
763            stream: Some(hdr),
764            counter,
765            timestamp_ms: now_ms,
766            expires_at,
767            payload,
768        };
769        seal_message(&params)
770    }
771
772    /// A mutable session by `correlation_id`, or [`MessageError::StreamProtocol`] if unknown.
773    fn session_mut(&mut self, correlation_id: Bytes32) -> Result<&mut StreamSession> {
774        self.sessions
775            .get_mut(&correlation_id)
776            .ok_or(MessageError::StreamProtocol("unknown stream"))
777    }
778
779    /// Drop a session once fully closed, so a completed stream frees its slot against the cap.
780    fn drop_if_closed(&mut self, correlation_id: Bytes32) {
781        if self
782            .sessions
783            .get(&correlation_id)
784            .is_some_and(StreamSession::is_closed)
785        {
786            self.sessions.remove(&correlation_id);
787        }
788    }
789}
790
791/// Pair a pure [`Accepted`] transition with the opened payload to build the public [`StreamEvent`].
792fn to_event(accepted: Accepted, payload: Vec<u8>) -> StreamEvent {
793    match accepted {
794        Accepted::Established => StreamEvent::Established,
795        Accepted::Data => StreamEvent::Data(payload),
796        Accepted::Credit(n) => StreamEvent::CreditGranted(n),
797        Accepted::RemoteClosed => StreamEvent::RemoteClosed,
798        Accepted::CloseAcked => StreamEvent::CloseAcked,
799        Accepted::Reset => StreamEvent::PeerReset,
800    }
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use dig_identity::{derive_identity_sk, master_secret_key_from_seed, public_key_bytes};
807    use sha2::{Digest, Sha256};
808
809    const NOW: u64 = 1_700_000_000_000;
810
811    fn sk(label: &str) -> SecretKey {
812        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
813        derive_identity_sk(&master_secret_key_from_seed(&seed))
814    }
815
816    fn cid(tag: &str) -> Bytes32 {
817        Bytes32::new(Sha256::digest(tag.as_bytes()).into())
818    }
819
820    // ── Pure state-machine transition tests (no crypto) ──────────────────────────────────────────
821
822    #[test]
823    fn initiator_handshake_then_data() {
824        let mut s = StreamSession::initiator(4);
825        assert_eq!(s.state(), StreamState::Opening);
826        // No DATA until established.
827        assert!(s.build_data().is_err());
828        // OPEN_ACK grants us 2 send credits.
829        assert_eq!(
830            s.on_recv(StreamFrame::OpenAck, header(StreamFrame::OpenAck, 0, 2)),
831            Ok(Accepted::Established)
832        );
833        assert_eq!(s.state(), StreamState::Open);
834        assert_eq!(s.send_credit(), 2);
835        // Two DATA frames spend the credit; the third is refused (backpressure).
836        assert!(s.build_data().is_ok());
837        assert!(s.build_data().is_ok());
838        assert!(s.build_data().is_err());
839    }
840
841    #[test]
842    fn outbound_data_seq_increments() {
843        let mut s = StreamSession::initiator(0);
844        s.on_recv(StreamFrame::OpenAck, header(StreamFrame::OpenAck, 0, 3))
845            .unwrap();
846        assert_eq!(s.build_data().unwrap().seq, 0);
847        assert_eq!(s.build_data().unwrap().seq, 1);
848        assert_eq!(s.build_data().unwrap().seq, 2);
849    }
850
851    #[test]
852    fn responder_rejects_out_of_order_recv_seq() {
853        // Responder established (sent OPEN_ACK granting itself a recv window of 4).
854        let mut s = StreamSession::responder(0);
855        s.build_open_ack(4).unwrap();
856        // In-order seq 0 accepted.
857        assert_eq!(
858            s.on_recv(StreamFrame::Data, header(StreamFrame::Data, 0, 0)),
859            Ok(Accepted::Data)
860        );
861        // A gap (seq 2 when 1 expected) is rejected.
862        assert!(s
863            .on_recv(StreamFrame::Data, header(StreamFrame::Data, 2, 0))
864            .is_err());
865        // A replay of seq 0 is rejected.
866        assert!(s
867            .on_recv(StreamFrame::Data, header(StreamFrame::Data, 0, 0))
868            .is_err());
869    }
870
871    #[test]
872    fn recv_credit_window_bounds_inbound_data() {
873        let mut s = StreamSession::responder(0);
874        s.build_open_ack(1).unwrap(); // grant exactly 1 credit
875        assert_eq!(
876            s.on_recv(StreamFrame::Data, header(StreamFrame::Data, 0, 0)),
877            Ok(Accepted::Data)
878        );
879        // The 2nd DATA exceeds the granted window.
880        assert!(s
881            .on_recv(StreamFrame::Data, header(StreamFrame::Data, 1, 0))
882            .is_err());
883    }
884
885    #[test]
886    fn credit_frame_relieves_send_backpressure() {
887        let mut s = StreamSession::initiator(0);
888        s.on_recv(StreamFrame::OpenAck, header(StreamFrame::OpenAck, 0, 1))
889            .unwrap();
890        s.build_data().unwrap();
891        assert!(s.build_data().is_err(), "credit exhausted");
892        // A CREDIT(2) grant refills.
893        assert_eq!(
894            s.on_recv(StreamFrame::Credit, header(StreamFrame::Credit, 0, 2)),
895            Ok(Accepted::Credit(2))
896        );
897        assert!(s.build_data().is_ok());
898        assert!(s.build_data().is_ok());
899        assert!(s.build_data().is_err());
900    }
901
902    #[test]
903    fn bidirectional_half_close() {
904        let mut s = StreamSession::initiator(4);
905        s.on_recv(StreamFrame::OpenAck, header(StreamFrame::OpenAck, 0, 4))
906            .unwrap();
907        // We close our sending direction: peer may still send.
908        s.build_close().unwrap();
909        assert_eq!(s.state(), StreamState::HalfClosedLocal);
910        assert!(s.build_data().is_err(), "no DATA after our CLOSE");
911        // Peer can still deliver DATA to us.
912        assert_eq!(
913            s.on_recv(StreamFrame::Data, header(StreamFrame::Data, 0, 0)),
914            Ok(Accepted::Data)
915        );
916        // Peer closes too → fully closed.
917        assert_eq!(
918            s.on_recv(StreamFrame::Close, header(StreamFrame::Close, 0, 0)),
919            Ok(Accepted::RemoteClosed)
920        );
921        assert_eq!(s.state(), StreamState::Closed);
922        assert!(s.is_closed());
923    }
924
925    #[test]
926    fn reset_aborts_from_any_state() {
927        let mut s = StreamSession::initiator(4);
928        assert_eq!(
929            s.on_recv(StreamFrame::Reset, header(StreamFrame::Reset, 0, 0)),
930            Ok(Accepted::Reset)
931        );
932        assert_eq!(s.state(), StreamState::Closed);
933        // No frame is accepted after reset.
934        assert!(s
935            .on_recv(StreamFrame::Data, header(StreamFrame::Data, 0, 0))
936            .is_err());
937    }
938
939    // ── End-to-end crypto integration (seal every frame, open + verify) ──────────────────────────
940
941    struct Pair {
942        a_sk: SecretKey,
943        a_did: Bytes32,
944        a_pub: [u8; 48],
945        b_sk: SecretKey,
946        b_did: Bytes32,
947        b_pub: [u8; 48],
948    }
949    fn pair(tag: &str) -> Pair {
950        let a_sk = sk(&format!("{tag}/a"));
951        let b_sk = sk(&format!("{tag}/b"));
952        Pair {
953            a_pub: public_key_bytes(&a_sk),
954            b_pub: public_key_bytes(&b_sk),
955            a_did: cid(&format!("{tag}/a-did")),
956            b_did: cid(&format!("{tag}/b-did")),
957            a_sk,
958            b_sk,
959        }
960    }
961
962    const MT: u32 = 0x0000_0200; // dig-chat band, a stream type
963
964    #[test]
965    fn full_stream_round_trip_open_data_close() {
966        let p = pair("rt");
967        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
968        let mut bob = StreamEndpoint::new(&p.b_sk, p.b_did, 0, p.a_did, &p.a_pub, MT);
969        let sender_is_bob = |_d: Bytes32, _e: u32| Some(p.b_pub);
970        let sender_is_alice = |_d: Bytes32, _e: u32| Some(p.a_pub);
971        let stream = cid("rt/stream");
972
973        // Alice OPENs, granting Bob 4 credits.
974        let open = alice.open(stream, 4, NOW, 0).unwrap();
975        assert!(matches!(
976            bob.accept(&open, sender_is_alice, NOW).unwrap(),
977            StreamAccept::Event(StreamEvent::Opened)
978        ));
979
980        // Bob OPEN_ACKs, granting Alice 4 credits.
981        let ack = bob.open_ack(stream, 4, NOW, 0).unwrap();
982        assert!(matches!(
983            alice.accept(&ack, sender_is_bob, NOW).unwrap(),
984            StreamAccept::Event(StreamEvent::Established)
985        ));
986
987        // Alice streams two DATA chunks; Bob delivers them in order.
988        let d0 = alice.send_data(stream, b"hello ", NOW, 0).unwrap();
989        let d1 = alice.send_data(stream, b"world", NOW, 0).unwrap();
990        assert_eq!(
991            bob.accept(&d0, sender_is_alice, NOW).unwrap(),
992            StreamAccept::Event(StreamEvent::Data(b"hello ".to_vec()))
993        );
994        assert_eq!(
995            bob.accept(&d1, sender_is_alice, NOW).unwrap(),
996            StreamAccept::Event(StreamEvent::Data(b"world".to_vec()))
997        );
998
999        // Alice CLOSEs; Bob sees the half-close then Alice's stream is dropped once Bob closes too.
1000        let close = alice.close(stream, NOW, 0).unwrap();
1001        assert!(matches!(
1002            bob.accept(&close, sender_is_alice, NOW).unwrap(),
1003            StreamAccept::Event(StreamEvent::RemoteClosed)
1004        ));
1005    }
1006
1007    #[test]
1008    fn concurrent_stream_cap_rejects_the_nth_plus_one_open() {
1009        let p = pair("cap");
1010        // A tiny endpoint (cap = 2) makes the boundary cheap to hit.
1011        let mut bob =
1012            StreamEndpoint::new(&p.b_sk, p.b_did, 0, p.a_did, &p.a_pub, MT).with_max_concurrent(2);
1013        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
1014        let sender_is_alice = |_d: Bytes32, _e: u32| Some(p.a_pub);
1015
1016        for i in 0..2 {
1017            let open = alice.open(cid(&format!("cap/{i}")), 1, NOW, 0).unwrap();
1018            assert!(matches!(
1019                bob.accept(&open, sender_is_alice, NOW).unwrap(),
1020                StreamAccept::Event(StreamEvent::Opened)
1021            ));
1022        }
1023        assert_eq!(bob.stream_count(), 2);
1024
1025        // The 3rd concurrent OPEN is refused with a RESET carrying StreamLimit.
1026        let open3 = alice.open(cid("cap/3"), 1, NOW, 0).unwrap();
1027        match bob.accept(&open3, sender_is_alice, NOW).unwrap() {
1028            StreamAccept::Reset { cause, .. } => {
1029                assert!(matches!(cause, MessageError::StreamLimit { cap: 2 }));
1030            }
1031            other => panic!("expected a StreamLimit RESET, got {other:?}"),
1032        }
1033        assert_eq!(
1034            bob.stream_count(),
1035            2,
1036            "the rejected OPEN created no session"
1037        );
1038    }
1039
1040    #[test]
1041    fn failed_verify_frame_is_dropped_never_reset() {
1042        // A tampered/forged frame is unauthenticated → DROP (no signed RESET), so an inject-only relay
1043        // cannot provoke a RESET reflection storm (§5.4).
1044        let p = pair("badverify");
1045        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
1046        let mut bob = StreamEndpoint::new(&p.b_sk, p.b_did, 0, p.a_did, &p.a_pub, MT);
1047        let sender_is_alice = |_d: Bytes32, _e: u32| Some(p.a_pub);
1048        let stream = cid("badverify/s");
1049
1050        let mut open = alice.open(stream, 4, NOW, 0).unwrap();
1051        let last = open.sealed.ciphertext.len() - 1;
1052        open.sealed.ciphertext[last] ^= 0x01;
1053        assert_eq!(
1054            bob.accept(&open, sender_is_alice, NOW).unwrap(),
1055            StreamAccept::Dropped {
1056                cause: MessageError::OpenFailed
1057            }
1058        );
1059        assert_eq!(bob.stream_count(), 0);
1060    }
1061
1062    #[test]
1063    fn frame_for_unknown_stream_is_dropped_never_reset() {
1064        let p = pair("proto");
1065        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
1066        let mut bob = StreamEndpoint::new(&p.b_sk, p.b_did, 0, p.a_did, &p.a_pub, MT);
1067        let sender_is_alice = |_d: Bytes32, _e: u32| Some(p.a_pub);
1068        let stream = cid("proto/s");
1069
1070        // Alice forces an authenticated DATA without Bob ever seeing an OPEN.
1071        alice.open(stream, 4, NOW, 0).unwrap();
1072        alice
1073            .sessions
1074            .get_mut(&stream)
1075            .unwrap()
1076            .on_recv(StreamFrame::OpenAck, header(StreamFrame::OpenAck, 0, 4))
1077            .unwrap();
1078        let data = alice.send_data(stream, b"early", NOW, 0).unwrap();
1079        // Bob has no session for it → DROP (no RESET → no ping-pong).
1080        assert!(matches!(
1081            bob.accept(&data, sender_is_alice, NOW).unwrap(),
1082            StreamAccept::Dropped { .. }
1083        ));
1084    }
1085
1086    #[test]
1087    fn replayed_data_on_a_live_stream_is_dropped_and_stream_survives() {
1088        // Exploit-B guard: the relay re-injects a prior valid DATA on a LIVE stream. The ReplayGuard
1089        // rejects it → DROP (NOT a RESET), and the healthy session MUST stay live (no teardown).
1090        let p = pair("replay");
1091        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
1092        let mut bob = StreamEndpoint::new(&p.b_sk, p.b_did, 0, p.a_did, &p.a_pub, MT);
1093        let sender_is_alice = |_d: Bytes32, _e: u32| Some(p.a_pub);
1094        let stream = cid("replay/s");
1095
1096        let open = alice.open(stream, 4, NOW, 0).unwrap();
1097        bob.accept(&open, sender_is_alice, NOW).unwrap();
1098        let ack = bob.open_ack(stream, 4, NOW, 0).unwrap();
1099        alice.accept(&ack, |_d, _e| Some(p.b_pub), NOW).unwrap();
1100        let data = alice.send_data(stream, b"once", NOW, 0).unwrap();
1101        assert_eq!(
1102            bob.accept(&data, sender_is_alice, NOW).unwrap(),
1103            StreamAccept::Event(StreamEvent::Data(b"once".to_vec()))
1104        );
1105        // Re-inject the exact same sealed frame: guard rejects the duplicate counter → DROP.
1106        assert_eq!(
1107            bob.accept(&data, sender_is_alice, NOW).unwrap(),
1108            StreamAccept::Dropped {
1109                cause: MessageError::Replay
1110            }
1111        );
1112        // The stream is UNTOUCHED — a healthy stream is not torn down by a replayed frame.
1113        assert_eq!(bob.stream_count(), 1);
1114        assert_eq!(bob.session(stream).unwrap().state(), StreamState::Open);
1115        // And the stream keeps working: the next in-order DATA is delivered.
1116        let next = alice.send_data(stream, b"twice", NOW, 0).unwrap();
1117        assert_eq!(
1118            bob.accept(&next, sender_is_alice, NOW).unwrap(),
1119            StreamAccept::Event(StreamEvent::Data(b"twice".to_vec()))
1120        );
1121    }
1122
1123    #[test]
1124    fn inbound_reset_for_unknown_stream_does_not_beget_a_reset() {
1125        // Anti-storm: an inbound RESET naming an unknown stream is DROPPED, never answered with a RESET.
1126        let p = pair("resetstorm");
1127        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
1128        let mut bob = StreamEndpoint::new(&p.b_sk, p.b_did, 0, p.a_did, &p.a_pub, MT);
1129        let sender_is_alice = |_d: Bytes32, _e: u32| Some(p.a_pub);
1130
1131        // Alice opens then RESETs a stream Bob never saw → Bob receives a RESET for an unknown stream.
1132        let ghost = cid("resetstorm/ghost");
1133        alice.open(ghost, 1, NOW, 0).unwrap();
1134        let reset = alice.reset(ghost, NOW, 0).unwrap();
1135        assert!(matches!(
1136            bob.accept(&reset, sender_is_alice, NOW).unwrap(),
1137            StreamAccept::Dropped { .. }
1138        ));
1139        assert_eq!(bob.stream_count(), 0);
1140    }
1141
1142    #[test]
1143    fn protocol_violation_on_established_stream_still_resets() {
1144        // The LEGIT RESET path: an authenticated peer breaks the state machine on a KNOWN session
1145        // (a bad DATA seq) → RESET + drop the session.
1146        let p = pair("legitreset");
1147        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
1148        let mut bob = StreamEndpoint::new(&p.b_sk, p.b_did, 0, p.a_did, &p.a_pub, MT);
1149        let sender_is_alice = |_d: Bytes32, _e: u32| Some(p.a_pub);
1150        let sender_is_bob = |_d: Bytes32, _e: u32| Some(p.b_pub);
1151        let stream = cid("legitreset/s");
1152
1153        let open = alice.open(stream, 4, NOW, 0).unwrap();
1154        bob.accept(&open, sender_is_alice, NOW).unwrap();
1155        let ack = bob.open_ack(stream, 4, NOW, 0).unwrap();
1156        alice.accept(&ack, sender_is_bob, NOW).unwrap();
1157
1158        // Alice (mis)sends DATA at seq 1 first (skipping 0) by desyncing her local send_seq.
1159        alice.sessions.get_mut(&stream).unwrap().send_seq = 1;
1160        let bad = alice.send_data(stream, b"gap", NOW, 0).unwrap();
1161        match bob.accept(&bad, sender_is_alice, NOW).unwrap() {
1162            StreamAccept::Reset { cause, .. } => {
1163                assert!(matches!(cause, MessageError::StreamProtocol(_)));
1164            }
1165            other => panic!("expected a RESET on the authenticated violation, got {other:?}"),
1166        }
1167        assert_eq!(bob.stream_count(), 0, "the violated session is dropped");
1168    }
1169
1170    #[test]
1171    fn every_frame_uses_a_unique_ephemeral() {
1172        // CUSTODY: no two frames may share a KEM ephemeral (kem_enc) — that would be ChaCha20Poly1305
1173        // nonce-reuse. Seal a batch of frames and assert all kem_enc values are distinct.
1174        let p = pair("uniq");
1175        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
1176        let stream = cid("uniq/s");
1177        let mut kems = Vec::new();
1178        kems.push(alice.open(stream, 100, NOW, 0).unwrap().sealed.kem_enc);
1179        alice
1180            .sessions
1181            .get_mut(&stream)
1182            .unwrap()
1183            .on_recv(StreamFrame::OpenAck, header(StreamFrame::OpenAck, 0, 100))
1184            .unwrap();
1185        for _ in 0..16 {
1186            kems.push(
1187                alice
1188                    .send_data(stream, b"x", NOW, 0)
1189                    .unwrap()
1190                    .sealed
1191                    .kem_enc,
1192            );
1193        }
1194        let unique: std::collections::HashSet<_> = kems.iter().collect();
1195        assert_eq!(
1196            unique.len(),
1197            kems.len(),
1198            "every frame must use a fresh ephemeral"
1199        );
1200    }
1201
1202    #[test]
1203    fn credit_grant_close_ack_and_peer_reset_round_trip() {
1204        // Exercise the CREDIT / CLOSE-ack / RESET receive paths end-to-end over the seal.
1205        let p = pair("credit");
1206        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
1207        let mut bob = StreamEndpoint::new(&p.b_sk, p.b_did, 0, p.a_did, &p.a_pub, MT);
1208        let sender_is_alice = |_d: Bytes32, _e: u32| Some(p.a_pub);
1209        let sender_is_bob = |_d: Bytes32, _e: u32| Some(p.b_pub);
1210        let stream = cid("credit/s");
1211
1212        let open = alice.open(stream, 1, NOW, 0).unwrap();
1213        bob.accept(&open, sender_is_alice, NOW).unwrap();
1214        let ack = bob.open_ack(stream, 1, NOW, 0).unwrap();
1215        alice.accept(&ack, sender_is_bob, NOW).unwrap();
1216
1217        // Bob grants Alice 3 more credits (backpressure relief).
1218        let credit = bob.grant_credit(stream, 3, NOW, 0).unwrap();
1219        assert_eq!(
1220            alice.accept(&credit, sender_is_bob, NOW).unwrap(),
1221            StreamAccept::Event(StreamEvent::CreditGranted(3))
1222        );
1223        assert_eq!(alice.session(stream).unwrap().send_credit(), 4);
1224
1225        // Bob RESETs; Alice sees PeerReset and drops the stream.
1226        let reset = bob.reset(stream, NOW, 0).unwrap();
1227        assert_eq!(
1228            alice.accept(&reset, sender_is_bob, NOW).unwrap(),
1229            StreamAccept::Event(StreamEvent::PeerReset)
1230        );
1231        assert_eq!(alice.stream_count(), 0);
1232        assert_eq!(
1233            bob.stream_count(),
1234            0,
1235            "reset drops the sender's session too"
1236        );
1237    }
1238
1239    #[test]
1240    fn close_ack_is_delivered() {
1241        let p = pair("closeack");
1242        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
1243        let mut bob = StreamEndpoint::new(&p.b_sk, p.b_did, 0, p.a_did, &p.a_pub, MT);
1244        let sender_is_alice = |_d: Bytes32, _e: u32| Some(p.a_pub);
1245        let sender_is_bob = |_d: Bytes32, _e: u32| Some(p.b_pub);
1246        let stream = cid("closeack/s");
1247
1248        let open = alice.open(stream, 2, NOW, 0).unwrap();
1249        bob.accept(&open, sender_is_alice, NOW).unwrap();
1250        let ack = bob.open_ack(stream, 2, NOW, 0).unwrap();
1251        alice.accept(&ack, sender_is_bob, NOW).unwrap();
1252
1253        // Alice CLOSEs; Bob CLOSE_ACKs; Alice receives the CloseAcked event.
1254        let close = alice.close(stream, NOW, 0).unwrap();
1255        bob.accept(&close, sender_is_alice, NOW).unwrap();
1256        // Bob replies with a raw CLOSE_ACK frame (no dedicated builder — craft via a RESET-shaped seal).
1257        let close_ack = bob
1258            .seal_frame(stream, header(StreamFrame::CloseAck, 0, 0), &[], NOW, 0)
1259            .unwrap();
1260        assert_eq!(
1261            alice.accept(&close_ack, sender_is_bob, NOW).unwrap(),
1262            StreamAccept::Event(StreamEvent::CloseAcked)
1263        );
1264    }
1265
1266    #[test]
1267    fn endpoint_send_side_error_branches() {
1268        let p = pair("errs");
1269        let mut alice = StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT);
1270        let stream = cid("errs/s");
1271
1272        // Sending on an unknown stream is a protocol error.
1273        assert!(matches!(
1274            alice.send_data(stream, b"x", NOW, 0),
1275            Err(MessageError::StreamProtocol(_))
1276        ));
1277
1278        alice.open(stream, 1, NOW, 0).unwrap();
1279        // A duplicate correlation_id OPEN is refused.
1280        assert!(matches!(
1281            alice.open(stream, 1, NOW, 0),
1282            Err(MessageError::StreamProtocol(_))
1283        ));
1284
1285        // The concurrent-stream cap also gates the SEND (open) side, not only accept.
1286        let mut tight =
1287            StreamEndpoint::new(&p.a_sk, p.a_did, 0, p.b_did, &p.b_pub, MT).with_max_concurrent(1);
1288        tight.open(cid("errs/only"), 1, NOW, 0).unwrap();
1289        assert!(matches!(
1290            tight.open(cid("errs/2"), 1, NOW, 0),
1291            Err(MessageError::StreamLimit { cap: 1 })
1292        ));
1293    }
1294
1295    #[test]
1296    fn half_closed_remote_state_then_local_close() {
1297        // Cover the HalfClosedRemote branch: peer closes first, we keep sending, then we close.
1298        let mut s = StreamSession::initiator(4);
1299        s.on_recv(StreamFrame::OpenAck, header(StreamFrame::OpenAck, 0, 4))
1300            .unwrap();
1301        s.on_recv(StreamFrame::Close, header(StreamFrame::Close, 0, 0))
1302            .unwrap();
1303        assert_eq!(s.state(), StreamState::HalfClosedRemote);
1304        assert!(
1305            s.build_data().is_ok(),
1306            "we may still send after the peer's CLOSE"
1307        );
1308        s.build_close().unwrap();
1309        assert_eq!(s.state(), StreamState::Closed);
1310    }
1311}