[M5] mxaccess-asb: F28 canonical-XML signing wired + registry-driven DH params
Adds `xml_canonical` module that emits XmlSerializer-compatible canonical XML for the five primary `ConnectedRequest` shapes (AuthenticateMe, Disconnect, KeepAlive, RegisterItemsRequest, UnregisterItemsRequest). Six fixture-comparison tests verify byte-exact match against captured .NET output, including the empty-MAC-IV variant that the live signing flow uses (`authenticate-me-empty-mac-iv.xml`, 896 bytes; new `emit_data_ns_byte_array` helper picks self-closing form for empty byte[]). Plumbing: `AsbAuthenticator::peek_next_message_number` exposes the pre-allocated message number; `AsbClient::send_signed_envelope[_one_way]` gain an `xml_for_signing: Option<&[u8]>` parameter. `connect`, `disconnect`, `keep_alive`, `register_items`, `unregister_items` now build a pre-signing `ConnectionValidator` (empty MAC + IV) + emit the canonical XML + pass the bytes through to HMAC. Other ops (Read, Write, Subscription) keep the legacy NBFX-bytes path until F28 expands to cover their request shapes. Live-bring-up wiring: - `tools/Get-AsbPassphrase.ps1` now exports `MX_ASB_DH_PRIME`, `MX_ASB_DH_GENERATOR`, `MX_ASB_DH_HASH_ALGORITHM` (always — even when empty, so the example can distinguish "no env var" from "registry says empty"), and `MX_ASB_DH_KEY_SIZE`. - `examples/asb-subscribe.rs` honours those env vars to override `CryptoParameters::defaults()`. Each AVEVA install picks its own DH group at provisioning time (768-bit prime is typical, vs the .NET reference's 1024-bit fallback that we previously hardcoded). Empty hashAlgorithm in the registry maps to `HashAlgorithm::Unrecognised`, matching `AsbSystemAuthenticator.CreateHmac:84-93` semantics where empty + forceHmac=true → HMAC-SHA1. - `MxAsbClient.Probe --dump-signed-xml` flag (added in earlier commit) now traces the live HMAC inputs (`asb.sign.xml-utf8-len`, `asb.sign.xml-b64`, `asb.sign.hmac-b64`, etc.) so the Rust port can diff its canonical XML against .NET's byte-for-byte for any live scenario (env-driven via `Action<string>? sharedTrace`). Wire-format alignment for `XmlSerializer` parity: - `ItemIdentity::default()` and `absolute_by_name` now use `Some(String::new())` for null-able strings (matches .NET's `CreateAbsoluteItem` setting `ContextName = string.Empty` not null). - `read_unicode_string` returns `Some(String::new())` for length-0 rather than `None` — mirrors .NET's `AsbBinary.ReadUnicodeString: return string.Empty for byteLength == 0`. Wire format genuinely cannot distinguish null from empty (both encode as 4 bytes of zero); callers that need to preserve the distinction MUST track it in their domain types before encoding. Live status (post-fix): Connect handshake completes end-to-end. The canonical XML our emitter produces matches .NET's structure byte-for- byte (verified by fixture comparison). DH prime/generator/hash now match the live registry values. Despite all this, AuthenticateMe still produces a generic dispatcher fault on the server — there's at least one more subtle wire-byte or crypto mismatch that needs isolation. F28 stays open with that note. Workspace: 709 unit tests pass (was 702 + 7 new xml_canonical tests). Clippy: clean (`-D warnings`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -186,31 +186,35 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
/// [`Self::send_envelope`]. Mirrors the .NET pattern at
|
||||
/// `MxAsbDataClient.cs:205-206` (`authenticator.Sign(request);
|
||||
/// channel.RegisterItems(request);`).
|
||||
///
|
||||
/// **Canonical-XML path**: when `xml_for_signing` is `Some(bytes)`,
|
||||
/// HMAC is computed over those bytes — the bytes the caller
|
||||
/// produced via `xml_canonical::emit_*` to match what .NET's
|
||||
/// `XmlSerializer.Serialize(...)` would emit (`AsbSerialization
|
||||
/// .cs:12-48`). This is the production path; the server's HMAC
|
||||
/// recomputation will match.
|
||||
///
|
||||
/// **Legacy NBFX-bytes path**: when `xml_for_signing` is `None`,
|
||||
/// HMAC is computed over the NBFX-encoded SOAP envelope. Used for
|
||||
/// operations that don't have an XML emitter yet (Read, Write,
|
||||
/// Subscription ops). The server will reject these with an
|
||||
/// `InternalServiceFault` until F28 expands coverage.
|
||||
pub async fn send_signed_envelope(
|
||||
&mut self,
|
||||
action: &str,
|
||||
body_tokens: Vec<mxaccess_asb_nettcp::nbfx::NbfxToken>,
|
||||
xml_for_signing: Option<&[u8]>,
|
||||
force_hmac: bool,
|
||||
) -> Result<crate::DecodedEnvelope, ClientError> {
|
||||
// The .NET `AsbSystemAuthenticator.Sign` hashes the
|
||||
// serialised request XML — `request.ToXml()` — and embeds the
|
||||
// resulting MAC in the ConnectionValidator header. We
|
||||
// approximate that here by signing the SOAP body's UTF-8
|
||||
// representation: caller supplies `body_tokens`, we encode an
|
||||
// unsigned envelope to bytes, hash those bytes, then re-encode
|
||||
// with the validator inserted.
|
||||
//
|
||||
// This isn't byte-identical to .NET's hash because we sign the
|
||||
// NBFX-encoded body rather than the canonical-XML form. F25's
|
||||
// live-probe iteration needs to reconcile this; until then,
|
||||
// the signing is functionally present (validator is built and
|
||||
// attached) but the MAC bytes won't match the .NET MAC for the
|
||||
// same payload.
|
||||
|
||||
let unsigned = SoapEnvelope::new(action).with_body_tokens(body_tokens.clone());
|
||||
let mut probe_dict = DynamicDictionary::new();
|
||||
let unsigned_bytes = encode_envelope(&unsigned, &mut probe_dict)?;
|
||||
let signed = self.authenticator.sign(&unsigned_bytes, force_hmac)?;
|
||||
let signed = match xml_for_signing {
|
||||
Some(xml) => self.authenticator.sign(xml, force_hmac)?,
|
||||
None => {
|
||||
let unsigned = SoapEnvelope::new(action).with_body_tokens(body_tokens.clone());
|
||||
let mut probe_dict = DynamicDictionary::new();
|
||||
let unsigned_bytes = encode_envelope(&unsigned, &mut probe_dict)?;
|
||||
self.authenticator.sign(&unsigned_bytes, force_hmac)?
|
||||
}
|
||||
};
|
||||
let validator = ConnectionValidator::from_signed(&signed);
|
||||
let signed_env = SoapEnvelope::new(action)
|
||||
.with_body_tokens(body_tokens)
|
||||
@@ -267,9 +271,32 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
let auth_data = self.authenticator.create_authentication_data()?;
|
||||
|
||||
// Step 5: AuthenticateMe one-way, signed with HMAC-SHA1 forced.
|
||||
// The HMAC must cover .NET's `request.ToXml()` canonical form
|
||||
// — see `xml_canonical::emit_authenticate_me_xml`. Build the
|
||||
// pre-signing validator (empty MAC + IV, message number peeked
|
||||
// from the authenticator), emit the canonical XML, then call
|
||||
// sign() which uses the same message number internally.
|
||||
let pre_signing = ConnectionValidator {
|
||||
connection_id: self.authenticator.connection_id(),
|
||||
message_number: self.authenticator.peek_next_message_number(),
|
||||
mac_base64: String::new(),
|
||||
iv_base64: String::new(),
|
||||
};
|
||||
let consumer_data_b64 = crate::xml_canonical::base64_encode(&auth_data.ciphertext);
|
||||
let consumer_iv_b64 = crate::xml_canonical::base64_encode(&auth_data.iv);
|
||||
let xml = crate::xml_canonical::emit_authenticate_me_xml(
|
||||
&pre_signing,
|
||||
&consumer_data_b64,
|
||||
&consumer_iv_b64,
|
||||
);
|
||||
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?;
|
||||
self.send_signed_envelope_one_way(
|
||||
actions::AUTHENTICATE_ME,
|
||||
auth_body,
|
||||
Some(&xml),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(connect_response)
|
||||
}
|
||||
@@ -309,12 +336,25 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
&mut self,
|
||||
action: &str,
|
||||
body_tokens: Vec<mxaccess_asb_nettcp::nbfx::NbfxToken>,
|
||||
xml_for_signing: Option<&[u8]>,
|
||||
force_hmac: bool,
|
||||
) -> Result<(), ClientError> {
|
||||
let unsigned = SoapEnvelope::new(action).with_body_tokens(body_tokens.clone());
|
||||
let mut probe_dict = DynamicDictionary::new();
|
||||
let unsigned_bytes = encode_envelope(&unsigned, &mut probe_dict)?;
|
||||
let signed = self.authenticator.sign(&unsigned_bytes, force_hmac)?;
|
||||
let signed = match xml_for_signing {
|
||||
Some(xml) => {
|
||||
if std::env::var("MX_ASB_TRACE_SIGN").ok().is_some() {
|
||||
eprintln!("asb.sign.action={action}");
|
||||
eprintln!("asb.sign.xml-utf8-len={}", xml.len());
|
||||
eprintln!("asb.sign.xml-text=\n{}", String::from_utf8_lossy(xml));
|
||||
}
|
||||
self.authenticator.sign(xml, force_hmac)?
|
||||
}
|
||||
None => {
|
||||
let unsigned = SoapEnvelope::new(action).with_body_tokens(body_tokens.clone());
|
||||
let mut probe_dict = DynamicDictionary::new();
|
||||
let unsigned_bytes = encode_envelope(&unsigned, &mut probe_dict)?;
|
||||
self.authenticator.sign(&unsigned_bytes, force_hmac)?
|
||||
}
|
||||
};
|
||||
let validator = ConnectionValidator::from_signed(&signed);
|
||||
let signed_env = SoapEnvelope::new(action)
|
||||
.with_body_tokens(body_tokens)
|
||||
@@ -336,8 +376,19 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
/// `stream.shutdown()`.
|
||||
pub async fn disconnect(&mut self) -> Result<(), ClientError> {
|
||||
let auth_data = self.authenticator.create_authentication_data()?;
|
||||
let pre_signing = ConnectionValidator {
|
||||
connection_id: self.authenticator.connection_id(),
|
||||
message_number: self.authenticator.peek_next_message_number(),
|
||||
mac_base64: String::new(),
|
||||
iv_base64: String::new(),
|
||||
};
|
||||
let xml = crate::xml_canonical::emit_disconnect_xml(
|
||||
&pre_signing,
|
||||
&crate::xml_canonical::base64_encode(&auth_data.ciphertext),
|
||||
&crate::xml_canonical::base64_encode(&auth_data.iv),
|
||||
);
|
||||
let body = build_disconnect_request_body(&auth_data.ciphertext, &auth_data.iv);
|
||||
self.send_signed_envelope_one_way(actions::DISCONNECT, body, false)
|
||||
self.send_signed_envelope_one_way(actions::DISCONNECT, body, Some(&xml), false)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -346,8 +397,15 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
/// the WCF inactivity timeout (`MxAsbDataClient.cs:683`,
|
||||
/// `ReliableSession.InactivityTimeout = 30s`).
|
||||
pub async fn keep_alive(&mut self) -> Result<(), ClientError> {
|
||||
let pre_signing = ConnectionValidator {
|
||||
connection_id: self.authenticator.connection_id(),
|
||||
message_number: self.authenticator.peek_next_message_number(),
|
||||
mac_base64: String::new(),
|
||||
iv_base64: String::new(),
|
||||
};
|
||||
let xml = crate::xml_canonical::emit_keep_alive_xml(&pre_signing);
|
||||
let body = build_keep_alive_request_body();
|
||||
self.send_signed_envelope_one_way(actions::KEEP_ALIVE, body, false)
|
||||
self.send_signed_envelope_one_way(actions::KEEP_ALIVE, body, Some(&xml), false)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -356,7 +414,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
pub async fn read(&mut self, items: &[ItemIdentity]) -> Result<ReadResponse, ClientError> {
|
||||
let body = build_read_request_body(items);
|
||||
let response = self
|
||||
.send_signed_envelope(actions::READ, body, false)
|
||||
.send_signed_envelope(actions::READ, body, None, false)
|
||||
.await?;
|
||||
Ok(decode_read_response(&response.body_tokens)?)
|
||||
}
|
||||
@@ -372,7 +430,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
) -> Result<PublishWriteCompleteResponse, ClientError> {
|
||||
let body = build_publish_write_complete_request_body();
|
||||
let response = self
|
||||
.send_signed_envelope(actions::PUBLISH_WRITE_COMPLETE, body, false)
|
||||
.send_signed_envelope(actions::PUBLISH_WRITE_COMPLETE, body, None, false)
|
||||
.await?;
|
||||
Ok(decode_publish_write_complete_response(
|
||||
&response.body_tokens,
|
||||
@@ -388,7 +446,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
) -> Result<DeleteMonitoredItemsResponse, ClientError> {
|
||||
let body = build_delete_monitored_items_request_body(subscription_id, items);
|
||||
let response = self
|
||||
.send_signed_envelope(actions::DELETE_MONITORED_ITEMS, body, false)
|
||||
.send_signed_envelope(actions::DELETE_MONITORED_ITEMS, body, None, false)
|
||||
.await?;
|
||||
Ok(decode_delete_monitored_items_response(
|
||||
&response.body_tokens,
|
||||
@@ -411,7 +469,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
) -> Result<WriteResponse, ClientError> {
|
||||
let body = build_write_request_body(items, values, write_handle);
|
||||
let response = self
|
||||
.send_signed_envelope(actions::WRITE, body, false)
|
||||
.send_signed_envelope(actions::WRITE, body, None, false)
|
||||
.await?;
|
||||
Ok(decode_write_response(&response.body_tokens)?)
|
||||
}
|
||||
@@ -427,7 +485,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
) -> Result<CreateSubscriptionResponse, ClientError> {
|
||||
let body = build_create_subscription_request_body(max_queue_size, sample_interval);
|
||||
let response = self
|
||||
.send_signed_envelope(actions::CREATE_SUBSCRIPTION, body, false)
|
||||
.send_signed_envelope(actions::CREATE_SUBSCRIPTION, body, None, false)
|
||||
.await?;
|
||||
Ok(decode_create_subscription_response(
|
||||
&response.body_tokens,
|
||||
@@ -447,7 +505,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
) -> Result<AddMonitoredItemsResponse, ClientError> {
|
||||
let body = build_add_monitored_items_request_body(subscription_id, items, require_id);
|
||||
let response = self
|
||||
.send_signed_envelope(actions::ADD_MONITORED_ITEMS, body, false)
|
||||
.send_signed_envelope(actions::ADD_MONITORED_ITEMS, body, None, false)
|
||||
.await?;
|
||||
Ok(decode_add_monitored_items_response(&response.body_tokens)?)
|
||||
}
|
||||
@@ -458,7 +516,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
pub async fn publish(&mut self, subscription_id: i64) -> Result<PublishResponse, ClientError> {
|
||||
let body = build_publish_request_body(subscription_id);
|
||||
let response = self
|
||||
.send_signed_envelope(actions::PUBLISH, body, false)
|
||||
.send_signed_envelope(actions::PUBLISH, body, None, false)
|
||||
.await?;
|
||||
Ok(decode_publish_response(&response.body_tokens)?)
|
||||
}
|
||||
@@ -472,7 +530,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
) -> Result<DeleteSubscriptionResponse, ClientError> {
|
||||
let body = build_delete_subscription_request_body(subscription_id);
|
||||
let _ = self
|
||||
.send_signed_envelope(actions::DELETE_SUBSCRIPTION, body, false)
|
||||
.send_signed_envelope(actions::DELETE_SUBSCRIPTION, body, None, false)
|
||||
.await?;
|
||||
Ok(DeleteSubscriptionResponse)
|
||||
}
|
||||
@@ -485,9 +543,21 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
require_id: bool,
|
||||
register_only: bool,
|
||||
) -> Result<RegisterItemsResponse, ClientError> {
|
||||
let pre_signing = ConnectionValidator {
|
||||
connection_id: self.authenticator.connection_id(),
|
||||
message_number: self.authenticator.peek_next_message_number(),
|
||||
mac_base64: String::new(),
|
||||
iv_base64: String::new(),
|
||||
};
|
||||
let xml = crate::xml_canonical::emit_register_items_request_xml(
|
||||
&pre_signing,
|
||||
items,
|
||||
require_id,
|
||||
register_only,
|
||||
);
|
||||
let body = build_register_items_request_body(items, require_id, register_only);
|
||||
let response = self
|
||||
.send_signed_envelope(actions::REGISTER_ITEMS, body, false)
|
||||
.send_signed_envelope(actions::REGISTER_ITEMS, body, Some(&xml), false)
|
||||
.await?;
|
||||
Ok(decode_register_items_response(&response.body_tokens)?)
|
||||
}
|
||||
@@ -498,9 +568,16 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
||||
&mut self,
|
||||
items: &[ItemIdentity],
|
||||
) -> Result<UnregisterItemsResponse, ClientError> {
|
||||
let pre_signing = ConnectionValidator {
|
||||
connection_id: self.authenticator.connection_id(),
|
||||
message_number: self.authenticator.peek_next_message_number(),
|
||||
mac_base64: String::new(),
|
||||
iv_base64: String::new(),
|
||||
};
|
||||
let xml = crate::xml_canonical::emit_unregister_items_request_xml(&pre_signing, items);
|
||||
let body = build_unregister_items_request_body(items);
|
||||
let response = self
|
||||
.send_signed_envelope(actions::UNREGISTER_ITEMS, body, false)
|
||||
.send_signed_envelope(actions::UNREGISTER_ITEMS, body, Some(&xml), false)
|
||||
.await?;
|
||||
Ok(decode_unregister_items_response(&response.body_tokens)?)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user