From 316d99ad2ef7ec84e3e8ebf11f2ab57b5bcf9477 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:57:07 +0000 Subject: [PATCH] a2ui-server: sealed transport on ogar_encryption::stream; refuse reflected frames SealedTransport is now a frame layer over ogar-encryption's SealedStream (OGAR #360), the generic form of this module's own transport. Wire form and nonce layout are unchanged. open_frame used to take the direction from the sealed blob and never check it against the transport's own. Both directions share the session key, so a server's own down-frame reflected back at its up transport opened and was accepted as a client frame. It is now refused (TransportError::WrongDirection). seal_frame returns Result: the send counter refuses to wrap instead of overflowing (TransportError::CounterExhausted). Direction is re-exported from ogar-encryption; the variants and byte values are the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- Cargo.lock | 113 ++++++++++++----- crates/a2ui-server/src/desktop.rs | 41 ++++--- crates/a2ui-server/src/transport.rs | 176 +++++++++++++-------------- crates/a2ui-server/tests/p_rehost.rs | 4 +- 4 files changed, 189 insertions(+), 145 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a7065b..231bf8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,13 +129,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +version = "0.6.0" +source = "git+https://github.com/AdaWorldAPI/password-hashes?branch=master#00b4e373929894a9681a3ec763525bea8d73c732" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", + "ndarray", "password-hash", ] @@ -167,6 +167,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "askama_axum" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41603f7cdbf5ac4af60760f17253eb6adf6ec5b6f14a7ed830cf687d375f163" +dependencies = [ + "askama", + "axum-core 0.4.5", + "http", +] + [[package]] name = "askama_derive" version = "0.12.5" @@ -198,6 +209,17 @@ dependencies = [ "nom", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -216,7 +238,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "axum-core", + "axum-core 0.5.6", "bytes", "form_urlencoded", "futures-util", @@ -243,6 +265,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.5.6" @@ -306,11 +348,11 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -497,7 +539,7 @@ dependencies = [ "ctutils", "hybrid-array", "num-traits", - "rand_core 0.10.1", + "rand_core", "subtle", "zeroize", ] @@ -519,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -587,7 +629,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", - "subtle", ] [[package]] @@ -598,6 +639,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -660,7 +702,7 @@ checksum = "b805154de2e68f59874ec217ca36790dcffe500cd872c60fe509d28d0814a74d" dependencies = [ "elliptic-curve", "hash2curve", - "rand_core 0.10.1", + "rand_core", "shake", "subtle", ] @@ -678,7 +720,7 @@ dependencies = [ "group", "hybrid-array", "pkcs8 0.11.0", - "rand_core 0.10.1", + "rand_core", "sec1", "subtle", "zeroize", @@ -687,9 +729,8 @@ dependencies = [ [[package]] name = "encryption" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/ndarray?branch=master#e0a7e1cec6b7579d5433ea64b8dac39ad1b59c58" +source = "git+https://github.com/AdaWorldAPI/ndarray?branch=master#1ecbccbd728427e9333933d84e589a7cd7539d8d" dependencies = [ - "argon2", "chacha20poly1305", "ed25519-dalek", "getrandom", @@ -729,7 +770,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.10.1", + "rand_core", "subtle", ] @@ -895,7 +936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", - "rand_core 0.10.1", + "rand_core", "subtle", ] @@ -1146,7 +1187,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "lance-graph-contract" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/lance-graph?branch=main#d8fb61fc91fa903f6d2499185fe339d5162ad67c" +source = "git+https://github.com/AdaWorldAPI/lance-graph?branch=main#4e386f2653119a88395cef773e08fb11144496d9" dependencies = [ "glob", "serde", @@ -1463,7 +1504,7 @@ dependencies = [ [[package]] name = "ogar-a2ui-frame" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#df7331e0eecd1f1b7561045e64cbbfa22f93ea9c" +source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#ee3c700c54f78ceb66854d1d95ef098aebe868f1" dependencies = [ "serde", ] @@ -1471,17 +1512,21 @@ dependencies = [ [[package]] name = "ogar-encryption" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#df7331e0eecd1f1b7561045e64cbbfa22f93ea9c" +source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#ee3c700c54f78ceb66854d1d95ef098aebe868f1" dependencies = [ + "argon2", "encryption", + "getrandom", + "zeroize", ] [[package]] name = "ogar-render-askama" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#df7331e0eecd1f1b7561045e64cbbfa22f93ea9c" +source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#ee3c700c54f78ceb66854d1d95ef098aebe868f1" dependencies = [ "askama", + "askama_axum", "lance-graph-contract", "ogar-vocab", ] @@ -1489,7 +1534,7 @@ dependencies = [ [[package]] name = "ogar-vocab" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#df7331e0eecd1f1b7561045e64cbbfa22f93ea9c" +source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#ee3c700c54f78ceb66854d1d95ef098aebe868f1" [[package]] name = "once_cell" @@ -1537,13 +1582,11 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "phc", ] [[package]] @@ -1564,6 +1607,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1671,12 +1724,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rand_core" version = "0.10.1" diff --git a/crates/a2ui-server/src/desktop.rs b/crates/a2ui-server/src/desktop.rs index 4b70534..cf60b8d 100644 --- a/crates/a2ui-server/src/desktop.rs +++ b/crates/a2ui-server/src/desktop.rs @@ -267,7 +267,10 @@ impl DesktopSession { fmt, ) .map_err(DesktopError::Projection)?; - let sealed = self.down.seal_frame(&Frame::NodeDelta(projection.delta)); + let sealed = self + .down + .seal_frame(&Frame::NodeDelta(projection.delta)) + .map_err(DesktopError::Transport)?; Ok(RenderedFrame { sealed, html: projection.html, @@ -455,11 +458,13 @@ mod tests { let key = key_0102(); // The client seals an ActionInvoke (ordinal 1 = "Post") up the wire. let mut client_up = SealedTransport::new(srv.session_key(), Direction::ClientToServer); - let sealed = client_up.seal_frame(&CoreFrame::ActionInvoke(ActionInvoke { - key, - action_ordinal: 1, - args: vec![], - })); + let sealed = client_up + .seal_frame(&CoreFrame::ActionInvoke(ActionInvoke { + key, + action_ordinal: 1, + args: vec![], + })) + .unwrap(); let resolved = srv.receive_action(&sealed).unwrap(); assert_eq!(resolved.predicate, "action_post"); @@ -474,11 +479,13 @@ mod tests { assert_eq!(edges[0].seq, 0); // A second click advances the sequence; take_klickwege drains. - let sealed2 = client_up.seal_frame(&CoreFrame::ActionInvoke(ActionInvoke { - key, - action_ordinal: 0, - args: vec![], - })); + let sealed2 = client_up + .seal_frame(&CoreFrame::ActionInvoke(ActionInvoke { + key, + action_ordinal: 0, + args: vec![], + })) + .unwrap(); srv.receive_action(&sealed2).unwrap(); let drained = srv.take_klickwege(); assert_eq!(drained.len(), 2); @@ -504,11 +511,13 @@ mod tests { fn tampered_up_frame_is_refused_no_klickweg_recorded() { let mut srv = server(); let mut client_up = SealedTransport::new(srv.session_key(), Direction::ClientToServer); - let mut sealed = client_up.seal_frame(&CoreFrame::ActionInvoke(ActionInvoke { - key: key_0102(), - action_ordinal: 0, - args: vec![], - })); + let mut sealed = client_up + .seal_frame(&CoreFrame::ActionInvoke(ActionInvoke { + key: key_0102(), + action_ordinal: 0, + args: vec![], + })) + .unwrap(); let n = sealed.len(); sealed[n - 1] ^= 0x01; // flip a tag bit assert!(matches!( diff --git a/crates/a2ui-server/src/transport.rs b/crates/a2ui-server/src/transport.rs index 1afed73..f345b5e 100644 --- a/crates/a2ui-server/src/transport.rs +++ b/crates/a2ui-server/src/transport.rs @@ -43,21 +43,8 @@ //! reordered frame fails closed, it is never applied twice. use a2ui_core::{Frame, FrameError}; -use ogar_encryption::aead::{self, NONCE_LEN, TAG_LEN}; - -/// A sealed frame's fixed prefix: `direction(1) + counter(8)`. -const SEAL_PREFIX: usize = 1 + 8; - -/// Which half-duplex stream a sealed frame belongs to — the nonce -/// `direction` byte, so the two directions never share a `(key, nonce)`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum Direction { - /// Server → client (the addressed-surface `NodeDelta` stream). - ServerToClient = 0, - /// Client → server (the `ActionInvoke` stream). - ClientToServer = 1, -} +pub use ogar_encryption::stream::Direction; +use ogar_encryption::stream::{SealedStream, StreamError}; /// Why a sealed frame could not be produced or accepted. #[derive(Debug, Clone, PartialEq, Eq)] @@ -66,6 +53,15 @@ pub enum TransportError { Truncated, /// The `direction` byte was not a known [`Direction`]. BadDirection(u8), + /// The `direction` byte names the other half of the session: a frame + /// offered to the transport that does not receive it, for example a + /// sender's own frame reflected back at it. Refused, never applied. + WrongDirection { + /// The direction this transport receives. + expected: Direction, + /// The direction the frame carries. + got: Direction, + }, /// AEAD open failed — wrong key, or the blob was tampered with /// (indistinguishable by design). Decrypt, @@ -78,19 +74,38 @@ pub enum TransportError { /// The highest counter already accepted on that direction. last: u64, }, + /// The send counter is exhausted; the session must be re-keyed. + CounterExhausted, /// The decrypted plaintext was not a valid frame. Frame(FrameError), } +impl From for TransportError { + fn from(e: StreamError) -> Self { + match e { + StreamError::Truncated => Self::Truncated, + StreamError::BadDirection(b) => Self::BadDirection(b), + StreamError::WrongDirection { expected, got } => Self::WrongDirection { expected, got }, + StreamError::Replay { got, last } => Self::Replay { got, last }, + StreamError::Decrypt => Self::Decrypt, + StreamError::CounterExhausted => Self::CounterExhausted, + } + } +} + impl core::fmt::Display for TransportError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Truncated => f.write_str("sealed frame shorter than its prefix"), Self::BadDirection(b) => write!(f, "unknown transport direction {b}"), + Self::WrongDirection { expected, got } => { + write!(f, "frame for {got:?} offered to the {expected:?} receiver") + } Self::Decrypt => f.write_str("sealed frame failed to open (wrong key or tampered)"), Self::Replay { got, last } => { write!(f, "replayed/reordered frame: counter {got} <= last {last}") } + Self::CounterExhausted => f.write_str("send counter exhausted; re-key the session"), Self::Frame(e) => write!(f, "decrypted plaintext is not a valid frame: {e}"), } } @@ -98,17 +113,22 @@ impl core::fmt::Display for TransportError { impl std::error::Error for TransportError {} +fn other(d: Direction) -> Direction { + match d { + Direction::ServerToClient => Direction::ClientToServer, + Direction::ClientToServer => Direction::ServerToClient, + } +} + /// A sealed session transport over one direction of the stream. /// -/// Holds the 32-byte session key (derived once, see -/// [`crate::session::Session`]), the stream `direction` this half owns, its -/// own send counter, and the highest counter it has accepted on receive (for -/// replay rejection). Construct one per direction per endpoint. +/// A thin frame layer over [`ogar_encryption::stream::SealedStream`]: it seals +/// frames onto `direction` and opens only frames that carry `direction`. +/// Construct one per direction per endpoint, all with the session key (derived +/// once, see [`crate::session::Session`]). pub struct SealedTransport { - key: [u8; 32], - direction: Direction, - send_counter: u64, - last_recv_counter: Option, + sender: SealedStream, + receiver: SealedStream, } impl SealedTransport { @@ -116,49 +136,28 @@ impl SealedTransport { #[must_use] pub fn new(key: [u8; 32], direction: Direction) -> Self { Self { - key, - direction, - send_counter: 0, - last_recv_counter: None, + sender: SealedStream::new(key, direction), + // The receiving half of `direction` is the stream that sends the + // other way: it accepts only frames whose direction byte is + // `direction`. + receiver: SealedStream::new(key, other(direction)), } } - /// Build the 24-byte XChaCha nonce for `(direction, counter)`: - /// `[direction:1][counter:8 LE][zero:15]`. - fn nonce(direction: Direction, counter: u64) -> [u8; NONCE_LEN] { - let mut n = [0u8; NONCE_LEN]; - n[0] = direction as u8; - n[1..9].copy_from_slice(&counter.to_le_bytes()); - n - } - - /// Seal one [`Frame`] for transmission. Advances this half's send counter; - /// the returned bytes are `[direction][counter][ciphertext‖tag]`. + /// Seal one [`Frame`] for transmission. Advances this transport's send + /// counter; the returned bytes are `[direction][counter][ciphertext‖tag]`. /// /// The frame's own `to_le_bytes` is the plaintext — nothing serializes to /// wrap it (charter T3). `FRAME_VERSION` is bound as AEAD associated data, /// so a version mismatch on the wire fails the open. /// - /// # Panics + /// # Errors /// - /// Never in practice: `aead::seal_with_key` only fails on an internal - /// AEAD error, which cannot occur for a valid 32-byte key and 24-byte - /// nonce; the `expect` documents that invariant. - #[must_use] - pub fn seal_frame(&mut self, frame: &Frame) -> Vec { - let counter = self.send_counter; - self.send_counter += 1; - let nonce = Self::nonce(self.direction, counter); - let plaintext = frame.to_le_bytes(); + /// [`TransportError::CounterExhausted`] after 2^64 frames, or + /// [`TransportError::Decrypt`] if the AEAD reports an internal failure. + pub fn seal_frame(&mut self, frame: &Frame) -> Result, TransportError> { let aad = [a2ui_core::FRAME_VERSION]; - let ciphertext = aead::seal_with_key(&self.key, &nonce, &aad, &plaintext) - .expect("XChaCha20-Poly1305 seal cannot fail for a valid key+nonce"); - - let mut out = Vec::with_capacity(SEAL_PREFIX + ciphertext.len()); - out.push(self.direction as u8); - out.extend_from_slice(&counter.to_le_bytes()); - out.extend_from_slice(&ciphertext); - out + Ok(self.sender.seal(&aad, &frame.to_le_bytes())?) } /// Open one sealed frame, enforcing strictly-monotonic counters (replay / @@ -166,40 +165,13 @@ impl SealedTransport { /// /// # Errors /// - /// See [`TransportError`] — every arm is a refusal (truncation, bad - /// direction, AEAD failure, replay, or an invalid inner frame). On success - /// the accepted counter becomes the new watermark. + /// See [`TransportError`] — every arm is a refusal (truncation, wrong or + /// unknown direction, AEAD failure, replay, or an invalid inner frame). On + /// success the accepted counter becomes the new watermark. pub fn open_frame(&mut self, sealed: &[u8]) -> Result { - if sealed.len() < SEAL_PREFIX + TAG_LEN { - return Err(TransportError::Truncated); - } - let dir_byte = sealed[0]; - let direction = match dir_byte { - 0 => Direction::ServerToClient, - 1 => Direction::ClientToServer, - other => return Err(TransportError::BadDirection(other)), - }; - let mut ctr = [0u8; 8]; - ctr.copy_from_slice(&sealed[1..9]); - let counter = u64::from_le_bytes(ctr); - - // Replay / reorder rejection — BEFORE spending the AEAD open, so a - // flood of stale counters is cheap to shed. (The AEAD tag still - // authenticates the counter via the nonce, so a forged counter can't - // pass open anyway; this is defense-in-depth + ordering.) - if let Some(last) = self.last_recv_counter - && counter <= last - { - return Err(TransportError::Replay { got: counter, last }); - } - - let nonce = Self::nonce(direction, counter); let aad = [a2ui_core::FRAME_VERSION]; - let plaintext = aead::open_with_key(&self.key, &nonce, &aad, &sealed[SEAL_PREFIX..]) - .map_err(|_| TransportError::Decrypt)?; - let frame = Frame::from_le_bytes(&plaintext).map_err(TransportError::Frame)?; - self.last_recv_counter = Some(counter); - Ok(frame) + let plaintext = self.receiver.open(&aad, sealed)?; + Frame::from_le_bytes(&plaintext).map_err(TransportError::Frame) } } @@ -225,7 +197,7 @@ mod tests { let mut tx = SealedTransport::new(key(), Direction::ServerToClient); let mut rx = SealedTransport::new(key(), Direction::ServerToClient); let f = node_frame(); - let sealed = tx.seal_frame(&f); + let sealed = tx.seal_frame(&f).unwrap(); // The plaintext frame is NOT visible in the sealed bytes. assert!( !sealed.windows(4).any(|w| w == [1, 2, 3, 4]), @@ -239,7 +211,7 @@ mod tests { fn wrong_key_fails_to_open() { let mut tx = SealedTransport::new(key(), Direction::ServerToClient); let mut rx = SealedTransport::new([0xFFu8; 32], Direction::ServerToClient); - let sealed = tx.seal_frame(&node_frame()); + let sealed = tx.seal_frame(&node_frame()).unwrap(); assert_eq!(rx.open_frame(&sealed), Err(TransportError::Decrypt)); } @@ -247,7 +219,7 @@ mod tests { fn tamper_fails_to_open() { let mut tx = SealedTransport::new(key(), Direction::ServerToClient); let mut rx = SealedTransport::new(key(), Direction::ServerToClient); - let mut sealed = tx.seal_frame(&node_frame()); + let mut sealed = tx.seal_frame(&node_frame()).unwrap(); let n = sealed.len(); sealed[n - 1] ^= 0x01; // flip a ciphertext/tag bit assert_eq!(rx.open_frame(&sealed), Err(TransportError::Decrypt)); @@ -262,7 +234,7 @@ mod tests { action_ordinal: 3, args: vec![], }); - let sealed = tx.seal_frame(&f); + let sealed = tx.seal_frame(&f).unwrap(); assert!(rx.open_frame(&sealed).is_ok()); // Re-delivering the SAME sealed bytes must be refused (counter <= last). assert_eq!( @@ -275,8 +247,8 @@ mod tests { fn reorder_is_rejected() { let mut tx = SealedTransport::new(key(), Direction::ServerToClient); let mut rx = SealedTransport::new(key(), Direction::ServerToClient); - let s0 = tx.seal_frame(&node_frame()); - let s1 = tx.seal_frame(&node_frame()); + let s0 = tx.seal_frame(&node_frame()).unwrap(); + let s1 = tx.seal_frame(&node_frame()).unwrap(); // Accept #1 first (counter 1); then #0 (counter 0) must be refused. assert!(rx.open_frame(&s1).is_ok()); assert_eq!( @@ -285,6 +257,22 @@ mod tests { ); } + /// A server's own frame reflected back at its receiving half is refused. + /// Both directions share the key, so only the direction check stops it. + #[test] + fn a_reflected_frame_is_refused() { + let mut down = SealedTransport::new(key(), Direction::ServerToClient); + let mut up = SealedTransport::new(key(), Direction::ClientToServer); + let own = down.seal_frame(&node_frame()).unwrap(); + assert_eq!( + up.open_frame(&own), + Err(TransportError::WrongDirection { + expected: Direction::ClientToServer, + got: Direction::ServerToClient, + }) + ); + } + #[test] fn truncated_is_refused() { let mut rx = SealedTransport::new(key(), Direction::ServerToClient); diff --git a/crates/a2ui-server/tests/p_rehost.rs b/crates/a2ui-server/tests/p_rehost.rs index e3ddb21..b0168db 100644 --- a/crates/a2ui-server/tests/p_rehost.rs +++ b/crates/a2ui-server/tests/p_rehost.rs @@ -259,7 +259,7 @@ fn p_rehost_lite_harvest_to_rehost_no_winforms() { let mut server_down = SealedTransport::new(server.key(), Direction::ServerToClient); let mut client_down = SealedTransport::new(client.key(), Direction::ServerToClient); let down_frame = Frame::NodeDelta(projection.delta.clone()); - let sealed = server_down.seal_frame(&down_frame); + let sealed = server_down.seal_frame(&down_frame).unwrap(); // The plaintext bytes are not visible in the sealed blob. assert!( !sealed.windows(16).any(|w| w == key), @@ -285,7 +285,7 @@ fn p_rehost_lite_harvest_to_rehost_no_winforms() { action_ordinal: 1, // "Post" — an address, never a handler args: vec![], }); - let sealed_up = client_up.seal_frame(&invoke); + let sealed_up = client_up.seal_frame(&invoke).unwrap(); let received_up = server_up.open_frame(&sealed_up).unwrap(); let Frame::ActionInvoke(ai) = received_up else { panic!("expected an ActionInvoke up the wire");