From f14580e0db585ff5b5c2def340d84ff368563288 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 17:31:31 -0400 Subject: [PATCH] [M5] mxaccess-asb: F28 canonical-XML signing wired + registry-driven DH params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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? 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) --- rust/crates/mxaccess-asb-nettcp/src/auth.rs | 10 + rust/crates/mxaccess-asb/src/client.rs | 151 +++-- rust/crates/mxaccess-asb/src/contracts.rs | 58 +- rust/crates/mxaccess-asb/src/envelope.rs | 2 +- rust/crates/mxaccess-asb/src/lib.rs | 1 + rust/crates/mxaccess-asb/src/xml_canonical.rs | 515 ++++++++++++++++++ .../authenticate-me-empty-mac-iv.xml | 13 + .../crates/mxaccess/examples/asb-subscribe.rs | 53 +- src/MxAsbClient.Probe/Program.cs | 26 + src/MxAsbClient/AsbSystemAuthenticator.cs | 19 +- tools/Get-AsbPassphrase.ps1 | 53 ++ 11 files changed, 850 insertions(+), 51 deletions(-) create mode 100644 rust/crates/mxaccess-asb/src/xml_canonical.rs create mode 100644 rust/crates/mxaccess-asb/tests/fixtures/signed-xml/authenticate-me-empty-mac-iv.xml diff --git a/rust/crates/mxaccess-asb-nettcp/src/auth.rs b/rust/crates/mxaccess-asb-nettcp/src/auth.rs index a086020..b193b30 100644 --- a/rust/crates/mxaccess-asb-nettcp/src/auth.rs +++ b/rust/crates/mxaccess-asb-nettcp/src/auth.rs @@ -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] { self.connection_id } diff --git a/rust/crates/mxaccess-asb/src/client.rs b/rust/crates/mxaccess-asb/src/client.rs index 6bd6c7b..9e55db9 100644 --- a/rust/crates/mxaccess-asb/src/client.rs +++ b/rust/crates/mxaccess-asb/src/client.rs @@ -186,31 +186,35 @@ impl AsbClient { /// [`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, + xml_for_signing: Option<&[u8]>, force_hmac: bool, ) -> Result { - // 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 AsbClient { 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 AsbClient { &mut self, action: &str, body_tokens: Vec, + 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 AsbClient { /// `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 AsbClient { /// 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 AsbClient { pub async fn read(&mut self, items: &[ItemIdentity]) -> Result { 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 AsbClient { ) -> Result { 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 AsbClient { ) -> Result { 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 AsbClient { ) -> Result { 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 AsbClient { ) -> Result { 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 AsbClient { ) -> Result { 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 AsbClient { pub async fn publish(&mut self, subscription_id: i64) -> Result { 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 AsbClient { ) -> Result { 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 AsbClient { require_id: bool, register_only: bool, ) -> Result { + 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 AsbClient { &mut self, items: &[ItemIdentity], ) -> Result { + 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)?) } diff --git a/rust/crates/mxaccess-asb/src/contracts.rs b/rust/crates/mxaccess-asb/src/contracts.rs index e223b85..a86f049 100644 --- a/rust/crates/mxaccess-asb/src/contracts.rs +++ b/rust/crates/mxaccess-asb/src/contracts.rs @@ -37,7 +37,7 @@ use mxaccess_codec::{AsbStatus, AsbVariant, CodecError, RuntimeValue}; /// `AsbBinary.WriteUnicodeString` per `cs:1622-1633`: /// * Null/empty → 4-byte `0u32` length, no payload /// * Non-empty → 4-byte byte-length + UTF-16LE bytes -#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ItemIdentity { pub kind: u16, pub reference_type: u16, @@ -47,6 +47,24 @@ pub struct ItemIdentity { 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`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u16)] @@ -78,7 +96,14 @@ impl ItemIdentity { kind: ItemIdentityType::Name as u16, reference_type: ItemReferenceType::Absolute as u16, 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 + // `` (self-closing) while null + // produces ``. 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_specified: false, } @@ -374,13 +399,19 @@ fn write_unicode_string(out: &mut Vec, value: Option<&str>) { } /// Mirror `AsbBinary.ReadUnicodeString` at `cs:1616-1620`. Length 0 -/// returns `None` (matches `string.Empty` in .NET — both forms collapse -/// to a Rust `None` here so callers can distinguish unset from empty by -/// asserting on the original string). +/// → `Some(String::new())` to match .NET's behaviour (the C# code +/// returns `string.Empty` for length 0, NOT `null`). The wire format +/// 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, CodecError> { let len = read_u32_le(input, cursor)? as usize; if len == 0 { - return Ok(None); + return Ok(Some(String::new())); } if len % 2 != 0 { return Err(CodecError::Decode { @@ -508,17 +539,24 @@ mod tests { #[test] 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(); write_unicode_string(&mut buf, None); 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(); write_unicode_string(&mut buf, Some("")); 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 let mut buf = Vec::new(); diff --git a/rust/crates/mxaccess-asb/src/envelope.rs b/rust/crates/mxaccess-asb/src/envelope.rs index ed85c09..531316d 100644 --- a/rust/crates/mxaccess-asb/src/envelope.rs +++ b/rust/crates/mxaccess-asb/src/envelope.rs @@ -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 /// little-endian, the next 2x2 are little-endian, the last 2+6 are /// 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 d2 = u16::from_le_bytes([bytes[4], bytes[5]]); let d3 = u16::from_le_bytes([bytes[6], bytes[7]]); diff --git a/rust/crates/mxaccess-asb/src/lib.rs b/rust/crates/mxaccess-asb/src/lib.rs index 918eb2c..b939ee4 100644 --- a/rust/crates/mxaccess-asb/src/lib.rs +++ b/rust/crates/mxaccess-asb/src/lib.rs @@ -13,6 +13,7 @@ pub mod client; pub mod contracts; pub mod envelope; pub mod operations; +pub mod xml_canonical; pub use client::{AsbClient, ClientError, PreambleMode}; diff --git a/rust/crates/mxaccess-asb/src/xml_canonical.rs b/rust/crates/mxaccess-asb/src/xml_canonical.rs new file mode 100644 index 0000000..7bbeff7 --- /dev/null +++ b/rust/crates/mxaccess-asb/src/xml_canonical.rs @@ -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)]` → +//! ``. Empty string → self-closing +//! ``. +//! 6. `*Specified` pattern: `XxxSpecified = true` triggers `` 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: `` (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 = "\r\n"; + +// ---- public emitters ----------------------------------------------------- + +/// `` per `AsbContracts.cs:102-107`. +pub fn emit_authenticate_me_xml( + validator: &ConnectionValidator, + consumer_data_b64: &str, + consumer_iv_b64: &str, +) -> Vec { + emit_top("AuthenticateMe", |s| { + emit_validator(s, validator); + emit_authentication_data_field(s, "ConsumerAuthenticationData", consumer_data_b64, consumer_iv_b64); + }) +} + +/// `` 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 { + emit_top("Disconnect", |s| { + emit_validator(s, validator); + emit_authentication_data_field(s, "ConsumerAuthenticationData", consumer_data_b64, consumer_iv_b64); + }) +} + +/// `` per `AsbContracts.cs:116-117`. Empty body — only the +/// inherited `ConnectionValidator` header. +pub fn emit_keep_alive_xml(validator: &ConnectionValidator) -> Vec { + emit_top("KeepAlive", |s| { + emit_validator(s, validator); + }) +} + +/// `` 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 { + 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); + }) +} + +/// `` 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 { + emit_top("UnregisterItemsRequest", |s| { + emit_validator(s, validator); + for item in items { + emit_item_identity(s, item); + } + }) +} + +// ---- internal helpers ---------------------------------------------------- + +fn emit_top(class_name: &str, body: F) -> Vec { + 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.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 +/// ``. After signing they +/// carry base64 content. Both forms must round-trip. +fn emit_validator(s: &mut String, v: &ConnectionValidator) { + s.push_str(" \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(" \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(" \r\n"); +} + +/// `` 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 `` element is emitted. +/// +/// Null Name/ContextName → ``; +/// empty-string ContextName → self-closing ``. +fn emit_item_identity(s: &mut String, item: &ItemIdentity) { + s.push_str(" \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(" \r\n"); +} + +/// Emit a `byte[]` field in the data namespace. Empty bytes (empty +/// base64 string) → self-closing ``; non-empty → +/// `b64`. 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 `value\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("\r\n"); +} + +/// Emit `value\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("\r\n"); +} + +/// Emit a string-typed `[XmlElement(IsNullable = true)]` field. Three +/// cases per the captured fixtures: +/// * `None` → `\r\n` +/// * `Some("")` → `\r\n` +/// * `Some(s)` → `s\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("\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("\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 { + 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 = (0u8..16).collect(); + let iv: Vec = (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: + /// `` (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, + ); + } +} diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/authenticate-me-empty-mac-iv.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/authenticate-me-empty-mac-iv.xml new file mode 100644 index 0000000..86f38b0 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/authenticate-me-empty-mac-iv.xml @@ -0,0 +1,13 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + + + + + ZGV0ZXJtaW5pc3RpYy1jaXBoZXJ0ZXh0LWJ5dGVz + MDEyMzQ1Njc4OWFiY2RlZg== + + \ No newline at end of file diff --git a/rust/crates/mxaccess/examples/asb-subscribe.rs b/rust/crates/mxaccess/examples/asb-subscribe.rs index 28f2fe2..9c0756b 100644 --- a/rust/crates/mxaccess/examples/asb-subscribe.rs +++ b/rust/crates/mxaccess/examples/asb-subscribe.rs @@ -34,7 +34,7 @@ use std::time::Duration; use mxaccess::AsbTransport; use mxaccess_asb::ItemIdentity; -use mxaccess_asb_nettcp::auth::CryptoParameters; +use mxaccess_asb_nettcp::auth::{CryptoParameters, HashAlgorithm}; #[tokio::main] async fn main() -> Result<(), Box> { @@ -48,10 +48,20 @@ async fn main() -> Result<(), Box> { eprintln!("connecting ASB at {} via {} ...", env.addr, env.via_uri); 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\\{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( env.addr, &env.passphrase, - &CryptoParameters::defaults(), + &crypto, &env.via_uri, connection_id, ) @@ -157,3 +167,42 @@ fn generate_connection_id() -> [u8; 16] { rand::thread_rng().fill_bytes(&mut 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\\`; +/// 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::() { + params.key_size_bits = parsed; + } + } + params +} diff --git a/src/MxAsbClient.Probe/Program.cs b/src/MxAsbClient.Probe/Program.cs index 6d87e8f..eee2859 100644 --- a/src/MxAsbClient.Probe/Program.cs +++ b/src/MxAsbClient.Probe/Program.cs @@ -84,6 +84,32 @@ if (args.Any(arg => arg.Equals("--dump-signed-xml", StringComparison.OrdinalIgno 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() { ConnectionValidator = validator, diff --git a/src/MxAsbClient/AsbSystemAuthenticator.cs b/src/MxAsbClient/AsbSystemAuthenticator.cs index eaad348..f306f1f 100644 --- a/src/MxAsbClient/AsbSystemAuthenticator.cs +++ b/src/MxAsbClient/AsbSystemAuthenticator.cs @@ -17,9 +17,18 @@ internal sealed class AsbSystemAuthenticator private readonly byte[] localPublicKey; private byte[] remotePublicKey = []; 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? sharedTrace; public AsbSystemAuthenticator(string passphrase, AsbSolutionCryptoParameters cryptoParameters, Action? trace = null) { + sharedTrace = trace; dhPrime = cryptoParameters.Prime; dhGenerator = cryptoParameters.Generator; hashAlgorithm = cryptoParameters.HashAlgorithm; @@ -76,9 +85,17 @@ internal sealed class AsbSystemAuthenticator 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.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) diff --git a/tools/Get-AsbPassphrase.ps1 b/tools/Get-AsbPassphrase.ps1 index 03b85d2..c44e166 100644 --- a/tools/Get-AsbPassphrase.ps1 +++ b/tools/Get-AsbPassphrase.ps1 @@ -68,6 +68,31 @@ function Resolve-AsbSolutionName { 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 { param([string]$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_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 'Done. Run the example with:' -ForegroundColor Green Write-Host ' cargo run -p mxaccess --example asb-subscribe' -ForegroundColor DarkGray