[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:
Joseph Doherty
2026-05-05 11:51:39 -04:00
parent 321b7963a4
commit 1b1ee1e0b7
4 changed files with 152 additions and 6 deletions
+5 -1
View File
@@ -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` (`<DisconnectRequest><ConsumerAuthenticationData><Data/><InitializationVector/></ConsumerAuthenticationData></DisconnectRequest>`) — 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 `<PublicKey><Data>byte[]</Data>` may include `xsi:type` attributes or distinct namespaces that this builder doesn't yet emit; live-probe iteration will reconcile.
**Earlier slices:**
+66 -2
View File
@@ -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);
+4 -3
View File
@@ -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,
};
@@ -145,6 +145,46 @@ pub fn build_connect_request_body(
tokens
}
/// Build the NBFX token stream for `DisconnectIn`. Mirrors
/// `AsbContracts.cs:109-114`:
/// ```xml
/// <DisconnectRequest xmlns="http://asb.contracts.messages/20111111">
/// <ConsumerAuthenticationData>
/// <Data>{encrypted-bytes}</Data>
/// <InitializationVector>{iv-bytes}</InitializationVector>
/// </ConsumerAuthenticationData>
/// </DisconnectRequest>
/// ```
///
/// 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<NbfxToken> {
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); // </ConsumerAuthenticationData>
tokens.push(NbfxToken::EndElement); // </DisconnectRequest>
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<Vec<u8>> = 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];