[M5] mxaccess-asb: F25 step 6 — Connect/AuthenticateMe handshake

Critical-path piece that turns a fresh TCP stream into an
authenticated session. With this slice landed, an `AsbClient` can
now do `send_preamble().await? -> connect().await? -> register_items()`
end-to-end against a peer.

Operations API additions:
* `build_connect_request_body(connection_id, public_key)` — first op
  on a fresh session. **Unsigned** (no ConnectionValidator header)
  because the authenticator hasn't received the service key yet.
  Wire shape: `<ConnectRequest xmlns="…messages/20111111">
    <ConnectionId>{guid-text}</ConnectionId>
    <ConsumerPublicKey><Data>{pubkey-bytes}</Data></ConsumerPublicKey>
  </ConnectRequest>` per `AsbContracts.cs:78-86`.
* `build_authenticate_me_request_body(data, iv)` — second op,
  **one-way + signed with `forceHmac=true`** per `MxAsbDataClient.cs
  :106-111`. Carries the encrypted `local_pub || remote_pub` blob
  produced by F23's `create_authentication_data()`.
* `ConnectResponse { service_public_key, service_authentication_data,
  connection_lifetime }` + `AuthenticationDataBytes { data, iv }`.
* `decode_connect_response(body, dict)` — extracts ServicePublicKey
  (required), optional ServiceAuthenticationData, optional
  ConnectionLifetime. The lifetime's `:V2` suffix is what F23
  inspects to toggle Apollo (raw AES) vs Baktun (deflate-then-AES)
  encryption.

Client API addition:
* `AsbClient::connect()` — orchestrates the full handshake:
  1. Build + send ConnectRequest (unsigned) carrying our DH public
     key + connection-id GUID.
  2. Decode ConnectResponse.
  3. `authenticator.accept_connect_response(...)` — feeds the
     service public key + lifetime into F23 so it derives the
     shared secret and picks Apollo/Baktun.
  4. `authenticator.create_authentication_data()` — encrypts
     `local_pub || remote_pub` under the derived AES key.
  5. Send AuthenticateMeRequest (one-way, signed with HMAC-SHA1
     forced).
  Returns the `ConnectResponse` so callers can inspect the
  negotiated connection lifetime.

6 new tests:
* ConnectRequest carries hyphenated GUID + raw public-key bytes.
* AuthenticateMe carries Data + IV bytes in order.
* ConnectResponse round-trip with all optional fields populated.
* ConnectResponse round-trip without optional fields.
* ConnectResponse decoder surfaces MissingField when
  ServicePublicKey is absent.
* End-to-end client::connect handshake via `tokio::io::duplex`
  peer that synthesises a ConnectResponse using bob's public key
  (so DH shared-secret derivation actually works) and drains the
  AuthenticateMe one-way SizedEnvelope.

Wire-byte caveat documented inline: WCF XML serialization may add
`xsi:type` attributes / distinct namespaces around <PublicKey> /
<AuthenticationData>; this builder ships the simplest plausible
shape and the live-probe iteration will reconcile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-05 11:47:35 -04:00
parent 9b8133f725
commit 321b7963a4
5 changed files with 614 additions and 8 deletions
+429
View File
@@ -90,6 +90,270 @@ pub fn build_read_request_body(items: &[ItemIdentity]) -> Vec<NbfxToken> {
asbidata_request_body("ReadRequest", &[BodyField::asbidata("Items", payload)])
}
/// Build the NBFX token stream for a `ConnectIn` request body.
/// `ConnectRequest` is the first operation a fresh ASB session sends —
/// it carries the consumer's DH public key + a fresh `ConnectionId`
/// GUID. Sent **unsigned** (no `ConnectionValidator` header) since the
/// authenticator hasn't received the service's public key yet.
///
/// Wire shape (mirrors `AsbContracts.cs:78-86`):
/// ```xml
/// <ConnectRequest xmlns="http://asb.contracts.messages/20111111">
/// <ConnectionId>{guid-text}</ConnectionId>
/// <ConsumerPublicKey>
/// <Data>{public-key-bytes}</Data>
/// </ConsumerPublicKey>
/// </ConnectRequest>
/// ```
///
/// **Wire-byte caveat**: WCF's XML serialiser emits the `<Data>`
/// `byte[]` member via `WriteBase64`, which the binary-message encoder
/// represents as a `BytesXText` NBFX record (raw binary, not base64
/// text). For services using DataContract serialisation, the inner
/// `PublicKey` element may also receive an `xsi:type` attribute or a
/// distinct namespace — until a live capture confirms the exact
/// wire form, this builder uses the simplest plausible shape. F25
/// live-probe iteration will reconcile.
pub fn build_connect_request_body(
connection_id: [u8; 16],
consumer_public_key: &[u8],
) -> Vec<NbfxToken> {
let mut tokens = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectRequest".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(MESSAGES_NS.to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectionId".to_string()),
},
NbfxToken::Text(NbfxText::Chars(crate::envelope::format_uuid_for_test(
&connection_id,
))),
NbfxToken::EndElement, // </ConnectionId>
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConsumerPublicKey".to_string()),
},
];
tokens.extend(public_key_data_field(consumer_public_key));
tokens.push(NbfxToken::EndElement); // </ConsumerPublicKey>
tokens.push(NbfxToken::EndElement); // </ConnectRequest>
tokens
}
/// Build the NBFX token stream for `AuthenticateMeIn`. Sent
/// **one-way** + **signed with `forceHmac=true`** per
/// `MxAsbDataClient.cs:106-111`:
/// ```xml
/// <AuthenticateMeRequest xmlns="http://asb.contracts.messages/20111111">
/// <ConsumerAuthenticationData>
/// <Data>{encrypted-bytes}</Data>
/// <InitializationVector>{iv-bytes}</InitializationVector>
/// </ConsumerAuthenticationData>
/// </AuthenticateMeRequest>
/// ```
pub fn build_authenticate_me_request_body(
consumer_data: &[u8],
initialization_vector: &[u8],
) -> Vec<NbfxToken> {
let mut tokens = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("AuthenticateMeRequest".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); // </AuthenticateMeRequest>
tokens
}
fn public_key_data_field(data: &[u8]) -> Vec<NbfxToken> {
vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(data.to_vec())),
NbfxToken::EndElement,
]
}
fn authentication_data_fields(data: &[u8], iv: &[u8]) -> Vec<NbfxToken> {
vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(data.to_vec())),
NbfxToken::EndElement,
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("InitializationVector".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(iv.to_vec())),
NbfxToken::EndElement,
]
}
/// Decoded `ConnectResponse`. Mirrors `AsbContracts.cs:88-100`.
#[derive(Debug, Clone, PartialEq)]
pub struct ConnectResponse {
/// Service public key bytes (`PublicKey.Data`). Required.
pub service_public_key: Vec<u8>,
/// Service authentication data — encrypted blob + IV. Optional;
/// some service versions omit it.
pub service_authentication_data: Option<AuthenticationDataBytes>,
/// Negotiated connection lifetime (xs:duration string like
/// `"PT60M:V2"`). The `:V2` suffix toggles Apollo signing in F23.
pub connection_lifetime: Option<String>,
}
/// `AuthenticationData` payload (`Data` + `InitializationVector`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthenticationDataBytes {
pub data: Vec<u8>,
pub initialization_vector: Vec<u8>,
}
/// Decode a `ConnectResponse` SOAP body from the NBFX tokens returned
/// by [`crate::decode_envelope`].
pub fn decode_connect_response(
body_tokens: &[NbfxToken],
dynamic: &mxaccess_asb_nettcp::nbfx::DynamicDictionary,
) -> Result<ConnectResponse, OperationError> {
let service_public_key = find_inline_bytes(body_tokens, &["ServicePublicKey", "Data"]).ok_or(
OperationError::MissingField {
field: "ServicePublicKey/Data",
},
)?;
let service_authentication_data =
find_authentication_data(body_tokens, "ServiceAuthenticationData");
let connection_lifetime = find_inline_text(body_tokens, "ConnectionLifetime", dynamic);
Ok(ConnectResponse {
service_public_key,
service_authentication_data,
connection_lifetime,
})
}
/// Walk `tokens` and find the inner `Bytes` payload of an element-path
/// like `["ServicePublicKey", "Data"]` (i.e. `<ServicePublicKey><Data>{Bytes}</Data></ServicePublicKey>`).
/// Permissive — skips attributes / namespace decls between element opens.
fn find_inline_bytes(tokens: &[NbfxToken], path: &[&str]) -> Option<Vec<u8>> {
let mut idx = 0;
let mut path_idx = 0;
while let Some(tok) = tokens.get(idx) {
if path_idx == path.len() {
// Should be a Text(Bytes) here (after skipping attribute-like tokens).
let mut inner = idx;
while matches!(
tokens.get(inner),
Some(NbfxToken::Attribute { .. })
| Some(NbfxToken::DefaultNamespace { .. })
| Some(NbfxToken::NamespaceDeclaration { .. })
) {
inner += 1;
}
if let Some(NbfxToken::Text(NbfxText::Bytes(bytes))) = tokens.get(inner) {
return Some(bytes.clone());
}
return None;
}
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if let Some(target) = path.get(path_idx) {
if local == target {
path_idx += 1;
}
}
}
idx += 1;
}
None
}
fn find_authentication_data(
tokens: &[NbfxToken],
outer_name: &str,
) -> Option<AuthenticationDataBytes> {
// Find the outer element, then within its scope locate Data and IV.
let mut idx = 0;
while let Some(tok) = tokens.get(idx) {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if local == outer_name {
let data = find_inline_bytes(tokens.get(idx + 1..)?, &["Data"]).unwrap_or_default();
let iv = find_inline_bytes(tokens.get(idx + 1..)?, &["InitializationVector"])
.unwrap_or_default();
if data.is_empty() && iv.is_empty() {
return None;
}
return Some(AuthenticationDataBytes {
data,
initialization_vector: iv,
});
}
}
idx += 1;
}
None
}
fn find_inline_text(
tokens: &[NbfxToken],
name: &str,
dynamic: &mxaccess_asb_nettcp::nbfx::DynamicDictionary,
) -> Option<String> {
let mut idx = 0;
while let Some(tok) = tokens.get(idx) {
if let NbfxToken::Element {
name: NbfxName::Inline(local),
..
} = tok
{
if local == name {
let mut inner = idx + 1;
while matches!(
tokens.get(inner),
Some(NbfxToken::Attribute { .. })
| Some(NbfxToken::DefaultNamespace { .. })
| Some(NbfxToken::NamespaceDeclaration { .. })
) {
inner += 1;
}
if let Some(NbfxToken::Text(text)) = tokens.get(inner) {
return text.resolve(dynamic);
}
}
}
idx += 1;
}
None
}
/// Build the NBFX token stream for a `KeepAliveIn` request body. The
/// `KeepAlive` contract has no body fields beyond the inherited
/// `ConnectionValidator` header, so the body is just the empty wrapper
@@ -681,6 +945,171 @@ mod tests {
));
}
#[test]
fn connect_request_carries_connection_id_and_public_key() {
let cid = [0x12u8; 16];
let pubkey = vec![0xAB, 0xCD, 0xEF];
let body = build_connect_request_body(cid, &pubkey);
// Outer wrapper
assert!(matches!(
&body[0],
NbfxToken::Element { name: NbfxName::Inline(s), .. } if s == "ConnectRequest"
));
// ConnectionId text contains hyphenated GUID form
let mut found_guid = false;
let mut found_pubkey_bytes = false;
for tok in &body {
if let NbfxToken::Text(NbfxText::Chars(s)) = tok {
if s.contains('-') && s.len() == 36 {
found_guid = true;
}
}
if let NbfxToken::Text(NbfxText::Bytes(b)) = tok {
if *b == pubkey {
found_pubkey_bytes = true;
}
}
}
assert!(found_guid, "ConnectionId text not found");
assert!(found_pubkey_bytes, "ConsumerPublicKey/Data bytes not found");
}
#[test]
fn authenticate_me_request_carries_data_and_iv() {
let data = vec![0x01, 0x02, 0x03];
let iv = vec![0x04, 0x05];
let body = build_authenticate_me_request_body(&data, &iv);
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 connect_response_round_trip() {
// Build a synthetic ConnectResponse body and decode it back.
let svc_pubkey = vec![0xFEu8, 0xED, 0xFA, 0xCE];
let svc_data = vec![0xBEu8, 0xEF];
let svc_iv = vec![0xCAu8, 0xFE];
let lifetime = "PT60M:V2".to_string();
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
let body: Vec<NbfxToken> = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectResponse".to_string()),
},
NbfxToken::DefaultNamespace {
value: NbfxText::Chars(MESSAGES_NS.to_string()),
},
// ServicePublicKey
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ServicePublicKey".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(svc_pubkey.clone())),
NbfxToken::EndElement,
NbfxToken::EndElement,
// ServiceAuthenticationData
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ServiceAuthenticationData".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(svc_data.clone())),
NbfxToken::EndElement,
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("InitializationVector".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(svc_iv.clone())),
NbfxToken::EndElement,
NbfxToken::EndElement,
// ConnectionLifetime
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectionLifetime".to_string()),
},
NbfxToken::Text(NbfxText::Chars(lifetime.clone())),
NbfxToken::EndElement,
// </ConnectResponse>
NbfxToken::EndElement,
];
let dict = DynamicDictionary::new();
let decoded = decode_connect_response(&body, &dict).unwrap();
assert_eq!(decoded.service_public_key, svc_pubkey);
assert_eq!(
decoded.service_authentication_data,
Some(AuthenticationDataBytes {
data: svc_data,
initialization_vector: svc_iv,
})
);
assert_eq!(decoded.connection_lifetime.as_deref(), Some("PT60M:V2"));
}
#[test]
fn connect_response_without_optional_fields() {
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
let body: Vec<NbfxToken> = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectResponse".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ServicePublicKey".to_string()),
},
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("Data".to_string()),
},
NbfxToken::Text(NbfxText::Bytes(vec![1, 2, 3])),
NbfxToken::EndElement,
NbfxToken::EndElement,
NbfxToken::EndElement,
];
let dict = DynamicDictionary::new();
let decoded = decode_connect_response(&body, &dict).unwrap();
assert_eq!(decoded.service_public_key, vec![1u8, 2, 3]);
assert!(decoded.service_authentication_data.is_none());
assert!(decoded.connection_lifetime.is_none());
}
#[test]
fn connect_response_missing_service_public_key_fails() {
use mxaccess_asb_nettcp::nbfx::DynamicDictionary;
let body: Vec<NbfxToken> = vec![
NbfxToken::Element {
prefix: None,
name: NbfxName::Inline("ConnectResponse".to_string()),
},
NbfxToken::EndElement,
];
let dict = DynamicDictionary::new();
let err = decode_connect_response(&body, &dict).unwrap_err();
assert!(matches!(
err,
OperationError::MissingField {
field: "ServicePublicKey/Data"
}
));
}
#[test]
fn keep_alive_body_is_empty_wrapper_with_namespace() {
let body = build_keep_alive_request_body();