From 1b1ee1e0b7ae192516380c3a06d70c4e028953a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 11:51:39 -0400 Subject: [PATCH] =?UTF-8?q?[M5]=20mxaccess-asb:=20F25=20step=207=20?= =?UTF-8?q?=E2=80=94=20Disconnect=20closes=20the=20session=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors `AsbContracts.cs:109-114` — same payload shape as AuthenticateMe (Data + InitializationVector under ConsumerAuthenticationData) but under the `` wrapper. Sent one-way + signed (regular HMAC, no force) per `AsbContracts.cs:22` (`IsOneWay = true`). API additions: * `build_disconnect_request_body(data, iv)` — NBFX token stream for the DisconnectRequest body. * `AsbClient::disconnect()` — builds a fresh encrypted authentication-data blob via F23's `create_authentication_data()` (encrypts `local_pub || remote_pub` under the derived AES key with a fresh IV), wraps it in a DisconnectRequest, sends one-way signed. 2 new tests: * `disconnect_request_carries_data_and_iv_under_correct_wrapper` — outer element name + Data/IV byte-payload order. * `disconnect_writes_signed_one_way_envelope` — end-to-end via `tokio::io::duplex` peer; verifies the SizedEnvelope payload contains the `:disconnectIn` action string. With Disconnect landed, AsbClient now covers the full session lifecycle: send_preamble → connect → register_items / read / keep_alive / unregister_items → disconnect → send_end → stream shutdown Co-Authored-By: Claude Opus 4.7 (1M context) --- design/followups.md | 6 +- rust/crates/mxaccess-asb/src/client.rs | 68 ++++++++++++++++++- rust/crates/mxaccess-asb/src/lib.rs | 7 +- rust/crates/mxaccess-asb/src/operations.rs | 77 ++++++++++++++++++++++ 4 files changed, 152 insertions(+), 6 deletions(-) diff --git a/design/followups.md b/design/followups.md index be4351b..6abccba 100644 --- a/design/followups.md +++ b/design/followups.md @@ -46,7 +46,11 @@ move to `## Resolved` with a date + commit hash. **Resolves when:** F19-F26 are all closed and the four DoD bullets above pass. -**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 (`c4bf0a0`); F25 step 4 (`1e59249`); F25 step 5 (`9b8133f`); F25 step 6 landed in this commit: +**Cumulative execution log.** F19 + F23 (`ed17c07`); F24 (`7611d9e`); F20 (`9dfd193`); F22 (`43c10a1`); F21 (`5f98558`); F25 step 1 (`25dbd8d`); F25 step 2 (`a2b8989`); F25 step 3 (`c4bf0a0`); F25 step 4 (`1e59249`); F25 step 5 (`9b8133f`); F25 step 6 (`321b796`); F25 step 7 landed in this commit: +- F25 step 7: Disconnect operation (closes the connection lifecycle: Connect → ops → Disconnect → End → close). New `build_disconnect_request_body(data, iv)` mirrors `AsbContracts.cs:109-114` (``) — same payload shape as AuthenticateMe but under a different wrapper element. New `client::disconnect()` builds a fresh encrypted authentication-data blob via F23's `create_authentication_data` (encrypts `local_pub || remote_pub` under the derived AES key with a fresh IV), wraps it, and sends one-way + signed (regular HMAC, no force). 2 new tests: `disconnect_request_carries_data_and_iv_under_correct_wrapper` (checks wrapper element name + Data/IV byte ordering), and end-to-end `disconnect_writes_signed_one_way_envelope` via `tokio::io::duplex` peer that verifies the encoded SizedEnvelope contains the disconnectIn action string. With Disconnect landed, `AsbClient` now covers the full session lifecycle: `send_preamble().await? → connect().await? → register_items()/read()/keep_alive()/unregister_items() → disconnect().await? → send_end().await?`. + +**Earlier slices:** +- F25 step 6 (commit `321b796`): - F25 step 6: Connect + AuthenticateMe handshake — the critical-path piece that turns a fresh TCP stream into an authenticated session. New `build_connect_request_body` (carries connection-id GUID + consumer public key bytes; sent **unsigned** because no shared secret exists yet), `build_authenticate_me_request_body` (carries encrypted Data + IV; sent **one-way + signed with `forceHmac=true`** per `MxAsbDataClient.cs:106-111`), `decode_connect_response` (extracts ServicePublicKey, optional ServiceAuthenticationData, optional ConnectionLifetime — handles the `:V2` Apollo lifetime suffix that toggles F23's encryption mode), `AuthenticationDataBytes` struct, and `client::connect` orchestration that runs the full handshake: ConnectRequest → ConnectResponse → `accept_connect_response` (derives shared secret) → `create_authentication_data` (encrypted local_pub || remote_pub) → AuthenticateMeRequest one-way. 6 new tests cover ConnectRequest body shape (carries hyphenated GUID + public-key bytes), AuthenticateMe body shape (Data + IV bytes), ConnectResponse round-trip with all optional fields, ConnectResponse without optional fields, MissingField error when ServicePublicKey absent, and an end-to-end client::connect handshake test via `tokio::io::duplex` peer that synthesises a ConnectResponse with bob's public key (so DH shared-secret derivation works) and drains the AuthenticateMe one-way SizedEnvelope. **Wire-byte caveat**: WCF XML serialization of `byte[]` may include `xsi:type` attributes or distinct namespaces that this builder doesn't yet emit; live-probe iteration will reconcile. **Earlier slices:** diff --git a/rust/crates/mxaccess-asb/src/client.rs b/rust/crates/mxaccess-asb/src/client.rs index 5d718d2..4cef3f6 100644 --- a/rust/crates/mxaccess-asb/src/client.rs +++ b/rust/crates/mxaccess-asb/src/client.rs @@ -56,8 +56,8 @@ use crate::contracts::{ItemIdentity, ItemStatus}; use crate::envelope::{ConnectionValidator, EnvelopeError, SoapEnvelope}; use crate::operations::{ ConnectResponse, OperationError, ReadResponse, RegisterItemsResponse, UnregisterItemsResponse, - build_authenticate_me_request_body, build_connect_request_body, build_keep_alive_request_body, - build_read_request_body, build_register_items_request_body, + build_authenticate_me_request_body, build_connect_request_body, build_disconnect_request_body, + build_keep_alive_request_body, build_read_request_body, build_register_items_request_body, build_unregister_items_request_body, decode_connect_response, decode_read_response, decode_register_items_response, decode_unregister_items_response, }; @@ -296,6 +296,25 @@ impl AsbClient { self.send_envelope_one_way(&signed_env).await } + /// `Disconnect` operation — one-way signed envelope carrying a + /// fresh encrypted authentication-data blob, used to close the ASB + /// session cleanly. Mirrors `AsbContracts.cs:22` (one-way op) + + /// `MxAsbDataClient`'s graceful-close path. + /// + /// Builds an `AuthenticationData` payload via F23's + /// `create_authentication_data()` (which encrypts `local_pub || + /// remote_pub` under the derived AES key — same payload shape as + /// `AuthenticateMe` but using a fresh IV). + /// + /// Caller should typically follow this with [`Self::send_end`] + + /// `stream.shutdown()`. + pub async fn disconnect(&mut self) -> Result<(), ClientError> { + let auth_data = self.authenticator.create_authentication_data()?; + let body = build_disconnect_request_body(&auth_data.ciphertext, &auth_data.iv); + self.send_signed_envelope_one_way(actions::DISCONNECT, body, false) + .await + } + /// `KeepAlive` operation — one-way signed envelope with an empty /// `KeepAliveRequest` body. Used to keep the channel alive past /// the WCF inactivity timeout (`MxAsbDataClient.cs:683`, @@ -823,6 +842,51 @@ mod tests { let _ = peer_task.await.unwrap(); } + #[tokio::test] + async fn disconnect_writes_signed_one_way_envelope() { + let (client_end, peer_end) = tokio::io::duplex(8192); + let peer_task = spawn_peer(peer_end, |mut peer| async move { + // Drain preamble + ack + let mut buf = vec![0u8; 256]; + let _n = peer.read(&mut buf).await.unwrap(); + peer.write_all(&[0x0Bu8]).await.unwrap(); + peer.flush().await.unwrap(); + + // Drain Disconnect SizedEnvelope (one-way — no reply needed) + let mut typebyte = [0u8; 1]; + peer.read_exact(&mut typebyte).await.unwrap(); + assert_eq!(typebyte[0], 0x06); + let mut lenbuf = Vec::new(); + for _ in 0..5 { + let mut b = [0u8; 1]; + peer.read_exact(&mut b).await.unwrap(); + lenbuf.push(b[0]); + if b[0] & 0x80 == 0 { + break; + } + } + let mut cursor = 0; + let len = mxaccess_asb_nettcp::nmf::decode_multibyte_int31(&lenbuf, &mut cursor) + .unwrap() as usize; + let payload = read_n(&mut peer, len).await; + // Sanity check: the Disconnect action string appears in the + // (NBFX-encoded) envelope bytes. + let action = b"http://asb.contracts/20111111:disconnectIn"; + assert!(payload.windows(action.len()).any(|w| w == action)); + peer + }); + + let mut client = AsbClient::new(client_end, make_authenticator(), "test://h/p"); + client.send_preamble().await.unwrap(); + // Need a remote public key so create_authentication_data can run. + let bob = make_authenticator(); + client + .authenticator_mut() + .accept_connect_response(bob.local_public_key(), None); + client.disconnect().await.unwrap(); + let _ = peer_task.await.unwrap(); + } + #[tokio::test] async fn connect_handshake_round_trips_through_in_memory_peer() { let (client_end, peer_end) = tokio::io::duplex(8192); diff --git a/rust/crates/mxaccess-asb/src/lib.rs b/rust/crates/mxaccess-asb/src/lib.rs index 8cfaa9e..cc25b9b 100644 --- a/rust/crates/mxaccess-asb/src/lib.rs +++ b/rust/crates/mxaccess-asb/src/lib.rs @@ -27,7 +27,8 @@ pub use envelope::{ pub use operations::{ AuthenticationDataBytes, ConnectResponse, OperationError, ReadResponse, RegisterItemsResponse, UnregisterItemsResponse, build_authenticate_me_request_body, build_connect_request_body, - build_keep_alive_request_body, build_read_request_body, build_register_items_request_body, - build_unregister_items_request_body, collect_asbidata_payloads, decode_connect_response, - decode_read_response, decode_register_items_response, decode_unregister_items_response, + build_disconnect_request_body, build_keep_alive_request_body, build_read_request_body, + build_register_items_request_body, build_unregister_items_request_body, + collect_asbidata_payloads, decode_connect_response, decode_read_response, + decode_register_items_response, decode_unregister_items_response, }; diff --git a/rust/crates/mxaccess-asb/src/operations.rs b/rust/crates/mxaccess-asb/src/operations.rs index cf44375..719e365 100644 --- a/rust/crates/mxaccess-asb/src/operations.rs +++ b/rust/crates/mxaccess-asb/src/operations.rs @@ -145,6 +145,46 @@ pub fn build_connect_request_body( tokens } +/// Build the NBFX token stream for `DisconnectIn`. Mirrors +/// `AsbContracts.cs:109-114`: +/// ```xml +/// +/// +/// {encrypted-bytes} +/// {iv-bytes} +/// +/// +/// ``` +/// +/// One-way op (`IsOneWay = true` at `AsbContracts.cs:22`); typically +/// signed with the connection validator (no `forceHmac`) right before +/// closing the channel. +pub fn build_disconnect_request_body( + consumer_data: &[u8], + initialization_vector: &[u8], +) -> Vec { + let mut tokens = vec![ + NbfxToken::Element { + prefix: None, + name: NbfxName::Inline("DisconnectRequest".to_string()), + }, + NbfxToken::DefaultNamespace { + value: NbfxText::Chars(MESSAGES_NS.to_string()), + }, + NbfxToken::Element { + prefix: None, + name: NbfxName::Inline("ConsumerAuthenticationData".to_string()), + }, + ]; + tokens.extend(authentication_data_fields( + consumer_data, + initialization_vector, + )); + tokens.push(NbfxToken::EndElement); // + tokens.push(NbfxToken::EndElement); // + tokens +} + /// Build the NBFX token stream for `AuthenticateMeIn`. Sent /// **one-way** + **signed with `forceHmac=true`** per /// `MxAsbDataClient.cs:106-111`: @@ -974,6 +1014,43 @@ mod tests { assert!(found_pubkey_bytes, "ConsumerPublicKey/Data bytes not found"); } + #[test] + fn disconnect_request_carries_data_and_iv_under_correct_wrapper() { + let data = vec![0xDEu8, 0xAD]; + let iv = vec![0xBEu8, 0xEF]; + let body = build_disconnect_request_body(&data, &iv); + assert!(matches!( + &body[0], + NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "DisconnectRequest" + )); + // Walk for the ConsumerAuthenticationData wrapper. + let mut saw_consumer_auth_data = false; + for tok in &body { + if let NbfxToken::Element { + name: NbfxName::Inline(local), + .. + } = tok + { + if local == "ConsumerAuthenticationData" { + saw_consumer_auth_data = true; + } + } + } + assert!(saw_consumer_auth_data); + + let bytes_payloads: Vec> = body + .iter() + .filter_map(|tok| { + if let NbfxToken::Text(NbfxText::Bytes(b)) = tok { + Some(b.clone()) + } else { + None + } + }) + .collect(); + assert_eq!(bytes_payloads, vec![data, iv]); + } + #[test] fn authenticate_me_request_carries_data_and_iv() { let data = vec![0x01, 0x02, 0x03];