[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:
@@ -192,6 +192,16 @@ impl AsbAuthenticator {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Peek the message number that the next [`Self::sign`] call will
|
||||||
|
/// assign to the validator. Useful for the canonical-XML signing
|
||||||
|
/// flow: the caller needs the message number to build the XML
|
||||||
|
/// being HMAC'd, since the validator-during-signing carries it
|
||||||
|
/// (with empty MAC + IV) and the same value must end up on the
|
||||||
|
/// wire after sign() fills MAC + IV.
|
||||||
|
pub fn peek_next_message_number(&self) -> u64 {
|
||||||
|
self.next_message_number
|
||||||
|
}
|
||||||
|
|
||||||
pub fn connection_id(&self) -> [u8; 16] {
|
pub fn connection_id(&self) -> [u8; 16] {
|
||||||
self.connection_id
|
self.connection_id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,31 +186,35 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
/// [`Self::send_envelope`]. Mirrors the .NET pattern at
|
/// [`Self::send_envelope`]. Mirrors the .NET pattern at
|
||||||
/// `MxAsbDataClient.cs:205-206` (`authenticator.Sign(request);
|
/// `MxAsbDataClient.cs:205-206` (`authenticator.Sign(request);
|
||||||
/// channel.RegisterItems(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(
|
pub async fn send_signed_envelope(
|
||||||
&mut self,
|
&mut self,
|
||||||
action: &str,
|
action: &str,
|
||||||
body_tokens: Vec<mxaccess_asb_nettcp::nbfx::NbfxToken>,
|
body_tokens: Vec<mxaccess_asb_nettcp::nbfx::NbfxToken>,
|
||||||
|
xml_for_signing: Option<&[u8]>,
|
||||||
force_hmac: bool,
|
force_hmac: bool,
|
||||||
) -> Result<crate::DecodedEnvelope, ClientError> {
|
) -> Result<crate::DecodedEnvelope, ClientError> {
|
||||||
// The .NET `AsbSystemAuthenticator.Sign` hashes the
|
let signed = match xml_for_signing {
|
||||||
// serialised request XML — `request.ToXml()` — and embeds the
|
Some(xml) => self.authenticator.sign(xml, force_hmac)?,
|
||||||
// resulting MAC in the ConnectionValidator header. We
|
None => {
|
||||||
// approximate that here by signing the SOAP body's UTF-8
|
let unsigned = SoapEnvelope::new(action).with_body_tokens(body_tokens.clone());
|
||||||
// representation: caller supplies `body_tokens`, we encode an
|
let mut probe_dict = DynamicDictionary::new();
|
||||||
// unsigned envelope to bytes, hash those bytes, then re-encode
|
let unsigned_bytes = encode_envelope(&unsigned, &mut probe_dict)?;
|
||||||
// with the validator inserted.
|
self.authenticator.sign(&unsigned_bytes, force_hmac)?
|
||||||
//
|
}
|
||||||
// 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 validator = ConnectionValidator::from_signed(&signed);
|
let validator = ConnectionValidator::from_signed(&signed);
|
||||||
let signed_env = SoapEnvelope::new(action)
|
let signed_env = SoapEnvelope::new(action)
|
||||||
.with_body_tokens(body_tokens)
|
.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()?;
|
let auth_data = self.authenticator.create_authentication_data()?;
|
||||||
|
|
||||||
// Step 5: AuthenticateMe one-way, signed with HMAC-SHA1 forced.
|
// 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);
|
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)
|
self.send_signed_envelope_one_way(
|
||||||
.await?;
|
actions::AUTHENTICATE_ME,
|
||||||
|
auth_body,
|
||||||
|
Some(&xml),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(connect_response)
|
Ok(connect_response)
|
||||||
}
|
}
|
||||||
@@ -309,12 +336,25 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
&mut self,
|
&mut self,
|
||||||
action: &str,
|
action: &str,
|
||||||
body_tokens: Vec<mxaccess_asb_nettcp::nbfx::NbfxToken>,
|
body_tokens: Vec<mxaccess_asb_nettcp::nbfx::NbfxToken>,
|
||||||
|
xml_for_signing: Option<&[u8]>,
|
||||||
force_hmac: bool,
|
force_hmac: bool,
|
||||||
) -> Result<(), ClientError> {
|
) -> Result<(), ClientError> {
|
||||||
let unsigned = SoapEnvelope::new(action).with_body_tokens(body_tokens.clone());
|
let signed = match xml_for_signing {
|
||||||
let mut probe_dict = DynamicDictionary::new();
|
Some(xml) => {
|
||||||
let unsigned_bytes = encode_envelope(&unsigned, &mut probe_dict)?;
|
if std::env::var("MX_ASB_TRACE_SIGN").ok().is_some() {
|
||||||
let signed = self.authenticator.sign(&unsigned_bytes, force_hmac)?;
|
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 validator = ConnectionValidator::from_signed(&signed);
|
||||||
let signed_env = SoapEnvelope::new(action)
|
let signed_env = SoapEnvelope::new(action)
|
||||||
.with_body_tokens(body_tokens)
|
.with_body_tokens(body_tokens)
|
||||||
@@ -336,8 +376,19 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
/// `stream.shutdown()`.
|
/// `stream.shutdown()`.
|
||||||
pub async fn disconnect(&mut self) -> Result<(), ClientError> {
|
pub async fn disconnect(&mut self) -> Result<(), ClientError> {
|
||||||
let auth_data = self.authenticator.create_authentication_data()?;
|
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);
|
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
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,8 +397,15 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
/// the WCF inactivity timeout (`MxAsbDataClient.cs:683`,
|
/// the WCF inactivity timeout (`MxAsbDataClient.cs:683`,
|
||||||
/// `ReliableSession.InactivityTimeout = 30s`).
|
/// `ReliableSession.InactivityTimeout = 30s`).
|
||||||
pub async fn keep_alive(&mut self) -> Result<(), ClientError> {
|
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();
|
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
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +414,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
pub async fn read(&mut self, items: &[ItemIdentity]) -> Result<ReadResponse, ClientError> {
|
pub async fn read(&mut self, items: &[ItemIdentity]) -> Result<ReadResponse, ClientError> {
|
||||||
let body = build_read_request_body(items);
|
let body = build_read_request_body(items);
|
||||||
let response = self
|
let response = self
|
||||||
.send_signed_envelope(actions::READ, body, false)
|
.send_signed_envelope(actions::READ, body, None, false)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(decode_read_response(&response.body_tokens)?)
|
Ok(decode_read_response(&response.body_tokens)?)
|
||||||
}
|
}
|
||||||
@@ -372,7 +430,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
) -> Result<PublishWriteCompleteResponse, ClientError> {
|
) -> Result<PublishWriteCompleteResponse, ClientError> {
|
||||||
let body = build_publish_write_complete_request_body();
|
let body = build_publish_write_complete_request_body();
|
||||||
let response = self
|
let response = self
|
||||||
.send_signed_envelope(actions::PUBLISH_WRITE_COMPLETE, body, false)
|
.send_signed_envelope(actions::PUBLISH_WRITE_COMPLETE, body, None, false)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(decode_publish_write_complete_response(
|
Ok(decode_publish_write_complete_response(
|
||||||
&response.body_tokens,
|
&response.body_tokens,
|
||||||
@@ -388,7 +446,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
) -> Result<DeleteMonitoredItemsResponse, ClientError> {
|
) -> Result<DeleteMonitoredItemsResponse, ClientError> {
|
||||||
let body = build_delete_monitored_items_request_body(subscription_id, items);
|
let body = build_delete_monitored_items_request_body(subscription_id, items);
|
||||||
let response = self
|
let response = self
|
||||||
.send_signed_envelope(actions::DELETE_MONITORED_ITEMS, body, false)
|
.send_signed_envelope(actions::DELETE_MONITORED_ITEMS, body, None, false)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(decode_delete_monitored_items_response(
|
Ok(decode_delete_monitored_items_response(
|
||||||
&response.body_tokens,
|
&response.body_tokens,
|
||||||
@@ -411,7 +469,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
) -> Result<WriteResponse, ClientError> {
|
) -> Result<WriteResponse, ClientError> {
|
||||||
let body = build_write_request_body(items, values, write_handle);
|
let body = build_write_request_body(items, values, write_handle);
|
||||||
let response = self
|
let response = self
|
||||||
.send_signed_envelope(actions::WRITE, body, false)
|
.send_signed_envelope(actions::WRITE, body, None, false)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(decode_write_response(&response.body_tokens)?)
|
Ok(decode_write_response(&response.body_tokens)?)
|
||||||
}
|
}
|
||||||
@@ -427,7 +485,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
) -> Result<CreateSubscriptionResponse, ClientError> {
|
) -> Result<CreateSubscriptionResponse, ClientError> {
|
||||||
let body = build_create_subscription_request_body(max_queue_size, sample_interval);
|
let body = build_create_subscription_request_body(max_queue_size, sample_interval);
|
||||||
let response = self
|
let response = self
|
||||||
.send_signed_envelope(actions::CREATE_SUBSCRIPTION, body, false)
|
.send_signed_envelope(actions::CREATE_SUBSCRIPTION, body, None, false)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(decode_create_subscription_response(
|
Ok(decode_create_subscription_response(
|
||||||
&response.body_tokens,
|
&response.body_tokens,
|
||||||
@@ -447,7 +505,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
) -> Result<AddMonitoredItemsResponse, ClientError> {
|
) -> Result<AddMonitoredItemsResponse, ClientError> {
|
||||||
let body = build_add_monitored_items_request_body(subscription_id, items, require_id);
|
let body = build_add_monitored_items_request_body(subscription_id, items, require_id);
|
||||||
let response = self
|
let response = self
|
||||||
.send_signed_envelope(actions::ADD_MONITORED_ITEMS, body, false)
|
.send_signed_envelope(actions::ADD_MONITORED_ITEMS, body, None, false)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(decode_add_monitored_items_response(&response.body_tokens)?)
|
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> {
|
pub async fn publish(&mut self, subscription_id: i64) -> Result<PublishResponse, ClientError> {
|
||||||
let body = build_publish_request_body(subscription_id);
|
let body = build_publish_request_body(subscription_id);
|
||||||
let response = self
|
let response = self
|
||||||
.send_signed_envelope(actions::PUBLISH, body, false)
|
.send_signed_envelope(actions::PUBLISH, body, None, false)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(decode_publish_response(&response.body_tokens)?)
|
Ok(decode_publish_response(&response.body_tokens)?)
|
||||||
}
|
}
|
||||||
@@ -472,7 +530,7 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
) -> Result<DeleteSubscriptionResponse, ClientError> {
|
) -> Result<DeleteSubscriptionResponse, ClientError> {
|
||||||
let body = build_delete_subscription_request_body(subscription_id);
|
let body = build_delete_subscription_request_body(subscription_id);
|
||||||
let _ = self
|
let _ = self
|
||||||
.send_signed_envelope(actions::DELETE_SUBSCRIPTION, body, false)
|
.send_signed_envelope(actions::DELETE_SUBSCRIPTION, body, None, false)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(DeleteSubscriptionResponse)
|
Ok(DeleteSubscriptionResponse)
|
||||||
}
|
}
|
||||||
@@ -485,9 +543,21 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
require_id: bool,
|
require_id: bool,
|
||||||
register_only: bool,
|
register_only: bool,
|
||||||
) -> Result<RegisterItemsResponse, ClientError> {
|
) -> 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 body = build_register_items_request_body(items, require_id, register_only);
|
||||||
let response = self
|
let response = self
|
||||||
.send_signed_envelope(actions::REGISTER_ITEMS, body, false)
|
.send_signed_envelope(actions::REGISTER_ITEMS, body, Some(&xml), false)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(decode_register_items_response(&response.body_tokens)?)
|
Ok(decode_register_items_response(&response.body_tokens)?)
|
||||||
}
|
}
|
||||||
@@ -498,9 +568,16 @@ impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsbClient<T> {
|
|||||||
&mut self,
|
&mut self,
|
||||||
items: &[ItemIdentity],
|
items: &[ItemIdentity],
|
||||||
) -> Result<UnregisterItemsResponse, ClientError> {
|
) -> 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 body = build_unregister_items_request_body(items);
|
||||||
let response = self
|
let response = self
|
||||||
.send_signed_envelope(actions::UNREGISTER_ITEMS, body, false)
|
.send_signed_envelope(actions::UNREGISTER_ITEMS, body, Some(&xml), false)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(decode_unregister_items_response(&response.body_tokens)?)
|
Ok(decode_unregister_items_response(&response.body_tokens)?)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use mxaccess_codec::{AsbStatus, AsbVariant, CodecError, RuntimeValue};
|
|||||||
/// `AsbBinary.WriteUnicodeString` per `cs:1622-1633`:
|
/// `AsbBinary.WriteUnicodeString` per `cs:1622-1633`:
|
||||||
/// * Null/empty → 4-byte `0u32` length, no payload
|
/// * Null/empty → 4-byte `0u32` length, no payload
|
||||||
/// * Non-empty → 4-byte byte-length + UTF-16LE bytes
|
/// * Non-empty → 4-byte byte-length + UTF-16LE bytes
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ItemIdentity {
|
pub struct ItemIdentity {
|
||||||
pub kind: u16,
|
pub kind: u16,
|
||||||
pub reference_type: u16,
|
pub reference_type: u16,
|
||||||
@@ -47,6 +47,24 @@ pub struct ItemIdentity {
|
|||||||
pub id_specified: bool,
|
pub id_specified: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default `ItemIdentity` matches the wire-equivalent .NET default:
|
||||||
|
/// `Name = string.Empty`, `ContextName = string.Empty`. Both fields
|
||||||
|
/// must be `Some(String::new())` so the wire round-trip is stable
|
||||||
|
/// (the binary codec collapses `None` → length-0 → `Some("")` per
|
||||||
|
/// `read_unicode_string`'s .NET-mirroring behaviour).
|
||||||
|
impl Default for ItemIdentity {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: 0,
|
||||||
|
reference_type: 0,
|
||||||
|
name: Some(String::new()),
|
||||||
|
context_name: Some(String::new()),
|
||||||
|
id: 0,
|
||||||
|
id_specified: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `ItemIdentityType` enum (`AsbContracts.cs:1295-1300`).
|
/// `ItemIdentityType` enum (`AsbContracts.cs:1295-1300`).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
#[repr(u16)]
|
#[repr(u16)]
|
||||||
@@ -78,7 +96,14 @@ impl ItemIdentity {
|
|||||||
kind: ItemIdentityType::Name as u16,
|
kind: ItemIdentityType::Name as u16,
|
||||||
reference_type: ItemReferenceType::Absolute as u16,
|
reference_type: ItemReferenceType::Absolute as u16,
|
||||||
name: Some(name.into()),
|
name: Some(name.into()),
|
||||||
context_name: None,
|
// .NET's `CreateAbsoluteItem` (`MxAsbDataClient.cs:604-613`)
|
||||||
|
// sets `ContextName = string.Empty` (NOT null). XmlSerializer
|
||||||
|
// treats empty-string and null differently — empty produces
|
||||||
|
// `<ContextName xmlns="..." />` (self-closing) while null
|
||||||
|
// produces `<ContextName xsi:nil="true" xmlns="..." />`. The
|
||||||
|
// canonical-XML signing path (F28) compares against .NET's
|
||||||
|
// form, so we must default to `Some(String::new())`.
|
||||||
|
context_name: Some(String::new()),
|
||||||
id: 0,
|
id: 0,
|
||||||
id_specified: false,
|
id_specified: false,
|
||||||
}
|
}
|
||||||
@@ -374,13 +399,19 @@ fn write_unicode_string(out: &mut Vec<u8>, value: Option<&str>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mirror `AsbBinary.ReadUnicodeString` at `cs:1616-1620`. Length 0
|
/// Mirror `AsbBinary.ReadUnicodeString` at `cs:1616-1620`. Length 0
|
||||||
/// returns `None` (matches `string.Empty` in .NET — both forms collapse
|
/// → `Some(String::new())` to match .NET's behaviour (the C# code
|
||||||
/// to a Rust `None` here so callers can distinguish unset from empty by
|
/// returns `string.Empty` for length 0, NOT `null`). The wire format
|
||||||
/// asserting on the original string).
|
/// genuinely cannot distinguish `null` from empty — both are encoded
|
||||||
|
/// as 4 bytes of zero — so we pick the same lossy collapse the
|
||||||
|
/// reference does. This matters for the canonical-XML signing path:
|
||||||
|
/// .NET's `XmlSerializer` treats `null` and `string.Empty` differently
|
||||||
|
/// (`xsi:nil` vs self-closing element), so callers that need to
|
||||||
|
/// preserve the distinction MUST track it in their domain types
|
||||||
|
/// before encoding (we cannot recover it from wire bytes).
|
||||||
fn read_unicode_string(input: &[u8], cursor: &mut usize) -> Result<Option<String>, CodecError> {
|
fn read_unicode_string(input: &[u8], cursor: &mut usize) -> Result<Option<String>, CodecError> {
|
||||||
let len = read_u32_le(input, cursor)? as usize;
|
let len = read_u32_le(input, cursor)? as usize;
|
||||||
if len == 0 {
|
if len == 0 {
|
||||||
return Ok(None);
|
return Ok(Some(String::new()));
|
||||||
}
|
}
|
||||||
if len % 2 != 0 {
|
if len % 2 != 0 {
|
||||||
return Err(CodecError::Decode {
|
return Err(CodecError::Decode {
|
||||||
@@ -508,17 +539,24 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unicode_string_round_trip_handles_null_empty_and_value() {
|
fn unicode_string_round_trip_handles_null_empty_and_value() {
|
||||||
// Null
|
// Null and empty are wire-identical (both encode as len=0 +
|
||||||
|
// zero bytes). The decoder collapses both to `Some(String::
|
||||||
|
// new())` to match .NET's `string.Empty` return.
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
write_unicode_string(&mut buf, None);
|
write_unicode_string(&mut buf, None);
|
||||||
let mut c = 0;
|
let mut c = 0;
|
||||||
assert_eq!(read_unicode_string(&buf, &mut c).unwrap(), None);
|
assert_eq!(
|
||||||
|
read_unicode_string(&buf, &mut c).unwrap(),
|
||||||
|
Some(String::new())
|
||||||
|
);
|
||||||
|
|
||||||
// Empty
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
write_unicode_string(&mut buf, Some(""));
|
write_unicode_string(&mut buf, Some(""));
|
||||||
let mut c = 0;
|
let mut c = 0;
|
||||||
assert_eq!(read_unicode_string(&buf, &mut c).unwrap(), None);
|
assert_eq!(
|
||||||
|
read_unicode_string(&buf, &mut c).unwrap(),
|
||||||
|
Some(String::new())
|
||||||
|
);
|
||||||
|
|
||||||
// ASCII
|
// ASCII
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
|
|||||||
@@ -779,7 +779,7 @@ pub fn format_uuid_for_test(bytes: &[u8; 16]) -> String {
|
|||||||
/// .NET stores GUID bytes in mixed-endian: the first 4 bytes are
|
/// .NET stores GUID bytes in mixed-endian: the first 4 bytes are
|
||||||
/// little-endian, the next 2x2 are little-endian, the last 2+6 are
|
/// little-endian, the next 2x2 are little-endian, the last 2+6 are
|
||||||
/// big-endian. We match that.
|
/// big-endian. We match that.
|
||||||
fn format_uuid(bytes: &[u8; 16]) -> String {
|
pub fn format_uuid(bytes: &[u8; 16]) -> String {
|
||||||
let d1 = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
let d1 = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||||
let d2 = u16::from_le_bytes([bytes[4], bytes[5]]);
|
let d2 = u16::from_le_bytes([bytes[4], bytes[5]]);
|
||||||
let d3 = u16::from_le_bytes([bytes[6], bytes[7]]);
|
let d3 = u16::from_le_bytes([bytes[6], bytes[7]]);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod client;
|
|||||||
pub mod contracts;
|
pub mod contracts;
|
||||||
pub mod envelope;
|
pub mod envelope;
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
|
pub mod xml_canonical;
|
||||||
|
|
||||||
pub use client::{AsbClient, ClientError, PreambleMode};
|
pub use client::{AsbClient, ClientError, PreambleMode};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
//! Canonical XML emitter for `ConnectedRequest` HMAC signing.
|
||||||
|
//!
|
||||||
|
//! .NET's `AsbSystemAuthenticator.Sign` (`AsbSystemAuthenticator.cs:79`)
|
||||||
|
//! HMACs `Encoding.UTF8.GetBytes(request.ToXml())` — the textual XML
|
||||||
|
//! produced by `XmlSerializer.Serialize(...)` with default namespace
|
||||||
|
//! `"urn:invensys.schemas"` (`AsbSerialization.cs:12-48`). For the
|
||||||
|
//! server's recomputation of the MAC to match ours, this module must
|
||||||
|
//! emit byte-identical UTF-8 bytes.
|
||||||
|
//!
|
||||||
|
//! ## Inferred XmlSerializer rules
|
||||||
|
//!
|
||||||
|
//! Captured from `MxAsbClient.Probe --dump-signed-xml` against
|
||||||
|
//! deterministic field values; fixtures saved at
|
||||||
|
//! `crates/mxaccess-asb/tests/fixtures/signed-xml/*.xml` (also see
|
||||||
|
//! `tests/fixtures/signed-xml/README.md`):
|
||||||
|
//!
|
||||||
|
//! 1. Element name = class name (NOT `[MessageContract.WrapperName]`).
|
||||||
|
//! 2. Field order = C# declaration order (inherited fields first; NOT
|
||||||
|
//! `[MessageBodyMember.Order]`).
|
||||||
|
//! 3. `[XmlType(Namespace = ...)]` on a field's TYPE causes per-child
|
||||||
|
//! `xmlns="..."` redeclaration on the children, NOT on the wrapper.
|
||||||
|
//! 4. `byte[]` → base64 text content. `Guid` → lowercase D-format.
|
||||||
|
//! `ulong` → decimal. `bool` → `"true"`/`"false"`.
|
||||||
|
//! 5. Null reference field with `[XmlElement(IsNullable = true)]` →
|
||||||
|
//! `<Name xsi:nil="true" xmlns="..." />`. Empty string → self-closing
|
||||||
|
//! `<Name xmlns="..." />`.
|
||||||
|
//! 6. `*Specified` pattern: `XxxSpecified = true` triggers `<Xxx>` to be
|
||||||
|
//! emitted with the int value; the `*Specified` field itself is
|
||||||
|
//! `[XmlIgnore]`.
|
||||||
|
//! 7. Self-closing elements use ` />` (space before `/>`).
|
||||||
|
//! 8. CRLF line endings, 2-space indent, no trailing newline.
|
||||||
|
//! 9. XML declaration: `<?xml version="1.0" encoding="utf-16"?>` (the
|
||||||
|
//! `utf-16` literal is a .NET StringWriter default — actual byte
|
||||||
|
//! encoding fed to HMAC is UTF-8).
|
||||||
|
|
||||||
|
use crate::ConnectionValidator;
|
||||||
|
use crate::contracts::ItemIdentity;
|
||||||
|
use crate::envelope::format_uuid;
|
||||||
|
|
||||||
|
const INVENSYS_NS: &str = "urn:invensys.schemas";
|
||||||
|
const DATA_NS: &str = "http://asb.contracts.data/20111111";
|
||||||
|
const IOM_DATA_NS: &str = "urn:data.data.asb.iom:2";
|
||||||
|
const XSI_NS: &str = "http://www.w3.org/2001/XMLSchema-instance";
|
||||||
|
const XSD_NS: &str = "http://www.w3.org/2001/XMLSchema";
|
||||||
|
|
||||||
|
const HEADER: &str = "<?xml version=\"1.0\" encoding=\"utf-16\"?>\r\n";
|
||||||
|
|
||||||
|
// ---- public emitters -----------------------------------------------------
|
||||||
|
|
||||||
|
/// `<AuthenticateMe>` per `AsbContracts.cs:102-107`.
|
||||||
|
pub fn emit_authenticate_me_xml(
|
||||||
|
validator: &ConnectionValidator,
|
||||||
|
consumer_data_b64: &str,
|
||||||
|
consumer_iv_b64: &str,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
emit_top("AuthenticateMe", |s| {
|
||||||
|
emit_validator(s, validator);
|
||||||
|
emit_authentication_data_field(s, "ConsumerAuthenticationData", consumer_data_b64, consumer_iv_b64);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<Disconnect>` per `AsbContracts.cs:109-114`. Same shape as
|
||||||
|
/// AuthenticateMe — both have a single `ConsumerAuthenticationData`
|
||||||
|
/// body field plus the inherited `ConnectionValidator` header.
|
||||||
|
pub fn emit_disconnect_xml(
|
||||||
|
validator: &ConnectionValidator,
|
||||||
|
consumer_data_b64: &str,
|
||||||
|
consumer_iv_b64: &str,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
emit_top("Disconnect", |s| {
|
||||||
|
emit_validator(s, validator);
|
||||||
|
emit_authentication_data_field(s, "ConsumerAuthenticationData", consumer_data_b64, consumer_iv_b64);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<KeepAlive>` per `AsbContracts.cs:116-117`. Empty body — only the
|
||||||
|
/// inherited `ConnectionValidator` header.
|
||||||
|
pub fn emit_keep_alive_xml(validator: &ConnectionValidator) -> Vec<u8> {
|
||||||
|
emit_top("KeepAlive", |s| {
|
||||||
|
emit_validator(s, validator);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<RegisterItemsRequest>` per `AsbContracts.cs:119-131`. Body
|
||||||
|
/// fields in declaration order: `Items`, `RequireId`, `RegisterOnly`.
|
||||||
|
/// Each `Items` entry is a single `ItemIdentity` (XmlElement attribute
|
||||||
|
/// renames the field to "Items").
|
||||||
|
pub fn emit_register_items_request_xml(
|
||||||
|
validator: &ConnectionValidator,
|
||||||
|
items: &[ItemIdentity],
|
||||||
|
require_id: bool,
|
||||||
|
register_only: bool,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
emit_top("RegisterItemsRequest", |s| {
|
||||||
|
emit_validator(s, validator);
|
||||||
|
for item in items {
|
||||||
|
emit_item_identity(s, item);
|
||||||
|
}
|
||||||
|
emit_invensys_bool(s, " ", "RequireId", require_id);
|
||||||
|
emit_invensys_bool(s, " ", "RegisterOnly", register_only);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<UnregisterItemsRequest>` per `AsbContracts.cs:145-150`. Body
|
||||||
|
/// has just the `Items` array (no `RequireId`/`RegisterOnly`).
|
||||||
|
pub fn emit_unregister_items_request_xml(
|
||||||
|
validator: &ConnectionValidator,
|
||||||
|
items: &[ItemIdentity],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
emit_top("UnregisterItemsRequest", |s| {
|
||||||
|
emit_validator(s, validator);
|
||||||
|
for item in items {
|
||||||
|
emit_item_identity(s, item);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internal helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
fn emit_top<F: FnOnce(&mut String)>(class_name: &str, body: F) -> Vec<u8> {
|
||||||
|
let mut s = String::with_capacity(1024);
|
||||||
|
s.push_str(HEADER);
|
||||||
|
s.push('<');
|
||||||
|
s.push_str(class_name);
|
||||||
|
s.push_str(" xmlns:xsi=\"");
|
||||||
|
s.push_str(XSI_NS);
|
||||||
|
s.push_str("\" xmlns:xsd=\"");
|
||||||
|
s.push_str(XSD_NS);
|
||||||
|
s.push_str("\" xmlns=\"");
|
||||||
|
s.push_str(INVENSYS_NS);
|
||||||
|
s.push_str("\">\r\n");
|
||||||
|
body(&mut s);
|
||||||
|
s.push_str("</");
|
||||||
|
s.push_str(class_name);
|
||||||
|
s.push('>');
|
||||||
|
s.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ConnectionValidator` element. The wrapper element itself stays in
|
||||||
|
/// the parent (urn:invensys.schemas) namespace because XmlSerializer
|
||||||
|
/// only redeclares xmlns when it changes; the inherited
|
||||||
|
/// `[XmlType(Namespace = "http://asb.contracts.data/20111111")]` (or
|
||||||
|
/// equivalent inferred default) on the inner type causes EACH direct
|
||||||
|
/// child to carry the data-ns redeclaration.
|
||||||
|
///
|
||||||
|
/// `MessageAuthenticationCode` and `SignatureInitializationVector` are
|
||||||
|
/// `byte[]` fields. When the validator is being signed (NOT yet on the
|
||||||
|
/// wire), they're empty `byte[]` and XmlSerializer emits self-closing
|
||||||
|
/// `<MessageAuthenticationCode xmlns="..." />`. After signing they
|
||||||
|
/// carry base64 content. Both forms must round-trip.
|
||||||
|
fn emit_validator(s: &mut String, v: &ConnectionValidator) {
|
||||||
|
s.push_str(" <ConnectionValidator>\r\n");
|
||||||
|
emit_data_ns_text(s, " ", "ConnectionId", &format_uuid(&v.connection_id));
|
||||||
|
emit_data_ns_text(s, " ", "MessageNumber", &v.message_number.to_string());
|
||||||
|
emit_data_ns_byte_array(s, " ", "MessageAuthenticationCode", &v.mac_base64);
|
||||||
|
emit_data_ns_byte_array(s, " ", "SignatureInitializationVector", &v.iv_base64);
|
||||||
|
s.push_str(" </ConnectionValidator>\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `AuthenticationData`-typed field (e.g. `ConsumerAuthenticationData`).
|
||||||
|
/// The wrapper stays in `urn:invensys.schemas`; children Data + IV are
|
||||||
|
/// in the data namespace per `[XmlType]` on `AuthenticationData`.
|
||||||
|
fn emit_authentication_data_field(
|
||||||
|
s: &mut String,
|
||||||
|
field_name: &str,
|
||||||
|
data_b64: &str,
|
||||||
|
iv_b64: &str,
|
||||||
|
) {
|
||||||
|
s.push_str(" <");
|
||||||
|
s.push_str(field_name);
|
||||||
|
s.push_str(">\r\n");
|
||||||
|
emit_data_ns_text(s, " ", "Data", data_b64);
|
||||||
|
emit_data_ns_text(s, " ", "InitializationVector", iv_b64);
|
||||||
|
s.push_str(" </");
|
||||||
|
s.push_str(field_name);
|
||||||
|
s.push_str(">\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<Items>` element holding one ItemIdentity. The wrapper is in
|
||||||
|
/// urn:invensys.schemas; children get `xmlns="urn:data.data.asb.iom:2"`
|
||||||
|
/// per `[XmlType(Namespace = "urn:data.data.asb.iom:2")]` on
|
||||||
|
/// `ItemIdentity` (`AsbContracts.cs:534`).
|
||||||
|
///
|
||||||
|
/// Field order matches C# declaration: contextNameField, idField,
|
||||||
|
/// idFieldSpecified, nameField, referenceTypeField, typeField — but
|
||||||
|
/// XmlSerializer uses the public *property* declaration order which
|
||||||
|
/// yields Type → ReferenceType → Name → ContextName → (Id) per the
|
||||||
|
/// captured fixtures. `IdSpecified` is `[XmlIgnore]` so it never
|
||||||
|
/// appears; when `IdSpecified == true` the `<Id>` element is emitted.
|
||||||
|
///
|
||||||
|
/// Null Name/ContextName → `<Name xsi:nil="true" xmlns="..." />`;
|
||||||
|
/// empty-string ContextName → self-closing `<ContextName xmlns="..." />`.
|
||||||
|
fn emit_item_identity(s: &mut String, item: &ItemIdentity) {
|
||||||
|
s.push_str(" <Items>\r\n");
|
||||||
|
emit_iom_text(s, " ", "Type", &item.kind.to_string());
|
||||||
|
emit_iom_text(s, " ", "ReferenceType", &item.reference_type.to_string());
|
||||||
|
emit_iom_optional_string(s, " ", "Name", item.name.as_deref());
|
||||||
|
emit_iom_optional_string(s, " ", "ContextName", item.context_name.as_deref());
|
||||||
|
if item.id_specified {
|
||||||
|
emit_iom_text(s, " ", "Id", &item.id.to_string());
|
||||||
|
}
|
||||||
|
s.push_str(" </Items>\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a `byte[]` field in the data namespace. Empty bytes (empty
|
||||||
|
/// base64 string) → self-closing `<Tag xmlns="..." />`; non-empty →
|
||||||
|
/// `<Tag xmlns="...">b64</Tag>`. Mirrors XmlSerializer's behaviour
|
||||||
|
/// for empty `byte[]` (verified via `--dump-signed-xml` with empty
|
||||||
|
/// MAC/IV).
|
||||||
|
fn emit_data_ns_byte_array(s: &mut String, indent: &str, tag: &str, value: &str) {
|
||||||
|
if value.is_empty() {
|
||||||
|
s.push_str(indent);
|
||||||
|
s.push('<');
|
||||||
|
s.push_str(tag);
|
||||||
|
s.push_str(" xmlns=\"");
|
||||||
|
s.push_str(DATA_NS);
|
||||||
|
s.push_str("\" />\r\n");
|
||||||
|
} else {
|
||||||
|
emit_data_ns_text(s, indent, tag, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit `<Tag xmlns="DATA_NS">value</Tag>\r\n` with the given indent.
|
||||||
|
fn emit_data_ns_text(s: &mut String, indent: &str, tag: &str, value: &str) {
|
||||||
|
s.push_str(indent);
|
||||||
|
s.push('<');
|
||||||
|
s.push_str(tag);
|
||||||
|
s.push_str(" xmlns=\"");
|
||||||
|
s.push_str(DATA_NS);
|
||||||
|
s.push_str("\">");
|
||||||
|
write_xml_escaped_text(s, value);
|
||||||
|
s.push_str("</");
|
||||||
|
s.push_str(tag);
|
||||||
|
s.push_str(">\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit `<Tag xmlns="IOM_DATA_NS">value</Tag>\r\n`.
|
||||||
|
fn emit_iom_text(s: &mut String, indent: &str, tag: &str, value: &str) {
|
||||||
|
s.push_str(indent);
|
||||||
|
s.push('<');
|
||||||
|
s.push_str(tag);
|
||||||
|
s.push_str(" xmlns=\"");
|
||||||
|
s.push_str(IOM_DATA_NS);
|
||||||
|
s.push_str("\">");
|
||||||
|
write_xml_escaped_text(s, value);
|
||||||
|
s.push_str("</");
|
||||||
|
s.push_str(tag);
|
||||||
|
s.push_str(">\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a string-typed `[XmlElement(IsNullable = true)]` field. Three
|
||||||
|
/// cases per the captured fixtures:
|
||||||
|
/// * `None` → `<Tag xsi:nil="true" xmlns="IOM_DATA_NS" />\r\n`
|
||||||
|
/// * `Some("")` → `<Tag xmlns="IOM_DATA_NS" />\r\n`
|
||||||
|
/// * `Some(s)` → `<Tag xmlns="IOM_DATA_NS">s</Tag>\r\n`
|
||||||
|
fn emit_iom_optional_string(s: &mut String, indent: &str, tag: &str, value: Option<&str>) {
|
||||||
|
s.push_str(indent);
|
||||||
|
s.push('<');
|
||||||
|
s.push_str(tag);
|
||||||
|
match value {
|
||||||
|
None => {
|
||||||
|
// Note: xsi:nil first, THEN xmlns, per fixtures.
|
||||||
|
s.push_str(" xsi:nil=\"true\" xmlns=\"");
|
||||||
|
s.push_str(IOM_DATA_NS);
|
||||||
|
s.push_str("\" />\r\n");
|
||||||
|
}
|
||||||
|
Some("") => {
|
||||||
|
s.push_str(" xmlns=\"");
|
||||||
|
s.push_str(IOM_DATA_NS);
|
||||||
|
s.push_str("\" />\r\n");
|
||||||
|
}
|
||||||
|
Some(text) => {
|
||||||
|
s.push_str(" xmlns=\"");
|
||||||
|
s.push_str(IOM_DATA_NS);
|
||||||
|
s.push_str("\">");
|
||||||
|
write_xml_escaped_text(s, text);
|
||||||
|
s.push_str("</");
|
||||||
|
s.push_str(tag);
|
||||||
|
s.push_str(">\r\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit a `bool` field in the default invensys namespace (no xmlns
|
||||||
|
/// redeclaration).
|
||||||
|
fn emit_invensys_bool(s: &mut String, indent: &str, tag: &str, value: bool) {
|
||||||
|
s.push_str(indent);
|
||||||
|
s.push('<');
|
||||||
|
s.push_str(tag);
|
||||||
|
s.push('>');
|
||||||
|
s.push_str(if value { "true" } else { "false" });
|
||||||
|
s.push_str("</");
|
||||||
|
s.push_str(tag);
|
||||||
|
s.push_str(">\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// XML-escape characters that XmlSerializer escapes in text nodes.
|
||||||
|
/// Only `<`, `>`, and `&` are emitted as entities by the .NET writer;
|
||||||
|
/// quotes appear inside attribute values which we control directly,
|
||||||
|
/// not in text content. (Verified via `XmlTextWriter.WriteString` —
|
||||||
|
/// CRLF/TAB are passed through verbatim.)
|
||||||
|
fn write_xml_escaped_text(out: &mut String, text: &str) {
|
||||||
|
for c in text.chars() {
|
||||||
|
match c {
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
other => out.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode raw bytes as base64 in the form `XmlSerializer` emits for
|
||||||
|
/// `byte[]` fields. Mirrors the inline encoder in
|
||||||
|
/// `envelope::base64_encode` (kept private there); duplicated here to
|
||||||
|
/// keep the xml_canonical module standalone.
|
||||||
|
pub fn base64_encode(input: &[u8]) -> String {
|
||||||
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let lookup = |idx: u32| ALPHABET.get((idx & 0x3F) as usize).copied().unwrap_or(b'=');
|
||||||
|
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||||
|
for chunk in input.chunks(3) {
|
||||||
|
let b0 = u32::from(chunk.first().copied().unwrap_or(0));
|
||||||
|
let b1 = u32::from(chunk.get(1).copied().unwrap_or(0));
|
||||||
|
let b2 = u32::from(chunk.get(2).copied().unwrap_or(0));
|
||||||
|
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||||
|
out.push(lookup(triple >> 18) as char);
|
||||||
|
out.push(lookup(triple >> 12) as char);
|
||||||
|
out.push(if chunk.len() > 1 {
|
||||||
|
lookup(triple >> 6) as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
out.push(if chunk.len() > 2 {
|
||||||
|
lookup(triple) as char
|
||||||
|
} else {
|
||||||
|
'='
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::ConnectionValidator;
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> Vec<u8> {
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("tests/fixtures/signed-xml")
|
||||||
|
.join(name);
|
||||||
|
std::fs::read(&path).unwrap_or_else(|e| {
|
||||||
|
panic!("could not read fixture {}: {e}", path.display())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pinned_validator() -> ConnectionValidator {
|
||||||
|
let mac: Vec<u8> = (0u8..16).collect();
|
||||||
|
let iv: Vec<u8> = (16u8..32).collect();
|
||||||
|
ConnectionValidator {
|
||||||
|
connection_id: parse_pinned_guid(),
|
||||||
|
message_number: 42,
|
||||||
|
mac_base64: base64_encode(&mac),
|
||||||
|
iv_base64: base64_encode(&iv),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `8cba964a-74c1-ef74-f6aa-761b3540191b` in .NET mixed-endian
|
||||||
|
/// byte order — same value the .NET probe pins.
|
||||||
|
fn parse_pinned_guid() -> [u8; 16] {
|
||||||
|
// d1 = 0x8cba964a (LE) → bytes [4a, 96, ba, 8c]
|
||||||
|
// d2 = 0x74c1 (LE) → bytes [c1, 74]
|
||||||
|
// d3 = 0xef74 (LE) → bytes [74, ef]
|
||||||
|
// d4 (BE) = f6 aa
|
||||||
|
// d5 (BE) = 76 1b 35 40 19 1b
|
||||||
|
[
|
||||||
|
0x4a, 0x96, 0xba, 0x8c, 0xc1, 0x74, 0x74, 0xef, 0xf6, 0xaa, 0x76, 0x1b, 0x35, 0x40,
|
||||||
|
0x19, 0x1b,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pinned_consumer_data_b64() -> String {
|
||||||
|
// "deterministic-ciphertext-bytes" base64-encoded
|
||||||
|
base64_encode(b"deterministic-ciphertext-bytes".as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pinned_consumer_iv_b64() -> String {
|
||||||
|
// "0123456789abcdef" base64-encoded
|
||||||
|
base64_encode(b"0123456789abcdef".as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pinned_disconnect_data_b64() -> String {
|
||||||
|
base64_encode(b"disconnect-ciphertext".as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actual signing input has empty MAC + IV (the MAC is filled
|
||||||
|
/// AFTER `request.ToXml()` produces the bytes that get HMAC'd). This
|
||||||
|
/// fixture pins XmlSerializer's empty-byte-array behaviour:
|
||||||
|
/// `<MessageAuthenticationCode xmlns="..." />` (self-closing) when
|
||||||
|
/// `byte[] = []`. Without this round-trip, the live HMAC will not
|
||||||
|
/// match the server's recomputation.
|
||||||
|
#[test]
|
||||||
|
fn authenticate_me_with_empty_mac_iv_matches_dotnet_fixture() {
|
||||||
|
let validator = ConnectionValidator {
|
||||||
|
connection_id: parse_pinned_guid(),
|
||||||
|
message_number: 42,
|
||||||
|
mac_base64: String::new(),
|
||||||
|
iv_base64: String::new(),
|
||||||
|
};
|
||||||
|
let data = pinned_consumer_data_b64();
|
||||||
|
let iv = pinned_consumer_iv_b64();
|
||||||
|
let actual = emit_authenticate_me_xml(&validator, &data, &iv);
|
||||||
|
let expected = fixture("authenticate-me-empty-mac-iv.xml");
|
||||||
|
assert_eq_bytes("authenticate-me-empty-mac-iv", &actual, &expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authenticate_me_matches_dotnet_fixture() {
|
||||||
|
let validator = pinned_validator();
|
||||||
|
let data = pinned_consumer_data_b64();
|
||||||
|
let iv = pinned_consumer_iv_b64();
|
||||||
|
let actual = emit_authenticate_me_xml(&validator, &data, &iv);
|
||||||
|
let expected = fixture("authenticate-me.xml");
|
||||||
|
assert_eq_bytes("authenticate-me", &actual, &expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disconnect_matches_dotnet_fixture() {
|
||||||
|
let validator = pinned_validator();
|
||||||
|
let data = pinned_disconnect_data_b64();
|
||||||
|
let iv = pinned_consumer_iv_b64();
|
||||||
|
let actual = emit_disconnect_xml(&validator, &data, &iv);
|
||||||
|
let expected = fixture("disconnect.xml");
|
||||||
|
assert_eq_bytes("disconnect", &actual, &expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keep_alive_matches_dotnet_fixture() {
|
||||||
|
let validator = pinned_validator();
|
||||||
|
let actual = emit_keep_alive_xml(&validator);
|
||||||
|
let expected = fixture("keep-alive.xml");
|
||||||
|
assert_eq_bytes("keep-alive", &actual, &expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_items_matches_dotnet_fixture() {
|
||||||
|
let validator = pinned_validator();
|
||||||
|
let item = ItemIdentity {
|
||||||
|
kind: 0,
|
||||||
|
reference_type: 1,
|
||||||
|
name: Some("TestChildObject.TestInt".to_string()),
|
||||||
|
context_name: Some(String::new()),
|
||||||
|
id: 0,
|
||||||
|
id_specified: false,
|
||||||
|
};
|
||||||
|
let actual = emit_register_items_request_xml(&validator, &[item], true, false);
|
||||||
|
let expected = fixture("register-items.xml");
|
||||||
|
assert_eq_bytes("register-items", &actual, &expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregister_items_matches_dotnet_fixture() {
|
||||||
|
let validator = pinned_validator();
|
||||||
|
let item = ItemIdentity {
|
||||||
|
kind: 1,
|
||||||
|
reference_type: 1,
|
||||||
|
name: None,
|
||||||
|
context_name: None,
|
||||||
|
id: 0xCAFE_BABE_DEAD_BEEFu64,
|
||||||
|
id_specified: true,
|
||||||
|
};
|
||||||
|
let actual = emit_unregister_items_request_xml(&validator, &[item]);
|
||||||
|
let expected = fixture("unregister-items.xml");
|
||||||
|
assert_eq_bytes("unregister-items", &actual, &expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// XML escaping: feed a name with `<` and `&` and confirm the
|
||||||
|
/// emitter produces `<` and `&`. Real wire never carries
|
||||||
|
/// these characters in tag names, but this protects against future
|
||||||
|
/// users-supplied-tag-name regressions.
|
||||||
|
#[test]
|
||||||
|
fn xml_escapes_text_content() {
|
||||||
|
let mut s = String::new();
|
||||||
|
write_xml_escaped_text(&mut s, "a < b & c > d");
|
||||||
|
assert_eq!(s, "a < b & c > d");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[track_caller]
|
||||||
|
fn assert_eq_bytes(label: &str, actual: &[u8], expected: &[u8]) {
|
||||||
|
if actual == expected {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let actual_str = String::from_utf8_lossy(actual);
|
||||||
|
let expected_str = String::from_utf8_lossy(expected);
|
||||||
|
let diverge = actual
|
||||||
|
.iter()
|
||||||
|
.zip(expected.iter())
|
||||||
|
.take_while(|(a, e)| a == e)
|
||||||
|
.count();
|
||||||
|
let context_start = diverge.saturating_sub(40);
|
||||||
|
let context_end_act = (diverge + 40).min(actual.len());
|
||||||
|
let context_end_exp = (diverge + 40).min(expected.len());
|
||||||
|
let actual_ctx = actual.get(context_start..context_end_act).unwrap_or(&[]);
|
||||||
|
let expected_ctx = expected.get(context_start..context_end_exp).unwrap_or(&[]);
|
||||||
|
panic!(
|
||||||
|
"{label}: bytes differ at offset {diverge}\n actual len={} bytes\n expected len={} bytes\n actual context: {:?}\n expected ctx: {:?}\n full actual:\n{}\n full expected:\n{}",
|
||||||
|
actual.len(),
|
||||||
|
expected.len(),
|
||||||
|
String::from_utf8_lossy(actual_ctx),
|
||||||
|
String::from_utf8_lossy(expected_ctx),
|
||||||
|
actual_str,
|
||||||
|
expected_str,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-16"?>
|
||||||
|
<AuthenticateMe xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="urn:invensys.schemas">
|
||||||
|
<ConnectionValidator>
|
||||||
|
<ConnectionId xmlns="http://asb.contracts.data/20111111">8cba964a-74c1-ef74-f6aa-761b3540191b</ConnectionId>
|
||||||
|
<MessageNumber xmlns="http://asb.contracts.data/20111111">42</MessageNumber>
|
||||||
|
<MessageAuthenticationCode xmlns="http://asb.contracts.data/20111111" />
|
||||||
|
<SignatureInitializationVector xmlns="http://asb.contracts.data/20111111" />
|
||||||
|
</ConnectionValidator>
|
||||||
|
<ConsumerAuthenticationData>
|
||||||
|
<Data xmlns="http://asb.contracts.data/20111111">ZGV0ZXJtaW5pc3RpYy1jaXBoZXJ0ZXh0LWJ5dGVz</Data>
|
||||||
|
<InitializationVector xmlns="http://asb.contracts.data/20111111">MDEyMzQ1Njc4OWFiY2RlZg==</InitializationVector>
|
||||||
|
</ConsumerAuthenticationData>
|
||||||
|
</AuthenticateMe>
|
||||||
@@ -34,7 +34,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use mxaccess::AsbTransport;
|
use mxaccess::AsbTransport;
|
||||||
use mxaccess_asb::ItemIdentity;
|
use mxaccess_asb::ItemIdentity;
|
||||||
use mxaccess_asb_nettcp::auth::CryptoParameters;
|
use mxaccess_asb_nettcp::auth::{CryptoParameters, HashAlgorithm};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -48,10 +48,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
eprintln!("connecting ASB at {} via {} ...", env.addr, env.via_uri);
|
eprintln!("connecting ASB at {} via {} ...", env.addr, env.via_uri);
|
||||||
let connection_id = generate_connection_id();
|
let connection_id = generate_connection_id();
|
||||||
|
// Each AVEVA install picks its own DH group at install time and
|
||||||
|
// stores it under HKLM\SOFTWARE\Wow6432Node\ArchestrA\
|
||||||
|
// ArchestrAServices\<solution>\{prime,generator,hashAlgorithm,
|
||||||
|
// keySize}. `CryptoParameters::defaults` falls back to the .NET
|
||||||
|
// reference's 1024-bit default — fine for unit tests but will not
|
||||||
|
// match a live AVEVA install (768-bit primes are typical). The
|
||||||
|
// companion loader `tools/Get-AsbPassphrase.ps1` exports the
|
||||||
|
// registry-stored values as MX_ASB_DH_* env vars; if they're set,
|
||||||
|
// honour them.
|
||||||
|
let crypto = build_crypto_parameters_from_env();
|
||||||
let (mut transport, response) = AsbTransport::connect(
|
let (mut transport, response) = AsbTransport::connect(
|
||||||
env.addr,
|
env.addr,
|
||||||
&env.passphrase,
|
&env.passphrase,
|
||||||
&CryptoParameters::defaults(),
|
&crypto,
|
||||||
&env.via_uri,
|
&env.via_uri,
|
||||||
connection_id,
|
connection_id,
|
||||||
)
|
)
|
||||||
@@ -157,3 +167,42 @@ fn generate_connection_id() -> [u8; 16] {
|
|||||||
rand::thread_rng().fill_bytes(&mut bytes);
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
bytes
|
bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build `CryptoParameters` from `MX_ASB_DH_*` env vars, falling back
|
||||||
|
/// to `CryptoParameters::defaults()` for any missing field. Each
|
||||||
|
/// AVEVA install stores its own DH group (prime, generator, hash,
|
||||||
|
/// key-size) under
|
||||||
|
/// `HKLM\SOFTWARE\Wow6432Node\ArchestrA\ArchestrAServices\<solution>\`;
|
||||||
|
/// the companion loader `tools/Get-AsbPassphrase.ps1` exports those
|
||||||
|
/// values so the live-bring-up example doesn't have to read the
|
||||||
|
/// registry directly (which would pull in a Windows-only crate dep
|
||||||
|
/// for what is supposed to be a portable example).
|
||||||
|
fn build_crypto_parameters_from_env() -> CryptoParameters {
|
||||||
|
let mut params = CryptoParameters::defaults();
|
||||||
|
if let Ok(prime) = std::env::var("MX_ASB_DH_PRIME") {
|
||||||
|
params.prime_decimal = prime;
|
||||||
|
}
|
||||||
|
if let Ok(generator) = std::env::var("MX_ASB_DH_GENERATOR") {
|
||||||
|
params.generator_decimal = generator;
|
||||||
|
}
|
||||||
|
if let Ok(hash) = std::env::var("MX_ASB_DH_HASH_ALGORITHM") {
|
||||||
|
// Empty / unrecognised maps to `Unrecognised`, NOT to the
|
||||||
|
// library default. .NET's `AsbSystemAuthenticator.CreateHmac`
|
||||||
|
// (`AsbSystemAuthenticator.cs:84-93`) treats an empty
|
||||||
|
// hashAlgorithm registry value as "fall through to forceHmac
|
||||||
|
// path" (HMAC-SHA1 for AuthenticateMe). Our `Unrecognised`
|
||||||
|
// variant has matching semantics (`auth.rs:303-309`).
|
||||||
|
params.hash_algorithm = match hash.to_ascii_lowercase().as_str() {
|
||||||
|
"md5" => HashAlgorithm::Md5,
|
||||||
|
"sha1" => HashAlgorithm::Sha1,
|
||||||
|
"sha512" => HashAlgorithm::Sha512,
|
||||||
|
_ => HashAlgorithm::Unrecognised,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Ok(size) = std::env::var("MX_ASB_DH_KEY_SIZE") {
|
||||||
|
if let Ok(parsed) = size.parse::<u32>() {
|
||||||
|
params.key_size_bits = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,32 @@ if (args.Any(arg => arg.Equals("--dump-signed-xml", StringComparison.OrdinalIgno
|
|||||||
SignatureInitializationVector = sigIv,
|
SignatureInitializationVector = sigIv,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The actual signing flow uses an EMPTY MessageAuthenticationCode +
|
||||||
|
// SignatureInitializationVector at the time of HMAC computation
|
||||||
|
// (`AsbSystemAuthenticator.Sign:79` calls request.ToXml() while the
|
||||||
|
// validator's MAC/IV are still `[]`; the encrypt-and-fill happens
|
||||||
|
// immediately after). The Rust port has to know what XmlSerializer
|
||||||
|
// emits for `byte[] = []` to produce HMAC-matching XML — capture
|
||||||
|
// the variant with empty MAC + IV so we can pin both shapes.
|
||||||
|
ConnectionValidator emptyValidator = new()
|
||||||
|
{
|
||||||
|
ConnectionId = connectionId,
|
||||||
|
MessageNumber = 42,
|
||||||
|
MessageAuthenticationCode = [],
|
||||||
|
SignatureInitializationVector = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
AuthenticateMe authMeEmpty = new()
|
||||||
|
{
|
||||||
|
ConnectionValidator = emptyValidator,
|
||||||
|
ConsumerAuthenticationData = new AuthenticationData
|
||||||
|
{
|
||||||
|
Data = Convert.FromBase64String("ZGV0ZXJtaW5pc3RpYy1jaXBoZXJ0ZXh0LWJ5dGVz"),
|
||||||
|
InitializationVector = Convert.FromBase64String("MDEyMzQ1Njc4OWFiY2RlZg=="),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Dump("AuthenticateMe-empty-mac-iv", authMeEmpty);
|
||||||
|
|
||||||
AuthenticateMe authMe = new()
|
AuthenticateMe authMe = new()
|
||||||
{
|
{
|
||||||
ConnectionValidator = validator,
|
ConnectionValidator = validator,
|
||||||
|
|||||||
@@ -17,9 +17,18 @@ internal sealed class AsbSystemAuthenticator
|
|||||||
private readonly byte[] localPublicKey;
|
private readonly byte[] localPublicKey;
|
||||||
private byte[] remotePublicKey = [];
|
private byte[] remotePublicKey = [];
|
||||||
private ulong nextMessageNumber = 1;
|
private ulong nextMessageNumber = 1;
|
||||||
|
/// Trace callback for the F28 canonical-XML reconciliation pass —
|
||||||
|
/// when set, `Sign` dumps the request type, the UTF-8 bytes of
|
||||||
|
/// `request.ToXml()`, the resulting HMAC, and the encrypted MAC +
|
||||||
|
/// IV. Used by `MxAsbClient.Probe --dump-signed-xml` and ad-hoc
|
||||||
|
/// live runs to capture the exact bytes the server's HMAC verifier
|
||||||
|
/// recomputes against; the Rust port's `xml_canonical` emitter must
|
||||||
|
/// produce byte-identical XML for the HMAC to round-trip.
|
||||||
|
private readonly Action<string>? sharedTrace;
|
||||||
|
|
||||||
public AsbSystemAuthenticator(string passphrase, AsbSolutionCryptoParameters cryptoParameters, Action<string>? trace = null)
|
public AsbSystemAuthenticator(string passphrase, AsbSolutionCryptoParameters cryptoParameters, Action<string>? trace = null)
|
||||||
{
|
{
|
||||||
|
sharedTrace = trace;
|
||||||
dhPrime = cryptoParameters.Prime;
|
dhPrime = cryptoParameters.Prime;
|
||||||
dhGenerator = cryptoParameters.Generator;
|
dhGenerator = cryptoParameters.Generator;
|
||||||
hashAlgorithm = cryptoParameters.HashAlgorithm;
|
hashAlgorithm = cryptoParameters.HashAlgorithm;
|
||||||
@@ -76,9 +85,17 @@ internal sealed class AsbSystemAuthenticator
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(request.ToXml()));
|
string xmlText = request.ToXml();
|
||||||
|
byte[] xmlBytes = Encoding.UTF8.GetBytes(xmlText);
|
||||||
|
sharedTrace?.Invoke($"asb.sign.type={request.GetType().Name}");
|
||||||
|
sharedTrace?.Invoke($"asb.sign.xml-utf8-len={xmlBytes.Length}");
|
||||||
|
sharedTrace?.Invoke($"asb.sign.xml-b64={Convert.ToBase64String(xmlBytes)}");
|
||||||
|
byte[] hash = hmac.ComputeHash(xmlBytes);
|
||||||
|
sharedTrace?.Invoke($"asb.sign.hmac-b64={Convert.ToBase64String(hash)}");
|
||||||
validator.MessageAuthenticationCode = Encrypt(hash, out byte[] iv);
|
validator.MessageAuthenticationCode = Encrypt(hash, out byte[] iv);
|
||||||
validator.SignatureInitializationVector = iv;
|
validator.SignatureInitializationVector = iv;
|
||||||
|
sharedTrace?.Invoke($"asb.sign.encrypted-mac-b64={Convert.ToBase64String(validator.MessageAuthenticationCode)}");
|
||||||
|
sharedTrace?.Invoke($"asb.sign.iv-b64={Convert.ToBase64String(iv)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private HMAC? CreateHmac(bool forceHmac)
|
private HMAC? CreateHmac(bool forceHmac)
|
||||||
|
|||||||
@@ -68,6 +68,31 @@ function Resolve-AsbSolutionName {
|
|||||||
return $default
|
return $default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-AsbCryptoParameters {
|
||||||
|
param([string]$Solution)
|
||||||
|
# Read the per-solution `prime`, `generator`, `hashAlgorithm`, and
|
||||||
|
# `keySize` registry values. Each AVEVA install picks its own DH
|
||||||
|
# group at provisioning time, so the Rust port must use the
|
||||||
|
# registry-stored values rather than a hardcoded constant — the
|
||||||
|
# default in `CryptoParameters::defaults` is the .NET reference's
|
||||||
|
# 1024-bit fallback (`AsbRegistry.cs:66-83`), but real installs use
|
||||||
|
# smaller group sizes (768-bit prime is common). Mismatch produces a
|
||||||
|
# working `Connect` (the wire bytes are exchanged) but a broken
|
||||||
|
# `AuthenticateMe` (encrypted ConsumerData decrypts to garbage on
|
||||||
|
# the server side because the shared secret derivation diverges).
|
||||||
|
$path = "$ServicesKeyPath\$Solution"
|
||||||
|
if (-not (Test-Path $path)) {
|
||||||
|
throw "Solution registry key not found at $path."
|
||||||
|
}
|
||||||
|
$key = Get-ItemProperty -Path $path -ErrorAction Stop
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Prime = if ($key.PSObject.Properties['prime']) { $key.prime } else { $null }
|
||||||
|
Generator = if ($key.PSObject.Properties['generator']) { $key.generator } else { $null }
|
||||||
|
HashAlgorithm = if ($key.PSObject.Properties['hashAlgorithm']) { $key.hashAlgorithm } else { $null }
|
||||||
|
KeySize = if ($key.PSObject.Properties['keySize']) { $key.keySize } else { $null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Get-AsbSharedSecretBytes {
|
function Get-AsbSharedSecretBytes {
|
||||||
param([string]$Solution)
|
param([string]$Solution)
|
||||||
$path = "$ServicesKeyPath\$Solution"
|
$path = "$ServicesKeyPath\$Solution"
|
||||||
@@ -147,6 +172,34 @@ Set-LiveEnvVar -Name 'MX_ASB_SOLUTION' -Value $solution
|
|||||||
Set-LiveEnvVar -Name 'MX_ASB_GALAXY_NAME' -Value $GalaxyName
|
Set-LiveEnvVar -Name 'MX_ASB_GALAXY_NAME' -Value $GalaxyName
|
||||||
Set-LiveEnvVar -Name 'MX_ASB_PASSPHRASE' -Value $passphrase -Sensitive
|
Set-LiveEnvVar -Name 'MX_ASB_PASSPHRASE' -Value $passphrase -Sensitive
|
||||||
|
|
||||||
|
# Per-solution DH crypto parameters from the registry — must override
|
||||||
|
# the Rust port's hardcoded `CryptoParameters::defaults()` (which uses
|
||||||
|
# the .NET reference's 1024-bit default; real installs use whatever
|
||||||
|
# was provisioned at install time, often a smaller 768-bit prime).
|
||||||
|
$crypto = Get-AsbCryptoParameters -Solution $solution
|
||||||
|
if ($crypto.Prime) {
|
||||||
|
# Strip whitespace/newlines that PowerShell display would wrap into
|
||||||
|
# the shown value; the registry-stored decimal must be a single
|
||||||
|
# contiguous integer.
|
||||||
|
$primeClean = $crypto.Prime -replace '\s+', ''
|
||||||
|
Set-LiveEnvVar -Name 'MX_ASB_DH_PRIME' -Value $primeClean
|
||||||
|
} else {
|
||||||
|
Write-Host "[WARN] no `prime` value in registry — leaving Rust default in place" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
if ($crypto.Generator) {
|
||||||
|
$genClean = ($crypto.Generator.ToString()) -replace '\s+', ''
|
||||||
|
Set-LiveEnvVar -Name 'MX_ASB_DH_GENERATOR' -Value $genClean
|
||||||
|
}
|
||||||
|
# Always export, even if empty — empty string in the registry means
|
||||||
|
# "use the forceHmac fallback (HMAC-SHA1)" per `AsbSystemAuthenticator
|
||||||
|
# .cs:91-92`. The example must distinguish "no env var" (use library
|
||||||
|
# default, MD5) from "registry says empty" (Unrecognised → SHA1 when
|
||||||
|
# forced). We pick the empty-string sentinel.
|
||||||
|
Set-LiveEnvVar -Name 'MX_ASB_DH_HASH_ALGORITHM' -Value ($crypto.HashAlgorithm ?? '')
|
||||||
|
if ($crypto.KeySize) {
|
||||||
|
Set-LiveEnvVar -Name 'MX_ASB_DH_KEY_SIZE' -Value ($crypto.KeySize.ToString())
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host 'Done. Run the example with:' -ForegroundColor Green
|
Write-Host 'Done. Run the example with:' -ForegroundColor Green
|
||||||
Write-Host ' cargo run -p mxaccess --example asb-subscribe' -ForegroundColor DarkGray
|
Write-Host ' cargo run -p mxaccess --example asb-subscribe' -ForegroundColor DarkGray
|
||||||
|
|||||||
Reference in New Issue
Block a user