diff --git a/design/followups.md b/design/followups.md index f318eb7..be4351b 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 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 landed in this commit: +- 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:** +- F25 step 5 (commit `9b8133f`): - F25 step 5: extends `AsbClient` with one-way operation support + `KeepAlive` + `Read` wrappers. New `send_envelope_one_way` / `send_signed_envelope_one_way` mirror WCF's `[OperationContract(IsOneWay = true)]` semantics — write the SizedEnvelope and return immediately. New `client::keep_alive` ports `MxAsbDataClient`'s channel inactivity-keepalive (`AsbContracts.cs:117` — empty wrapper element + ConnectionValidator header). New `client::read` + `decode_read_response` (in operations) decode `Status` (`Vec`) + `Values` (`Vec`) from the dual-``-payload `ReadResponse` body shape. RuntimeValue array decode mirrors `AsbContracts.cs:771-780` (4-byte int32 count + per-element `WriteToStream`). 5 new tests: keep_alive body shape (empty wrapper), ReadResponse round-trip with Status + Values, ReadResponse-with-no-Values graceful handling, plus two end-to-end client tests via `tokio::io::duplex` peer (keep_alive one-way send drains the SizedEnvelope but produces no response, read round-trips Status + Values from a synthetic ReadResponse). **Earlier slices:** diff --git a/rust/crates/mxaccess-asb/src/client.rs b/rust/crates/mxaccess-asb/src/client.rs index 6bc71ec..5d718d2 100644 --- a/rust/crates/mxaccess-asb/src/client.rs +++ b/rust/crates/mxaccess-asb/src/client.rs @@ -55,10 +55,11 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use crate::contracts::{ItemIdentity, ItemStatus}; use crate::envelope::{ConnectionValidator, EnvelopeError, SoapEnvelope}; use crate::operations::{ - OperationError, ReadResponse, RegisterItemsResponse, UnregisterItemsResponse, - build_keep_alive_request_body, build_read_request_body, build_register_items_request_body, - build_unregister_items_request_body, decode_read_response, decode_register_items_response, - decode_unregister_items_response, + 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, decode_connect_response, decode_read_response, + decode_register_items_response, decode_unregister_items_response, }; use crate::{actions, decode_envelope, encode_envelope}; @@ -196,6 +197,62 @@ impl AsbClient { self.send_envelope(&signed_env).await } + /// Run the full DH `Connect` + `AuthenticateMe` handshake. Mirrors + /// `MxAsbDataClient.cs:84-112`: + /// + /// 1. Send `ConnectRequest` (unsigned — the authenticator hasn't + /// received the service key yet) carrying our connection ID + + /// public key. + /// 2. Receive `ConnectResponse` containing the service public key + /// + optional connection lifetime + optional service auth data. + /// 3. Call `authenticator.accept_connect_response(...)` so it can + /// derive the shared secret + decide on Apollo vs Baktun + /// encryption based on the `:V2` lifetime suffix. + /// 4. Build encrypted `ConsumerAuthenticationData` via + /// `authenticator.create_authentication_data()` (this is + /// `Encrypt(local_pub || remote_pub)` — see F23). + /// 5. Send signed `AuthenticateMeRequest` with `forceHmac=true` + /// (one-way, no response expected). + /// + /// Caller must have called [`Self::send_preamble`] first. Returns + /// the `ConnectResponse` so callers can inspect the negotiated + /// connection lifetime. + pub async fn connect(&mut self) -> Result { + if !self.preamble_sent { + return Err(ClientError::PreambleNotSent); + } + + // Step 1: ConnectRequest (unsigned) + let connection_id = self.authenticator.connection_id(); + let public_key = self.authenticator.local_public_key().to_vec(); + let connect_body = build_connect_request_body(connection_id, &public_key); + let unsigned_env = SoapEnvelope::new(actions::CONNECT).with_body_tokens(connect_body); + + // Step 2: send + receive ConnectResponse + let response_env = self.send_envelope(&unsigned_env).await?; + let connect_response = + decode_connect_response(&response_env.body_tokens, &self.read_dictionary)?; + + // Step 3: feed the service public key + lifetime into the + // authenticator so it can derive the shared secret. + self.authenticator.accept_connect_response( + &connect_response.service_public_key, + connect_response.connection_lifetime.as_deref(), + ); + + // Step 4: build encrypted authentication data (local_pub || + // remote_pub, encrypted under the derived AES key). Errors + // surface through ClientError::Auth. + let auth_data = self.authenticator.create_authentication_data()?; + + // Step 5: AuthenticateMe one-way, signed with HMAC-SHA1 forced. + let auth_body = build_authenticate_me_request_body(&auth_data.ciphertext, &auth_data.iv); + self.send_signed_envelope_one_way(actions::AUTHENTICATE_ME, auth_body, true) + .await?; + + Ok(connect_response) + } + /// One-way send: encode + frame + write, but do **not** read a /// response. Mirrors WCF's `[OperationContract(IsOneWay = true)]` /// semantics — `KeepAlive`, `Disconnect`, and `AuthenticateMe` all @@ -766,6 +823,113 @@ mod tests { 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); + let peer_task = spawn_peer(peer_end, |mut peer| async move { + // 1. Drain preamble + send PreambleAck + let mut buf = vec![0u8; 256]; + let _n = peer.read(&mut buf).await.unwrap(); + peer.write_all(&[0x0Bu8]).await.unwrap(); + peer.flush().await.unwrap(); + + // 2. Drain ConnectRequest SizedEnvelope + 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 _connect_request = read_n(&mut peer, len).await; + + // 3. Build a synthetic ConnectResponse: service_public_key + // = matching `bob` so the shared-secret derivation works. + let bob = make_authenticator(); + let svc_pubkey = bob.local_public_key().to_vec(); + let body = synthesise_connect_response_body(svc_pubkey); + let envelope = SoapEnvelope::new(actions::CONNECT).with_body_tokens(body); + let mut response_dict = DynamicDictionary::new(); + let envelope_bytes = encode_envelope(&envelope, &mut response_dict).unwrap(); + + let mut frame = vec![0x06u8]; + mxaccess_asb_nettcp::nmf::encode_multibyte_int31( + &mut frame, + envelope_bytes.len() as i32, + ) + .unwrap(); + frame.extend_from_slice(&envelope_bytes); + peer.write_all(&frame).await.unwrap(); + peer.flush().await.unwrap(); + + // 4. Drain AuthenticateMe one-way SizedEnvelope. + 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 _authenticate_me = read_n(&mut peer, len).await; + + peer + }); + + let mut client = AsbClient::new(client_end, make_authenticator(), "test://h/p"); + client.send_preamble().await.unwrap(); + let response = client.connect().await.unwrap(); + // Smoke-check that the response carries our synthesized public + // key bytes (length matches a real DH key, ~129 bytes). + assert!(!response.service_public_key.is_empty()); + assert!(response.connection_lifetime.is_none()); + + let _ = peer_task.await.unwrap(); + } + + fn synthesise_connect_response_body( + service_public_key: Vec, + ) -> Vec { + use mxaccess_asb_nettcp::nbfx::{NbfxName, NbfxText, NbfxToken}; + const MESSAGES_NS: &str = "http://asb.contracts.messages/20111111"; + vec![ + NbfxToken::Element { + prefix: None, + name: NbfxName::Inline("ConnectResponse".to_string()), + }, + NbfxToken::DefaultNamespace { + value: NbfxText::Chars(MESSAGES_NS.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(service_public_key)), + NbfxToken::EndElement, // + NbfxToken::EndElement, // + NbfxToken::EndElement, // + ] + } + fn synthesise_read_response_body( status_payload: Vec, values_payload: Vec, diff --git a/rust/crates/mxaccess-asb/src/envelope.rs b/rust/crates/mxaccess-asb/src/envelope.rs index 3cb6dc9..24d4f9d 100644 --- a/rust/crates/mxaccess-asb/src/envelope.rs +++ b/rust/crates/mxaccess-asb/src/envelope.rs @@ -466,6 +466,14 @@ fn drain_body(tokens: &[NbfxToken], start: usize) -> (Vec, usize) { (body, idx) } +/// Internal helper exposed for the F25 step-6 ConnectRequest body +/// builder. Re-exports the same byte-order convention as +/// [`format_uuid`]. +#[doc(hidden)] +pub fn format_uuid_for_test(bytes: &[u8; 16]) -> String { + format_uuid(bytes) +} + /// Format a 16-byte GUID as the canonical `XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX` /// hex representation that .NET's `Guid.ToString("D")` emits — same form /// the WCF XML serialiser writes for `` (`AsbContracts.cs:82`). diff --git a/rust/crates/mxaccess-asb/src/lib.rs b/rust/crates/mxaccess-asb/src/lib.rs index ba2c012..8cfaa9e 100644 --- a/rust/crates/mxaccess-asb/src/lib.rs +++ b/rust/crates/mxaccess-asb/src/lib.rs @@ -25,8 +25,9 @@ pub use envelope::{ encode_envelope, }; pub use operations::{ - OperationError, ReadResponse, RegisterItemsResponse, UnregisterItemsResponse, + 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_read_response, - decode_register_items_response, decode_unregister_items_response, + 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 97d87d1..cf44375 100644 --- a/rust/crates/mxaccess-asb/src/operations.rs +++ b/rust/crates/mxaccess-asb/src/operations.rs @@ -90,6 +90,270 @@ pub fn build_read_request_body(items: &[ItemIdentity]) -> Vec { 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 +/// +/// {guid-text} +/// +/// {public-key-bytes} +/// +/// +/// ``` +/// +/// **Wire-byte caveat**: WCF's XML serialiser emits the `` +/// `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 { + 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, // + NbfxToken::Element { + prefix: None, + name: NbfxName::Inline("ConsumerPublicKey".to_string()), + }, + ]; + tokens.extend(public_key_data_field(consumer_public_key)); + 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`: +/// ```xml +/// +/// +/// {encrypted-bytes} +/// {iv-bytes} +/// +/// +/// ``` +pub fn build_authenticate_me_request_body( + consumer_data: &[u8], + initialization_vector: &[u8], +) -> Vec { + 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); // + tokens.push(NbfxToken::EndElement); // + tokens +} + +fn public_key_data_field(data: &[u8]) -> Vec { + 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 { + 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, + /// Service authentication data — encrypted blob + IV. Optional; + /// some service versions omit it. + pub service_authentication_data: Option, + /// Negotiated connection lifetime (xs:duration string like + /// `"PT60M:V2"`). The `:V2` suffix toggles Apollo signing in F23. + pub connection_lifetime: Option, +} + +/// `AuthenticationData` payload (`Data` + `InitializationVector`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticationDataBytes { + pub data: Vec, + pub initialization_vector: Vec, +} + +/// 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 { + 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. `{Bytes}`). +/// Permissive — skips attributes / namespace decls between element opens. +fn find_inline_bytes(tokens: &[NbfxToken], path: &[&str]) -> Option> { + 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 { + // 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 { + 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> = 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 = 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, + // + 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 = 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 = 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();