[M5] mxaccess-asb: F25 step 7 — Disconnect closes the session lifecycle
Mirrors `AsbContracts.cs:109-114` — same payload shape as AuthenticateMe (Data + InitializationVector under ConsumerAuthenticationData) but under the `<DisconnectRequest>` 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user