[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:
+5
-1
@@ -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 `<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:**
|
||||
- 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<ItemStatus>`) + `Values` (`Vec<RuntimeValue>`) from the dual-`<ASBIData>`-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:**
|
||||
|
||||
@@ -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<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
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<ConnectResponse, ClientError> {
|
||||
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<u8>,
|
||||
) -> Vec<mxaccess_asb_nettcp::nbfx::NbfxToken> {
|
||||
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, // </Data>
|
||||
NbfxToken::EndElement, // </ServicePublicKey>
|
||||
NbfxToken::EndElement, // </ConnectResponse>
|
||||
]
|
||||
}
|
||||
|
||||
fn synthesise_read_response_body(
|
||||
status_payload: Vec<u8>,
|
||||
values_payload: Vec<u8>,
|
||||
|
||||
@@ -466,6 +466,14 @@ fn drain_body(tokens: &[NbfxToken], start: usize) -> (Vec<NbfxToken>, 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 `<ConnectionId>` (`AsbContracts.cs:82`).
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user