diff --git a/design/followups.md b/design/followups.md index 55519d9..5d208c5 100644 --- a/design/followups.md +++ b/design/followups.md @@ -139,7 +139,9 @@ F25 (`mxaccess-asb` IASBIDataV2 client) and F26 (`mxaccess::Session` over `AsbTr **Source:** F25 live-bring-up; `AsbSystemAuthenticator.cs:79` + `AsbSerialization.cs:12-48`. **Why deferred:** `AsbSystemAuthenticator.Sign` HMACs `Encoding.UTF8.GetBytes(request.ToXml())` — the XML text produced by .NET's `XmlSerializer.Serialize(writer, value)` with `XmlSerializerNamespaces` = `"urn:invensys.schemas"`, then re-parsed via `XDocument.Load` and re-saved to normalise xmlns attribute ordering (xsi before xsd; see `AsbSerialization.cs:36-47`). The HMAC must match the server's recomputation, which uses the same XmlSerializer on the deserialised request — so the Rust port has to produce byte-identical XML. We currently HMAC the NBFX wire bytes of the unsigned envelope, which never matches. -**Resolves when:** A canonical XmlSerializer-compatible emitter lands in `mxaccess-asb` (probably `crates/mxaccess-asb/src/xml_canonical.rs`). Scope per request type: `AuthenticateMe`, `Disconnect`, `KeepAlive`, `RegisterItemsRequest`, `UnregisterItemsRequest`, `ReadRequest`, `WriteBasicRequest`, `PublishWriteCompleteRequest`, `CreateSubscriptionRequest`, `DeleteSubscriptionRequest`, `AddMonitoredItemsRequest`, `DeleteMonitoredItemsRequest`, `PublishRequest`. Each derives its XML form from the `[MessageContract] / [MessageBodyMember(Order = N, Namespace = ...)]` attributes plus per-type `[XmlType(Namespace = ...)]` on `AuthenticationData` / `PublicKey`. Validation: capture .NET probe's `request.ToXml()` output for each operation (instrument `AsbSystemAuthenticator.Sign` with a hex+UTF-8 trace, run `MxAsbClient.Probe`) and assert byte-equal vs the Rust emitter. The `request_xml_utf8` argument to `AsbAuthenticator::sign` is already wired correctly — only the producer is missing. Once HMAC matches, the existing `ConnectionValidator` header path (`mac` + `iv` base64 round-trip) is already validated by the F23 unit tests. **Resolves**: F25 live AuthenticateMe + RegisterItems + every signed operation; M5 DoD bullets 1+2 unblocked. +**Resolves when:** A canonical XmlSerializer-compatible emitter lands in `mxaccess-asb` (probably `crates/mxaccess-asb/src/xml_canonical.rs`). Scope per request type: `AuthenticateMe`, `Disconnect`, `KeepAlive`, `RegisterItemsRequest`, `UnregisterItemsRequest`, `ReadRequest`, `WriteBasicRequest`, `PublishWriteCompleteRequest`, `CreateSubscriptionRequest`, `DeleteSubscriptionRequest`, `AddMonitoredItemsRequest`, `DeleteMonitoredItemsRequest`, `PublishRequest`. Each derives its XML form from the `[MessageContract] / [MessageBodyMember(Order = N, Namespace = ...)]` attributes plus per-type `[XmlType(Namespace = ...)]` on `AuthenticationData` / `PublicKey`. The `request_xml_utf8` argument to `AsbAuthenticator::sign` is already wired correctly — only the producer is missing. Once HMAC matches, the existing `ConnectionValidator` header path (`mac` + `iv` base64 round-trip) is already validated by the F23 unit tests. **Resolves**: F25 live AuthenticateMe + RegisterItems + every signed operation; M5 DoD bullets 1+2 unblocked. + +**Captured fixtures (this commit).** `MxAsbClient.Probe --dump-signed-xml` (new flag, 2026-05-05) produces canonical `request.ToXml()` output for the five primary ConnectedRequest shapes; fixtures saved under `rust/crates/mxaccess-asb/tests/fixtures/signed-xml/{authenticate-me,disconnect,keep-alive,register-items,unregister-items}.xml`. Byte sizes pinned: 1000/980/705/1068/1072. The companion `README.md` documents 10 inferred XmlSerializer rules — most importantly: (1) element name = class name (NOT MessageContract.WrapperName), (2) field order = C# declaration order (NOT [MessageBodyMember.Order]), (3) `[XmlType(Namespace=...)]` on a field's type causes per-child xmlns redeclaration on the children, NOT the wrapper element, (4) the `*Specified` pattern controls whether `` is emitted, (5) CRLF line endings + 2-space indent + UTF-8-bytes-of-utf-16-declaration. The Rust emitter has a concrete byte target to converge on. ### F29 — Align `mxaccess-asb-nettcp::nbfs` static dictionary ids with canonical `[MC-NBFS]` table **Severity:** P2 — diagnostic-only today; blocks future fault-body decoding. diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/.gitattributes b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/.gitattributes new file mode 100644 index 0000000..69dc9b8 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/.gitattributes @@ -0,0 +1,7 @@ +# These fixtures are byte-equal targets for the F28 canonical XML +# emitter — `XmlSerializer.Serialize(...)` output that the .NET +# reference HMACs in `AsbSystemAuthenticator.Sign`. CRLF line endings +# are part of the canonical form (StringWriter default on Windows), +# so Git MUST NOT touch them. `-text` marks them as binary so neither +# `core.autocrlf` nor `text` filters can rewrite the bytes. +*.xml -text diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/README.md b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/README.md new file mode 100644 index 0000000..604f6de --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/README.md @@ -0,0 +1,99 @@ +# Signed-request XML fixtures + +Canonical `XmlSerializer` output for every `ConnectedRequest` shape that +the .NET reference HMACs in `AsbSystemAuthenticator.Sign` +(`src/MxAsbClient/AsbSystemAuthenticator.cs:79`). The Rust port's +canonical-XML emitter (F28) must produce these exact UTF-8 bytes for +the HMAC to match the server's recomputation. + +## Capture procedure + +```powershell +dotnet run --project src\MxAsbClient.Probe -c Release -- --dump-signed-xml > capture.txt +``` + +The probe's `--dump-signed-xml` flag (added 2026-05-05) builds each +shape with deterministic field values and prints the output of +`AsbSerialization.ToXml(...)` (`src/MxAsbClient/AsbSerialization.cs:12`). + +## Pinned values + +All shapes use the same `ConnectionValidator`: +- `ConnectionId = 8cba964a-74c1-ef74-f6aa-761b3540191b` +- `MessageNumber = 42` +- `MessageAuthenticationCode = AAECAwQFBgcICQoLDA0ODw==` (base64 of bytes 0..15) +- `SignatureInitializationVector = EBESExQVFhcYGRobHB0eHw==` (base64 of bytes 16..31) + +`AuthenticateMe` and `Disconnect` use `AuthenticationData` with: +- `Data = "deterministic-ciphertext-bytes"` (base64-encoded) +- `InitializationVector = "0123456789abcdef"` (base64-encoded) + +`RegisterItemsRequest` uses one `ItemIdentity` with +`Type = Name (0)`, `ReferenceType = Absolute (1)`, +`Name = "TestChildObject.TestInt"`, `ContextName = ""`. + +`UnregisterItemsRequest` uses one `ItemIdentity` with +`Type = Id (1)`, `ReferenceType = Absolute (1)`, `Name = null`, +`ContextName = null`, `Id = 0xCAFEBABEDEADBEEF (14627333968688430831)`, +`IdSpecified = true`. + +## Observed serialiser behaviour + +These rules were inferred from the captured output and from the .NET +source for `XmlSerializer`: + +1. **Element name = class name**, NOT `[MessageContract.WrapperName]`. + `XmlSerializer` does not honour WCF's MessageContract attributes. + +2. **Top-element xmlns ordering** (after ``): + `xmlns:xsi`, then `xmlns:xsd`, then default `xmlns`. + The `AsbSerialization.ToXml` post-process (`AsbSerialization.cs:36-47`) + reparses with `XDocument.Load` and reorders to put `xsi` before + `xsd` — `XmlSerializer`'s native order is the opposite. + +3. **Field order = C# declaration order** (with inherited fields + first), NOT `[MessageBodyMember.Order]`. + +4. **`[XmlType(Namespace = ...)]` on a field's type** triggers an + `xmlns="..."` redeclaration on EACH child element of that type's + instance, NOT on the wrapper element itself. e.g. inside + ``, every direct child gets + `xmlns="http://asb.contracts.data/20111111"`. + +5. **`byte[]` fields** serialise as base64 text content. + **`Guid`** as canonical lowercase D-format (`8cba964a-74c1-...`). + **`ulong`** as decimal. + **`bool`** as `"true"` / `"false"`. + +6. **Null reference-type fields** with `[XmlElement(IsNullable = true)]` + produce ``. + Empty string fields produce a self-closing ``. + +7. **`*Specified` pattern**: a public bool field named `XxxSpecified` = + `true` causes XmlSerializer to emit the corresponding `` + element. `IdSpecified = false` (default) → `` omitted. + `IdSpecified = true` → `` emitted with the int value. + The `*Specified` field itself is `[XmlIgnore]` and never emitted. + +8. **Self-closing elements** use ` />` (space before `/>`). + +9. **Indentation**: 2 spaces, `\r\n` line endings, no trailing + newline after the closing tag. + +10. **XML declaration**: `` — + note `utf-16` even though `AsbSystemAuthenticator.Sign` HMACs + `Encoding.UTF8.GetBytes(...)` of this string. The declaration is + a static .NET StringWriter default; the actual byte encoding fed + to HMAC is UTF-8. + +## Files + +- `authenticate-me.xml` — `AuthenticateMe` +- `disconnect.xml` — `Disconnect` +- `keep-alive.xml` — `KeepAlive` +- `register-items.xml` — `RegisterItemsRequest` +- `unregister-items.xml` — `UnregisterItemsRequest` + +Each file is the verbatim UTF-8 representation of `request.ToXml()`, +with literal `\r\n` line endings preserved. Treat as binary (don't +let your editor reformat). diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/authenticate-me.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/authenticate-me.xml new file mode 100644 index 0000000..e5c1683 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/authenticate-me.xml @@ -0,0 +1,13 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + + ZGV0ZXJtaW5pc3RpYy1jaXBoZXJ0ZXh0LWJ5dGVz + MDEyMzQ1Njc4OWFiY2RlZg== + + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/disconnect.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/disconnect.xml new file mode 100644 index 0000000..05770a3 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/disconnect.xml @@ -0,0 +1,13 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + + ZGlzY29ubmVjdC1jaXBoZXJ0ZXh0 + MDEyMzQ1Njc4OWFiY2RlZg== + + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/keep-alive.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/keep-alive.xml new file mode 100644 index 0000000..e861581 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/keep-alive.xml @@ -0,0 +1,9 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/register-items.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/register-items.xml new file mode 100644 index 0000000..fff9393 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/register-items.xml @@ -0,0 +1,17 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + + 0 + 1 + TestChildObject.TestInt + + + true + false + \ No newline at end of file diff --git a/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/unregister-items.xml b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/unregister-items.xml new file mode 100644 index 0000000..5d9b3e1 --- /dev/null +++ b/rust/crates/mxaccess-asb/tests/fixtures/signed-xml/unregister-items.xml @@ -0,0 +1,16 @@ + + + + 8cba964a-74c1-ef74-f6aa-761b3540191b + 42 + AAECAwQFBgcICQoLDA0ODw== + EBESExQVFhcYGRobHB0eHw== + + + 1 + 1 + + + 14627333968688430831 + + \ No newline at end of file diff --git a/src/MxAsbClient.Probe/Program.cs b/src/MxAsbClient.Probe/Program.cs index 0d4fb73..6d87e8f 100644 --- a/src/MxAsbClient.Probe/Program.cs +++ b/src/MxAsbClient.Probe/Program.cs @@ -53,6 +53,93 @@ if (args.Any(arg => arg.Equals("--dump-register-payload", StringComparison.Ordin return; } +// `--dump-signed-xml` produces deterministic .NET `XmlSerializer` output +// for each ConnectedRequest type that goes through `AsbSystemAuthenticator +// .Sign` (`AsbSystemAuthenticator.cs:79`). The output is exactly what +// the .NET HMAC computation runs over, so the Rust port's canonical-XML +// emitter (F28) needs to produce byte-identical bytes for every type +// listed here. Connection IDs, MACs, IVs, and message numbers are pinned +// to deterministic values so the dump is reproducible. +if (args.Any(arg => arg.Equals("--dump-signed-xml", StringComparison.OrdinalIgnoreCase))) +{ + Guid connectionId = Guid.Parse("8cba964a-74c1-ef74-f6aa-761b3540191b"); + byte[] mac = Convert.FromBase64String("AAECAwQFBgcICQoLDA0ODw=="); + byte[] sigIv = Convert.FromBase64String("EBESExQVFhcYGRobHB0eHw=="); + + void Dump(string label, object request) + { + string xml = AsbSerialization.ToXml(request); + byte[] xmlBytes = System.Text.Encoding.UTF8.GetBytes(xml); + Console.WriteLine($"--- {label} ({xmlBytes.Length} UTF-8 bytes) ---"); + Console.WriteLine(xml); + Console.WriteLine($"--- {label} (base64) ---"); + Console.WriteLine(Convert.ToBase64String(xmlBytes)); + } + + ConnectionValidator validator = new() + { + ConnectionId = connectionId, + MessageNumber = 42, + MessageAuthenticationCode = mac, + SignatureInitializationVector = sigIv, + }; + + AuthenticateMe authMe = new() + { + ConnectionValidator = validator, + ConsumerAuthenticationData = new AuthenticationData + { + Data = Convert.FromBase64String("ZGV0ZXJtaW5pc3RpYy1jaXBoZXJ0ZXh0LWJ5dGVz"), + InitializationVector = Convert.FromBase64String("MDEyMzQ1Njc4OWFiY2RlZg=="), + }, + }; + Dump("AuthenticateMe", authMe); + + Disconnect disconnect = new() + { + ConnectionValidator = validator, + ConsumerAuthenticationData = new AuthenticationData + { + Data = Convert.FromBase64String("ZGlzY29ubmVjdC1jaXBoZXJ0ZXh0"), + InitializationVector = Convert.FromBase64String("MDEyMzQ1Njc4OWFiY2RlZg=="), + }, + }; + Dump("Disconnect", disconnect); + + KeepAlive keepAlive = new() { ConnectionValidator = validator }; + Dump("KeepAlive", keepAlive); + + RegisterItemsRequest registerDump = new() + { + ConnectionValidator = validator, + Items = [new ItemIdentity + { + Type = (ushort)ItemIdentityType.Name, + ReferenceType = (ushort)ItemReferenceType.Absolute, + Name = "TestChildObject.TestInt", + ContextName = string.Empty, + }], + RequireId = true, + RegisterOnly = false, + }; + Dump("RegisterItemsRequest", registerDump); + + UnregisterItemsRequest unregisterDump = new() + { + ConnectionValidator = validator, + Items = [new ItemIdentity + { + Type = (ushort)ItemIdentityType.Id, + ReferenceType = (ushort)ItemReferenceType.Absolute, + Id = 0xCAFE_BABE_DEAD_BEEFul, + IdSpecified = true, + }], + }; + Dump("UnregisterItemsRequest", unregisterDump); + + return; +} + if (probeConnectFailure) { try diff --git a/src/MxAsbClient/MxAsbClient.csproj b/src/MxAsbClient/MxAsbClient.csproj index 97466e1..05354c5 100644 --- a/src/MxAsbClient/MxAsbClient.csproj +++ b/src/MxAsbClient/MxAsbClient.csproj @@ -14,4 +14,9 @@ + + + + +