[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
@@ -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];